feat: pivot to AI-native task management platform (#232)
Replace the agent framework codebase with a new monorepo structure for an AI-native Linear-like product where agents are first-class citizens. New architecture: - server/ — Go backend (Chi + gorilla/websocket + sqlc) - API server with REST routes for issues, agents, inbox, workspaces - WebSocket hub for real-time updates - Local daemon entry point for agent runtime connection - PostgreSQL migration with 13 tables (issue, agent, inbox, etc.) - WebSocket protocol types for server<->daemon communication - apps/web/ — Next.js 16 frontend - Dashboard layout with sidebar navigation - Route skeleton: inbox, issues, agents, board, settings - packages/ui/ — Preserved shadcn/ui design system (26+ components) - packages/types/ — Full API contract types (Issue, Agent, Workspace, Inbox, Events) - packages/sdk/ — REST ApiClient + WebSocket WSClient - packages/store/ — Zustand stores (issue, agent, inbox, auth) - packages/hooks/ — React hooks (useIssues, useAgents, useInbox, useRealtime) - packages/utils/ — Shared utilities Removed: apps/cli, apps/desktop, apps/mobile, apps/gateway, packages/core, skills/, and all agent-framework code. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3f589d8326
commit
d4f5c5b16f
677 changed files with 2779 additions and 122531 deletions
|
|
@ -1,45 +0,0 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build output
|
||||
dist
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
docs
|
||||
|
||||
# Tests
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
__tests__
|
||||
coverage
|
||||
|
||||
# Context directory
|
||||
.context
|
||||
20
.env.example
20
.env.example
|
|
@ -1,9 +1,15 @@
|
|||
# Telegram Bot
|
||||
# Get a token from @BotFather on Telegram
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
# Database
|
||||
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
|
||||
# Optional: webhook secret token for production
|
||||
# TELEGRAM_WEBHOOK_SECRET_TOKEN=
|
||||
# Server
|
||||
PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
|
||||
# Optional: webhook URL (if not set, uses long-polling mode for local dev)
|
||||
# TELEGRAM_WEBHOOK_URL=https://your-domain.ngrok-free.dev/telegram/webhook
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
||||
|
|
|
|||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
|
|
@ -11,28 +11,7 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: pnpm --filter @multica/web --filter @multica/desktop lint
|
||||
|
||||
build-and-typecheck:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -61,3 +40,21 @@ jobs:
|
|||
|
||||
- name: Build, type check, and test
|
||||
run: pnpm turbo build typecheck test
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Build
|
||||
run: cd server && go build ./...
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test ./...
|
||||
|
|
|
|||
23
.gitignore
vendored
23
.gitignore
vendored
|
|
@ -9,25 +9,22 @@ out
|
|||
.turbo
|
||||
build
|
||||
bin
|
||||
dist-electron
|
||||
release
|
||||
*.tsbuildinfo
|
||||
|
||||
# env
|
||||
.env*
|
||||
!.env.example
|
||||
!apps/desktop/.env.production
|
||||
!apps/desktop/.env.development
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
|
||||
# Go
|
||||
server/bin/
|
||||
server/tmp/
|
||||
|
||||
# context (agent workspace)
|
||||
.context
|
||||
|
||||
# platform specific
|
||||
*.dmg
|
||||
*.app
|
||||
*.apk
|
||||
*.ipa
|
||||
monorepo.md
|
||||
|
||||
# python
|
||||
__pycache__
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
CLAUDE.md
|
||||
77
CLAUDE.md
77
CLAUDE.md
|
|
@ -4,63 +4,56 @@ This file gives coding agents high-signal guidance for this repository.
|
|||
|
||||
## 1. Project Context
|
||||
|
||||
Super Multica is a distributed AI agent framework/product monorepo.
|
||||
It is used to run local-first agent workflows and support CLI/Desktop/Web/Gateway-based usage.
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
|
||||
Core purpose:
|
||||
- Agents can be assigned issues, create issues, comment, and change status
|
||||
- Supports local (daemon) and cloud agent runtimes
|
||||
- Built for 2-10 person AI-native teams
|
||||
|
||||
- execute agent tasks with tools and skills
|
||||
- persist sessions/profiles/credentials across runs
|
||||
- support development, testing, and operational automation workflows
|
||||
## 2. Architecture
|
||||
|
||||
## 2. Documentation Scope
|
||||
**Polyglot monorepo** — Go backend + TypeScript frontend.
|
||||
|
||||
Documentation in this repo should prioritize:
|
||||
|
||||
1. Development workflow
|
||||
2. Testing methods
|
||||
3. Operational process
|
||||
|
||||
Architecture explanations should stay minimal in docs.
|
||||
Treat source code as the architecture source of truth.
|
||||
- `server/` — Go backend (Chi + sqlc + gorilla/websocket)
|
||||
- `apps/web/` — Next.js 16 frontend
|
||||
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
|
||||
|
||||
## 3. Core Workflow Commands
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
pnpm install
|
||||
pnpm multica
|
||||
pnpm multica run "<prompt>"
|
||||
pnpm dev
|
||||
pnpm dev:gateway
|
||||
pnpm dev:web
|
||||
pnpm dev:local
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm dev:web # Next.js dev server
|
||||
pnpm build # Build all TS packages
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm test # TS tests
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server with hot-reload
|
||||
make daemon # Run local daemon
|
||||
make test # Go tests
|
||||
make sqlc # Regenerate sqlc code
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Infrastructure
|
||||
docker compose up -d # Start PostgreSQL
|
||||
```
|
||||
|
||||
## 4. Data and Credentials Workflow
|
||||
|
||||
- Default data dir: `~/.super-multica` (override with `SMC_DATA_DIR`)
|
||||
- Credentials: `~/.super-multica/credentials.json5` (override with `SMC_CREDENTIALS_PATH`)
|
||||
- Initialize credentials via `pnpm multica credentials init`
|
||||
|
||||
## 5. Coding Rules
|
||||
## 4. Coding Rules
|
||||
|
||||
- TypeScript strict mode is enabled; keep types explicit.
|
||||
- Go code follows standard Go conventions (gofmt, go vet).
|
||||
- Keep comments in code **English only**.
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- Keep docs concise and aligned with current code behavior.
|
||||
|
||||
## 6. Testing Rules
|
||||
## 5. Testing Rules
|
||||
|
||||
- Test runner: Vitest.
|
||||
- Mock policy: mock external/third-party dependencies only.
|
||||
- Do not mock internal modules when real integration can be tested.
|
||||
- Prefer temp directories and real file I/O for storage-related tests.
|
||||
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
|
||||
- **Go**: Standard `go test`. Use testcontainers or test database for DB tests.
|
||||
|
||||
## 7. Commit Rules
|
||||
## 6. Commit Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format:
|
||||
|
|
@ -71,14 +64,10 @@ pnpm test
|
|||
- `test(scope): ...`
|
||||
- `chore(scope): ...`
|
||||
|
||||
## 8. Minimum Pre-Push Checks
|
||||
## 7. Minimum Pre-Push Checks
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
make test
|
||||
```
|
||||
|
||||
## 9. E2E Process Docs
|
||||
|
||||
- `docs/e2e-testing-guide.md`
|
||||
- `docs/e2e-finance-benchmark.md`
|
||||
|
|
|
|||
32
Makefile
Normal file
32
Makefile
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean
|
||||
|
||||
# Go server
|
||||
dev:
|
||||
cd server && go run ./cmd/server
|
||||
|
||||
daemon:
|
||||
cd server && go run ./cmd/daemon
|
||||
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -o bin/daemon ./cmd/daemon
|
||||
|
||||
test:
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
migrate-up:
|
||||
cd server && go run ./cmd/migrate up
|
||||
|
||||
migrate-down:
|
||||
cd server && go run ./cmd/migrate down
|
||||
|
||||
sqlc:
|
||||
cd server && sqlc generate
|
||||
|
||||
seed:
|
||||
cd server && go run ./cmd/seed
|
||||
|
||||
# Cleanup
|
||||
clean:
|
||||
rm -rf server/bin server/tmp
|
||||
129
README.md
129
README.md
|
|
@ -1,129 +0,0 @@
|
|||
# Super Multica
|
||||
|
||||
Super Multica is a distributed AI agent framework and product monorepo.
|
||||
It provides a local-first agent runtime plus CLI, gateway, web, and mobile integration surfaces.
|
||||
|
||||
What this project does:
|
||||
|
||||
- runs AI agent sessions with tools, skills, and persistent session state
|
||||
- supports scheduled/automated execution workflows
|
||||
- supports both standalone local usage and remote-access client workflows
|
||||
|
||||
This repository keeps docs focused on:
|
||||
|
||||
1. Development workflow
|
||||
2. Testing workflow
|
||||
3. Operational process
|
||||
|
||||
Architecture details are still source-of-truth in code, but docs keep minimal project context for onboarding.
|
||||
|
||||
## Quick Start (Workflow)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm multica credentials init
|
||||
pnpm multica
|
||||
```
|
||||
|
||||
Run local desktop workflow:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Local Full-Stack Development (`pnpm dev:local`)
|
||||
|
||||
Use this when you need **Gateway + Web + Desktop** together for end-to-end dev.
|
||||
|
||||
Setup:
|
||||
|
||||
1. Copy `.env.example` to `.env` in repo root
|
||||
2. Set `TELEGRAM_BOT_TOKEN` in `.env` (from `@BotFather`)
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
pnpm dev:local
|
||||
```
|
||||
|
||||
What starts:
|
||||
|
||||
| Service | Address | Notes |
|
||||
|---------|---------|-------|
|
||||
| Gateway | `http://localhost:4000` | Telegram long-polling mode |
|
||||
| Web | `http://localhost:3000` | OAuth login flow |
|
||||
| Desktop | — | Connects to local Gateway + Web |
|
||||
|
||||
Data isolation:
|
||||
|
||||
- runtime data: `~/.super-multica-dev`
|
||||
- workspace data: `~/Documents/Multica-dev`
|
||||
|
||||
Related:
|
||||
|
||||
```bash
|
||||
pnpm dev:local:archive
|
||||
```
|
||||
|
||||
## Workflow Commands
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
pnpm multica
|
||||
pnpm multica run "Hello"
|
||||
pnpm multica chat
|
||||
pnpm multica help
|
||||
|
||||
# Development
|
||||
pnpm dev
|
||||
pnpm dev:desktop
|
||||
pnpm dev:gateway
|
||||
pnpm dev:web
|
||||
pnpm dev:local
|
||||
pnpm dev:local:archive
|
||||
|
||||
# Build / quality
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Testing Workflow
|
||||
|
||||
```bash
|
||||
# Unit/integration
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
pnpm test:coverage
|
||||
|
||||
# Type safety gate
|
||||
pnpm typecheck
|
||||
|
||||
# Agent E2E
|
||||
pnpm multica run --run-log "your test prompt"
|
||||
```
|
||||
|
||||
E2E process docs:
|
||||
|
||||
- `docs/e2e-testing-guide.md`
|
||||
- `docs/e2e-finance-benchmark.md`
|
||||
|
||||
## Runtime Paths
|
||||
|
||||
By default, runtime data is stored under:
|
||||
|
||||
- `~/.super-multica`
|
||||
|
||||
You can isolate environments with:
|
||||
|
||||
- `SMC_DATA_DIR=~/.super-multica-dev` (or other path)
|
||||
|
||||
## Process Docs
|
||||
|
||||
- `CLAUDE.md`
|
||||
- `docs/development.md`
|
||||
- `docs/cli.md`
|
||||
- `docs/credentials.md`
|
||||
- `docs/skills-and-tools.md`
|
||||
- `docs/package-management.md`
|
||||
- `docs/e2e-testing-guide.md`
|
||||
- `docs/e2e-finance-benchmark.md`
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"name": "@multica/cli",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"multica": "./dist/index.js",
|
||||
"mu": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
/**
|
||||
* Autocomplete Input
|
||||
*
|
||||
* Real-time dropdown autocomplete for terminal input
|
||||
* No external dependencies - uses raw terminal control
|
||||
*
|
||||
* Falls back to simple readline when terminal doesn't support advanced features
|
||||
*/
|
||||
|
||||
import * as readline from "readline";
|
||||
import { colors } from "./colors.js";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AutocompleteConfig {
|
||||
/** Function to get suggestions based on current input */
|
||||
getSuggestions: (input: string) => AutocompleteOption[];
|
||||
/** Prompt string */
|
||||
prompt?: string;
|
||||
/** Max suggestions to show */
|
||||
maxSuggestions?: number;
|
||||
}
|
||||
|
||||
// ANSI escape codes
|
||||
const ESC = "\x1b";
|
||||
const CLEAR_LINE = `${ESC}[2K`;
|
||||
const CURSOR_UP = (n: number) => (n > 0 ? `${ESC}[${n}A` : "");
|
||||
const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`;
|
||||
const RESET = `${ESC}[0m`;
|
||||
const INVERSE = `${ESC}[7m`;
|
||||
const SHOW_CURSOR = `${ESC}[?25h`;
|
||||
const CLEAR_TO_END = `${ESC}[J`;
|
||||
|
||||
// Strip ANSI escape codes to get visual length
|
||||
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(ANSI_REGEX, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visual width of a string in terminal columns
|
||||
* Full-width characters (CJK, etc.) take 2 columns
|
||||
*/
|
||||
function getStringWidth(str: string): number {
|
||||
let width = 0;
|
||||
for (const char of str) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) continue;
|
||||
|
||||
// Check for full-width characters:
|
||||
// - CJK Unified Ideographs (Chinese, Japanese Kanji, Korean Hanja)
|
||||
// - CJK Symbols and Punctuation
|
||||
// - Hiragana, Katakana
|
||||
// - Hangul
|
||||
// - Full-width ASCII and symbols
|
||||
if (
|
||||
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
|
||||
(code >= 0x2e80 && code <= 0x9fff) || // CJK
|
||||
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
|
||||
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(code >= 0xfe10 && code <= 0xfe1f) || // Vertical forms
|
||||
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
|
||||
(code >= 0xff00 && code <= 0xff60) || // Full-width ASCII
|
||||
(code >= 0xffe0 && code <= 0xffe6) || // Full-width symbols
|
||||
(code >= 0x20000 && code <= 0x2ffff) // CJK Extension B and beyond
|
||||
) {
|
||||
width += 2;
|
||||
} else {
|
||||
width += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if terminal supports advanced cursor control
|
||||
*/
|
||||
function isTerminalSupported(): boolean {
|
||||
// Check TERM environment variable
|
||||
const term = process.env.TERM;
|
||||
if (!term) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if running in known unsupported environments
|
||||
const unsupportedTerms = ["dumb", "emacs"];
|
||||
if (unsupportedTerms.includes(term.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if stdout is a TTY
|
||||
if (!process.stdout.isTTY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple readline input (fallback for unsupported terminals)
|
||||
*/
|
||||
function simpleInput(config: AutocompleteConfig): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const { prompt = "> " } = config;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a line with real-time autocomplete dropdown
|
||||
* Falls back to simple readline on unsupported terminals
|
||||
*/
|
||||
export function autocompleteInput(config: AutocompleteConfig): Promise<string> {
|
||||
// Fall back to simple input if terminal doesn't support advanced features
|
||||
if (!isTerminalSupported()) {
|
||||
return simpleInput(config);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const { getSuggestions, prompt = "> ", maxSuggestions = 5 } = config;
|
||||
|
||||
const stdin = process.stdin;
|
||||
const stdout = process.stdout;
|
||||
|
||||
let input = "";
|
||||
let cursorPos = 0;
|
||||
let suggestions: AutocompleteOption[] = [];
|
||||
let selectedIndex = -1;
|
||||
let lastRenderedLines = 0; // Track how many lines we rendered (for cleanup)
|
||||
|
||||
// Enable raw mode
|
||||
if (stdin.isTTY) {
|
||||
stdin.setRawMode(true);
|
||||
}
|
||||
|
||||
// Set up keypress events
|
||||
readline.emitKeypressEvents(stdin);
|
||||
|
||||
const cleanup = () => {
|
||||
stdout.write(SHOW_CURSOR);
|
||||
if (stdin.isTTY) {
|
||||
stdin.setRawMode(false);
|
||||
}
|
||||
stdin.removeListener("keypress", onKeypress);
|
||||
};
|
||||
|
||||
const clearDisplay = () => {
|
||||
// Move to beginning of current line
|
||||
stdout.write("\r");
|
||||
// Clear current line
|
||||
stdout.write(CLEAR_LINE);
|
||||
// Clear any suggestion lines below
|
||||
if (lastRenderedLines > 0) {
|
||||
stdout.write(CLEAR_TO_END);
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
clearDisplay();
|
||||
|
||||
// Write prompt and input
|
||||
stdout.write(`${prompt}${input}`);
|
||||
|
||||
// Calculate cursor position accounting for line wrapping and wide characters
|
||||
const termWidth = stdout.columns || 80;
|
||||
const promptVisualWidth = getStringWidth(stripAnsi(prompt));
|
||||
// Calculate visual width of input up to cursor position
|
||||
const inputBeforeCursor = input.slice(0, cursorPos);
|
||||
const inputVisualWidth = getStringWidth(inputBeforeCursor);
|
||||
const cursorOffset = promptVisualWidth + inputVisualWidth;
|
||||
|
||||
// Handle edge case: when cursor is exactly at line boundary,
|
||||
// show it at end of current line, not start of next line
|
||||
let cursorCol: number;
|
||||
if (cursorOffset > 0 && cursorOffset % termWidth === 0) {
|
||||
cursorCol = termWidth;
|
||||
} else {
|
||||
cursorCol = (cursorOffset % termWidth) + 1;
|
||||
}
|
||||
|
||||
// Get and display suggestions if input starts with /
|
||||
if (input.startsWith("/") && input.length > 1) {
|
||||
suggestions = getSuggestions(input).slice(0, maxSuggestions);
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
// Ensure selectedIndex is valid
|
||||
if (selectedIndex >= suggestions.length) {
|
||||
selectedIndex = suggestions.length - 1;
|
||||
}
|
||||
|
||||
// Move to new line for suggestions
|
||||
stdout.write("\n");
|
||||
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
const opt = suggestions[i]!;
|
||||
const isSelected = i === selectedIndex;
|
||||
const value = isSelected
|
||||
? `${INVERSE} ${opt.value}${RESET}`
|
||||
: ` ${colors.suggestionDim(opt.value)}`;
|
||||
const label = opt.label ? ` ${colors.suggestionLabel(opt.label)}` : "";
|
||||
const line = `${value}${label}`;
|
||||
|
||||
stdout.write(`${CLEAR_LINE}${line}`);
|
||||
if (i < suggestions.length - 1) {
|
||||
stdout.write("\n");
|
||||
}
|
||||
}
|
||||
|
||||
lastRenderedLines = suggestions.length;
|
||||
|
||||
// Move cursor back up to input line
|
||||
stdout.write(CURSOR_UP(suggestions.length));
|
||||
stdout.write(CURSOR_TO_COL(cursorCol));
|
||||
} else {
|
||||
lastRenderedLines = 0;
|
||||
}
|
||||
} else {
|
||||
suggestions = [];
|
||||
selectedIndex = -1;
|
||||
lastRenderedLines = 0;
|
||||
}
|
||||
|
||||
// Position cursor correctly within input
|
||||
stdout.write(CURSOR_TO_COL(cursorCol));
|
||||
};
|
||||
|
||||
const submit = (value: string) => {
|
||||
clearDisplay();
|
||||
stdout.write(`${prompt}${value}\n`);
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onKeypress = (_char: string, key: readline.Key) => {
|
||||
if (!key) return;
|
||||
|
||||
// Handle Ctrl+C
|
||||
if (key.ctrl && key.name === "c") {
|
||||
clearDisplay();
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle Ctrl+D (EOF)
|
||||
if (key.ctrl && key.name === "d") {
|
||||
clearDisplay();
|
||||
cleanup();
|
||||
stdout.write("\n");
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
// Use selected suggestion
|
||||
const selected = suggestions[selectedIndex]!;
|
||||
submit(selected.value);
|
||||
} else {
|
||||
submit(input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tab - cycle through suggestions or complete selected one
|
||||
if (key.name === "tab") {
|
||||
if (suggestions.length > 0) {
|
||||
if (selectedIndex >= 0) {
|
||||
// Already have a selection - complete it to input
|
||||
const selected = suggestions[selectedIndex]!;
|
||||
input = selected.value + " ";
|
||||
cursorPos = input.length;
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
} else {
|
||||
// No selection yet - select first item
|
||||
if (key.shift) {
|
||||
selectedIndex = suggestions.length - 1;
|
||||
} else {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
render();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys
|
||||
if (key.name === "up") {
|
||||
if (suggestions.length > 0) {
|
||||
selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "down") {
|
||||
if (suggestions.length > 0) {
|
||||
selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Escape - clear selection
|
||||
if (key.name === "escape") {
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle backspace
|
||||
if (key.name === "backspace") {
|
||||
if (cursorPos > 0) {
|
||||
input = input.slice(0, cursorPos - 1) + input.slice(cursorPos);
|
||||
cursorPos--;
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
if (key.name === "delete") {
|
||||
if (cursorPos < input.length) {
|
||||
input = input.slice(0, cursorPos) + input.slice(cursorPos + 1);
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle left arrow
|
||||
if (key.name === "left") {
|
||||
if (cursorPos > 0) {
|
||||
cursorPos--;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle right arrow
|
||||
if (key.name === "right") {
|
||||
if (cursorPos < input.length) {
|
||||
cursorPos++;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle home
|
||||
if (key.name === "home" || (key.ctrl && key.name === "a")) {
|
||||
cursorPos = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle end
|
||||
if (key.name === "end" || (key.ctrl && key.name === "e")) {
|
||||
cursorPos = input.length;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle printable characters
|
||||
if (key.sequence && !key.ctrl && !key.meta) {
|
||||
const char = key.sequence;
|
||||
if (char.length === 1 && char.charCodeAt(0) >= 32) {
|
||||
input = input.slice(0, cursorPos) + char + input.slice(cursorPos);
|
||||
cursorPos++;
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stdin.on("keypress", onKeypress);
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
/**
|
||||
* Terminal Colors and Styling
|
||||
*
|
||||
* Simple ANSI color utilities for terminal output
|
||||
*/
|
||||
|
||||
// Check if colors should be disabled
|
||||
const NO_COLOR = process.env.NO_COLOR !== undefined || process.env.TERM === "dumb";
|
||||
|
||||
type StyleFn = (s: string) => string;
|
||||
|
||||
const identity: StyleFn = (s) => s;
|
||||
|
||||
function style(code: number, reset: number = 0): StyleFn {
|
||||
if (NO_COLOR) return identity;
|
||||
return (s: string) => `\x1b[${code}m${s}\x1b[${reset}m`;
|
||||
}
|
||||
|
||||
// Basic styles
|
||||
export const reset = "\x1b[0m";
|
||||
export const bold = style(1, 22);
|
||||
export const dim = style(2, 22);
|
||||
export const italic = style(3, 23);
|
||||
export const underline = style(4, 24);
|
||||
export const inverse = style(7, 27);
|
||||
|
||||
// Foreground colors
|
||||
export const black = style(30, 39);
|
||||
export const red = style(31, 39);
|
||||
export const green = style(32, 39);
|
||||
export const yellow = style(33, 39);
|
||||
export const blue = style(34, 39);
|
||||
export const magenta = style(35, 39);
|
||||
export const cyan = style(36, 39);
|
||||
export const white = style(37, 39);
|
||||
export const gray = style(90, 39);
|
||||
|
||||
// Bright colors
|
||||
export const brightRed = style(91, 39);
|
||||
export const brightGreen = style(92, 39);
|
||||
export const brightYellow = style(93, 39);
|
||||
export const brightBlue = style(94, 39);
|
||||
export const brightMagenta = style(95, 39);
|
||||
export const brightCyan = style(96, 39);
|
||||
|
||||
// Background colors
|
||||
export const bgRed = style(41, 49);
|
||||
export const bgGreen = style(42, 49);
|
||||
export const bgYellow = style(43, 49);
|
||||
export const bgBlue = style(44, 49);
|
||||
|
||||
// Semantic colors for the CLI
|
||||
export const colors = {
|
||||
// UI elements
|
||||
prompt: cyan,
|
||||
promptSymbol: brightCyan,
|
||||
sessionId: dim,
|
||||
|
||||
// Tool output
|
||||
toolName: yellow,
|
||||
toolArgs: dim,
|
||||
toolBullet: cyan,
|
||||
toolArrow: dim,
|
||||
toolError: red,
|
||||
|
||||
// Messages
|
||||
error: red,
|
||||
warning: yellow,
|
||||
success: green,
|
||||
info: blue,
|
||||
|
||||
// Status bar
|
||||
statusBg: inverse,
|
||||
statusLabel: dim,
|
||||
statusValue: white,
|
||||
|
||||
// Welcome banner
|
||||
bannerBorder: cyan,
|
||||
bannerText: brightCyan,
|
||||
|
||||
// Suggestions
|
||||
suggestionSelected: inverse,
|
||||
suggestionDim: dim,
|
||||
suggestionLabel: gray,
|
||||
};
|
||||
|
||||
// Spinner frames for thinking indicator
|
||||
export const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
// Alternative spinner styles
|
||||
export const spinnerStyles = {
|
||||
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
||||
line: ["-", "\\", "|", "/"],
|
||||
arc: ["◜", "◠", "◝", "◞", "◡", "◟"],
|
||||
bounce: ["⠁", "⠂", "⠄", "⠂"],
|
||||
pulse: ["◯", "◔", "◑", "◕", "●", "◕", "◑", "◔"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a spinner instance
|
||||
*/
|
||||
export function createSpinner(options: {
|
||||
stream?: NodeJS.WritableStream;
|
||||
frames?: string[];
|
||||
interval?: number;
|
||||
} = {}) {
|
||||
const {
|
||||
stream = process.stderr,
|
||||
frames = spinnerFrames,
|
||||
interval = 80,
|
||||
} = options;
|
||||
|
||||
let frameIndex = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let currentText = "";
|
||||
|
||||
const render = () => {
|
||||
const frame = colors.toolBullet(frames[frameIndex % frames.length]!);
|
||||
stream.write(`\r\x1b[K${frame} ${currentText}`);
|
||||
frameIndex++;
|
||||
};
|
||||
|
||||
return {
|
||||
start(text: string) {
|
||||
currentText = text;
|
||||
frameIndex = 0;
|
||||
if (timer) clearInterval(timer);
|
||||
render();
|
||||
timer = setInterval(render, interval);
|
||||
},
|
||||
|
||||
update(text: string) {
|
||||
currentText = text;
|
||||
},
|
||||
|
||||
stop(finalText?: string) {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
stream.write("\r\x1b[K");
|
||||
if (finalText) {
|
||||
stream.write(finalText + "\n");
|
||||
}
|
||||
},
|
||||
|
||||
isSpinning() {
|
||||
return timer !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,640 +0,0 @@
|
|||
/**
|
||||
* Chat command - Interactive REPL mode
|
||||
*
|
||||
* Usage:
|
||||
* multica chat [options]
|
||||
* multica [options] (default command)
|
||||
*/
|
||||
|
||||
import * as readline from "readline";
|
||||
import { Agent } from "@multica/core";
|
||||
import type { AgentOptions } from "@multica/core";
|
||||
import { SkillManager } from "@multica/core";
|
||||
import { autocompleteInput, type AutocompleteOption } from "../autocomplete.js";
|
||||
import { colors, dim, cyan, brightCyan, yellow, green, gray, red } from "../colors.js";
|
||||
import {
|
||||
getProviderList,
|
||||
getCurrentProvider,
|
||||
getLoginInstructions,
|
||||
getProviderMeta,
|
||||
type ProviderInfo,
|
||||
} from "@multica/core";
|
||||
|
||||
type ChatOptions = {
|
||||
profile?: string | undefined;
|
||||
provider?: string | undefined;
|
||||
model?: string | undefined;
|
||||
system?: string | undefined;
|
||||
thinking?: string | undefined;
|
||||
cwd?: string | undefined;
|
||||
session?: string | undefined;
|
||||
help?: boolean;
|
||||
};
|
||||
|
||||
const COMMANDS = {
|
||||
help: "Show this help message",
|
||||
exit: "Exit the CLI (aliases: quit, q)",
|
||||
clear: "Clear the current session and start fresh",
|
||||
session: "Show current session ID",
|
||||
new: "Start a new session",
|
||||
multiline: "Toggle multi-line input mode (end with a line containing only '.')",
|
||||
provider: "Show current provider and available options",
|
||||
model: "Show or switch model (usage: /model [model-name])",
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica chat [options]
|
||||
multica [options]
|
||||
|
||||
${cyan("Options:")}
|
||||
${yellow("--profile")} ID Load agent profile
|
||||
${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.)
|
||||
${yellow("--model")} NAME Model name
|
||||
${yellow("--system")} TEXT System prompt (ignored if --profile set)
|
||||
${yellow("--thinking")} LEVEL Thinking level
|
||||
${yellow("--cwd")} DIR Working directory
|
||||
${yellow("--session")} ID Session ID to resume
|
||||
${yellow("--help")}, -h Show this help
|
||||
|
||||
${cyan("Interactive Commands:")}
|
||||
`);
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ChatOptions {
|
||||
const args = [...argv];
|
||||
const opts: ChatOptions = {};
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
opts.help = true;
|
||||
break;
|
||||
}
|
||||
if (arg === "--profile") {
|
||||
opts.profile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model") {
|
||||
opts.model = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--system") {
|
||||
opts.system = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--thinking") {
|
||||
opts.thinking = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
opts.cwd = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.session = args.shift();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printWelcome(sessionId: string, opts: ChatOptions) {
|
||||
const border = cyan("│");
|
||||
const topBorder = cyan("╭─────────────────────────────────────────╮");
|
||||
const bottomBorder = cyan("╰─────────────────────────────────────────╯");
|
||||
|
||||
console.log(topBorder);
|
||||
console.log(`${border} ${brightCyan("Super Multica Interactive CLI")} ${border}`);
|
||||
console.log(bottomBorder);
|
||||
|
||||
// Show configuration
|
||||
const configLines: string[] = [];
|
||||
configLines.push(`${dim("Session:")} ${gray(sessionId.slice(0, 8))}...`);
|
||||
if (opts.profile) {
|
||||
configLines.push(`${dim("Profile:")} ${yellow(opts.profile)}`);
|
||||
}
|
||||
if (opts.provider) {
|
||||
configLines.push(`${dim("Provider:")} ${green(opts.provider)}`);
|
||||
}
|
||||
if (opts.model) {
|
||||
configLines.push(`${dim("Model:")} ${green(opts.model)}`);
|
||||
}
|
||||
|
||||
console.log(configLines.join(" "));
|
||||
console.log(`${dim("Type")} ${cyan("/help")} ${dim("for commands,")} ${cyan("/exit")} ${dim("to quit.")}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printCommandHelp(skillManager?: SkillManager) {
|
||||
console.log(`\n${cyan("Built-in commands:")}`);
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
|
||||
}
|
||||
|
||||
// Show skill commands if available
|
||||
if (skillManager) {
|
||||
const reservedNames = new Set(Object.keys(COMMANDS));
|
||||
const skillCommands = skillManager.getSkillCommands({ reservedNames });
|
||||
if (skillCommands.length > 0) {
|
||||
console.log(`\n${cyan("Skill commands:")}`);
|
||||
for (const cmd of skillCommands) {
|
||||
console.log(` ${yellow(`/${cmd.name}`.padEnd(14))} ${dim(cmd.description)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Just type your message and press Enter to chat with the agent.")}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Status Bar - renders a persistent status line at the bottom of the terminal
|
||||
*/
|
||||
class StatusBar {
|
||||
private enabled: boolean;
|
||||
private currentStatus: string = "";
|
||||
private stream: NodeJS.WriteStream;
|
||||
|
||||
constructor(stream: NodeJS.WriteStream = process.stdout) {
|
||||
this.stream = stream;
|
||||
this.enabled = stream.isTTY === true;
|
||||
}
|
||||
|
||||
update(parts: { session?: string; provider?: string; model?: string; tokens?: number }) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const segments: string[] = [];
|
||||
|
||||
if (parts.session) {
|
||||
segments.push(`${dim("session:")}${gray(parts.session.slice(0, 8))}`);
|
||||
}
|
||||
if (parts.provider) {
|
||||
segments.push(`${dim("provider:")}${green(parts.provider)}`);
|
||||
}
|
||||
if (parts.model) {
|
||||
segments.push(`${dim("model:")}${yellow(parts.model)}`);
|
||||
}
|
||||
if (parts.tokens !== undefined) {
|
||||
segments.push(`${dim("tokens:")}${cyan(String(parts.tokens))}`);
|
||||
}
|
||||
|
||||
this.currentStatus = segments.join(" ");
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.enabled || !this.currentStatus) return;
|
||||
|
||||
const termWidth = this.stream.columns || 80;
|
||||
const termHeight = this.stream.rows || 24;
|
||||
|
||||
const statusLine = ` ${this.currentStatus} `.slice(0, termWidth);
|
||||
|
||||
this.stream.write(
|
||||
`\x1b[s` + // Save cursor
|
||||
`\x1b[${termHeight};1H` + // Move to last row
|
||||
`\x1b[7m` + // Inverse video
|
||||
`\x1b[2K` + // Clear line
|
||||
statusLine.padEnd(termWidth) +
|
||||
`\x1b[0m` + // Reset
|
||||
`\x1b[u` // Restore cursor
|
||||
);
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const termHeight = this.stream.rows || 24;
|
||||
|
||||
this.stream.write(
|
||||
`\x1b[s` +
|
||||
`\x1b[${termHeight};1H` +
|
||||
`\x1b[2K` +
|
||||
`\x1b[u`
|
||||
);
|
||||
this.currentStatus = "";
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
class InteractiveCLI {
|
||||
private agent: Agent;
|
||||
private opts: ChatOptions;
|
||||
private rl: readline.Interface | null = null;
|
||||
private multilineMode = false;
|
||||
private multilineBuffer: string[] = [];
|
||||
private running = true;
|
||||
private skillManager: SkillManager;
|
||||
private reservedNames: Set<string>;
|
||||
private statusBar: StatusBar;
|
||||
|
||||
constructor(opts: ChatOptions) {
|
||||
this.opts = opts;
|
||||
this.agent = this.createAgent(opts.session);
|
||||
this.statusBar = new StatusBar();
|
||||
|
||||
this.skillManager = new SkillManager({
|
||||
profileId: opts.profile,
|
||||
});
|
||||
|
||||
this.reservedNames = new Set(Object.keys(COMMANDS));
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
this.statusBar.clear();
|
||||
console.log(`\n${dim("Goodbye!")}`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
private getReadline(): readline.Interface {
|
||||
if (!this.rl) {
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
this.rl.on("close", () => {
|
||||
this.running = false;
|
||||
this.statusBar.clear();
|
||||
console.log(`\n${dim("Goodbye!")}`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
return this.rl;
|
||||
}
|
||||
|
||||
private closeReadline() {
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
this.rl = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getSuggestions(input: string): AutocompleteOption[] {
|
||||
if (!input.startsWith("/")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prefix = input.slice(1).toLowerCase();
|
||||
const suggestions: AutocompleteOption[] = [];
|
||||
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
if (cmd.toLowerCase().startsWith(prefix)) {
|
||||
suggestions.push({
|
||||
value: `/${cmd}`,
|
||||
label: desc.slice(0, 40),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const skillCommands = this.skillManager.getSkillCommands({ reservedNames: this.reservedNames });
|
||||
for (const cmd of skillCommands) {
|
||||
if (cmd.name.toLowerCase().startsWith(prefix)) {
|
||||
suggestions.push({
|
||||
value: `/${cmd.name}`,
|
||||
label: cmd.description.slice(0, 40),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.sort((a, b) => {
|
||||
if (a.value.length !== b.value.length) return a.value.length - b.value.length;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private createAgent(sessionId?: string): Agent {
|
||||
return new Agent({
|
||||
profileId: this.opts.profile,
|
||||
provider: this.opts.provider,
|
||||
model: this.opts.model,
|
||||
systemPrompt: this.opts.system,
|
||||
thinkingLevel: this.opts.thinking as AgentOptions["thinkingLevel"],
|
||||
cwd: this.opts.cwd,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
private prompt(): string {
|
||||
if (this.multilineMode) {
|
||||
return this.multilineBuffer.length === 0 ? cyan(">>> ") : cyan("... ");
|
||||
}
|
||||
return `${brightCyan("You:")} `;
|
||||
}
|
||||
|
||||
private updateStatusBar() {
|
||||
const statusUpdate: { session?: string; provider?: string; model?: string; tokens?: number } = {
|
||||
session: this.agent.sessionId,
|
||||
provider: this.opts.provider ?? "default",
|
||||
};
|
||||
if (this.opts.model) {
|
||||
statusUpdate.model = this.opts.model;
|
||||
}
|
||||
this.statusBar.update(statusUpdate);
|
||||
}
|
||||
|
||||
async run() {
|
||||
printWelcome(this.agent.sessionId, this.opts);
|
||||
this.updateStatusBar();
|
||||
await this.loop();
|
||||
}
|
||||
|
||||
private async loop() {
|
||||
while (this.running) {
|
||||
let input: string;
|
||||
|
||||
if (this.multilineMode) {
|
||||
const lineInput = await this.readline(this.prompt());
|
||||
if (lineInput === null) break;
|
||||
input = lineInput;
|
||||
|
||||
if (input === ".") {
|
||||
const fullInput = this.multilineBuffer.join("\n");
|
||||
this.multilineBuffer = [];
|
||||
this.multilineMode = false;
|
||||
this.closeReadline();
|
||||
if (fullInput.trim()) {
|
||||
await this.handleInput(fullInput);
|
||||
}
|
||||
} else {
|
||||
this.multilineBuffer.push(input);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
this.statusBar.hide();
|
||||
input = await autocompleteInput({
|
||||
prompt: this.prompt(),
|
||||
getSuggestions: (text) => this.getSuggestions(text),
|
||||
maxSuggestions: 8,
|
||||
});
|
||||
this.statusBar.show();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
const handled = await this.handleCommand(trimmed);
|
||||
if (!handled) {
|
||||
await this.handleInput(trimmed);
|
||||
}
|
||||
} else {
|
||||
await this.handleInput(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readline(prompt: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.getReadline().question(prompt, (answer) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCommand(input: string): Promise<boolean> {
|
||||
const cmd = input.slice(1).toLowerCase().split(/\s+/)[0];
|
||||
|
||||
switch (cmd) {
|
||||
case "help":
|
||||
printCommandHelp(this.skillManager);
|
||||
return true;
|
||||
|
||||
case "exit":
|
||||
case "quit":
|
||||
case "q":
|
||||
this.statusBar.clear();
|
||||
console.log(dim("Goodbye!"));
|
||||
this.running = false;
|
||||
this.closeReadline();
|
||||
process.exit(0);
|
||||
return true;
|
||||
|
||||
case "clear":
|
||||
this.agent = this.createAgent();
|
||||
this.updateStatusBar();
|
||||
console.log(`${green("Session cleared.")} ${dim("New session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
return true;
|
||||
|
||||
case "session":
|
||||
console.log(`${dim("Current session:")} ${cyan(this.agent.sessionId)}\n`);
|
||||
return true;
|
||||
|
||||
case "new":
|
||||
this.agent = this.createAgent();
|
||||
this.updateStatusBar();
|
||||
console.log(`${green("Started new session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
return true;
|
||||
|
||||
case "multiline":
|
||||
this.multilineMode = !this.multilineMode;
|
||||
if (this.multilineMode) {
|
||||
console.log(`${green("Multi-line mode enabled.")} ${dim("End input with a line containing only '.'")}`);
|
||||
this.multilineBuffer = [];
|
||||
} else {
|
||||
console.log(dim("Multi-line mode disabled."));
|
||||
this.multilineBuffer = [];
|
||||
this.closeReadline();
|
||||
}
|
||||
return true;
|
||||
|
||||
case "provider":
|
||||
this.showProviderStatus();
|
||||
return true;
|
||||
|
||||
case "model":
|
||||
this.handleModelCommand(input);
|
||||
return true;
|
||||
|
||||
default:
|
||||
const invocation = this.skillManager.resolveCommand(input);
|
||||
if (invocation) {
|
||||
const skillPrompt = invocation.args
|
||||
? `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}\n\nUser request: ${invocation.args}`
|
||||
: `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}`;
|
||||
await this.handleInput(skillPrompt);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleModelCommand(input: string) {
|
||||
const parts = input.trim().split(/\s+/);
|
||||
const modelArg = parts.slice(1).join(" ").trim();
|
||||
const currentProvider = this.opts.provider ?? getCurrentProvider();
|
||||
const providerMeta = getProviderMeta(currentProvider);
|
||||
|
||||
if (!providerMeta) {
|
||||
console.log(`${red("Error:")} Unknown provider: ${currentProvider}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// No argument - show current model and available models
|
||||
if (!modelArg) {
|
||||
console.log(`\n${cyan("🎯 Model Status")}\n`);
|
||||
console.log(`${dim("Provider:")} ${green(currentProvider)}`);
|
||||
console.log(`${dim("Current model:")} ${yellow(this.opts.model ?? providerMeta.defaultModel)}`);
|
||||
console.log(`${dim("Default model:")} ${gray(providerMeta.defaultModel)}`);
|
||||
|
||||
console.log(`\n${dim("Available models for")} ${green(currentProvider)}${dim(":")}`);
|
||||
for (const model of providerMeta.models) {
|
||||
const isCurrent = model === (this.opts.model ?? providerMeta.defaultModel);
|
||||
const marker = isCurrent ? yellow(" (current)") : "";
|
||||
const modelDisplay = isCurrent ? yellow(model) : model;
|
||||
console.log(` • ${modelDisplay}${marker}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Switch model:")} ${yellow(`/model <model-name>`)}`);
|
||||
console.log(`${dim("Example:")} ${yellow(`/model ${providerMeta.models[0]}`)}`);
|
||||
console.log("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if model is valid for current provider
|
||||
const normalizedModel = modelArg.toLowerCase();
|
||||
const matchedModel = providerMeta.models.find(
|
||||
(m) => m.toLowerCase() === normalizedModel
|
||||
);
|
||||
|
||||
if (!matchedModel) {
|
||||
console.log(`${red("Error:")} Model "${modelArg}" is not available for provider "${currentProvider}".`);
|
||||
console.log(`\n${dim("Available models:")}`);
|
||||
for (const model of providerMeta.models) {
|
||||
console.log(` • ${model}`);
|
||||
}
|
||||
console.log("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch model
|
||||
const oldModel = this.opts.model ?? providerMeta.defaultModel;
|
||||
this.opts.model = matchedModel;
|
||||
|
||||
// Recreate agent with new model
|
||||
this.agent = this.createAgent(this.agent.sessionId);
|
||||
this.updateStatusBar();
|
||||
|
||||
console.log(`${green("✓")} Model switched: ${gray(oldModel)} → ${yellow(matchedModel)}`);
|
||||
console.log(`${dim("Session preserved:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
}
|
||||
|
||||
private showProviderStatus() {
|
||||
const providers = getProviderList();
|
||||
const currentProvider = this.opts.provider ?? getCurrentProvider();
|
||||
|
||||
console.log(`\n${cyan("🔌 Provider Status")}\n`);
|
||||
console.log(`${dim("Current:")} ${green(currentProvider)}`);
|
||||
if (this.opts.model) {
|
||||
console.log(`${dim("Model:")} ${yellow(this.opts.model)}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Available Providers:")}`);
|
||||
console.log(` ${dim("ID".padEnd(16))} ${dim("Name".padEnd(20))} ${dim("Auth".padEnd(12))} ${dim("Status")}`);
|
||||
console.log(` ${dim("─".repeat(70))}`);
|
||||
|
||||
// Group by auth method
|
||||
const apiKeyProviders = providers.filter(p => p.authMethod === "api-key");
|
||||
const oauthProviders = providers.filter(p => p.authMethod === "oauth");
|
||||
|
||||
// OAuth providers first (more interesting)
|
||||
for (const p of oauthProviders) {
|
||||
const status = p.available ? green("✓") : red("✗");
|
||||
const isCurrent = p.id === currentProvider || (p.id === "claude-code" && currentProvider === "anthropic" && p.available);
|
||||
const current = isCurrent ? yellow(" (current)") : "";
|
||||
const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16);
|
||||
const authLabel = cyan("OAuth");
|
||||
const statusLabel = p.available ? green("ready") : dim("not logged in");
|
||||
console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`);
|
||||
}
|
||||
|
||||
// API Key providers
|
||||
for (const p of apiKeyProviders) {
|
||||
const status = p.available ? green("✓") : red("✗");
|
||||
const isCurrent = p.id === currentProvider;
|
||||
const current = isCurrent ? yellow(" (current)") : "";
|
||||
const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16);
|
||||
const authLabel = dim("API Key");
|
||||
const statusLabel = p.available ? green("configured") : dim("not configured");
|
||||
console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Usage:")}`);
|
||||
console.log(` ${yellow("multica --provider <id>")} ${dim("Start chat with specific provider")}`);
|
||||
console.log(` ${yellow("multica --provider <id> --model <model>")} ${dim("Specify model too")}`);
|
||||
|
||||
console.log(`\n${dim("Examples:")}`);
|
||||
console.log(` ${yellow("multica --provider claude-code")} ${dim("Use Claude Code OAuth")}`);
|
||||
console.log(` ${yellow("multica --provider openai")} ${dim("Use OpenAI with API Key")}`);
|
||||
|
||||
// If user hasn't logged into Claude Code, show instructions
|
||||
const claudeCode = providers.find(p => p.id === "claude-code");
|
||||
if (claudeCode && !claudeCode.available) {
|
||||
console.log(`\n${cyan("💡 Tip:")} To use Claude Code (free with Claude subscription):`);
|
||||
console.log(` 1. Install: ${yellow("npm install -g @anthropic-ai/claude-code")}`);
|
||||
console.log(` 2. Login: ${yellow("claude login")}`);
|
||||
console.log(` 3. Use: ${yellow("multica --provider claude-code")}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
}
|
||||
|
||||
private async handleInput(input: string) {
|
||||
try {
|
||||
console.log("");
|
||||
this.statusBar.hide();
|
||||
const result = await this.agent.run(input);
|
||||
this.statusBar.show();
|
||||
if (result.error) {
|
||||
console.error(`\n${colors.error(`Error: ${result.error}`)}`);
|
||||
}
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function chatCommand(args: string[]): Promise<void> {
|
||||
const opts = parseArgs(args);
|
||||
|
||||
if (opts.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(colors.error("Error: Interactive mode requires a TTY. Use 'multica run' for non-interactive mode."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cli = new InteractiveCLI(opts);
|
||||
await cli.run();
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
/**
|
||||
* Credentials command - Manage credentials and environment files
|
||||
*
|
||||
* Usage:
|
||||
* multica credentials init Create credential files
|
||||
* multica credentials show Show credential paths
|
||||
* multica credentials edit Open credentials in editor
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getCredentialsPath } from "@multica/core";
|
||||
import { cyan, yellow, green, dim, red } from "../colors.js";
|
||||
|
||||
type Command = "init" | "show" | "edit" | "help";
|
||||
|
||||
interface CredentialsOptions {
|
||||
command: Command;
|
||||
force: boolean;
|
||||
pathOverride?: string | undefined;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica credentials <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("init")} Create credentials.json5
|
||||
${yellow("show")} Show credential file paths
|
||||
${yellow("edit")} Open credentials directory in file manager
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Options for 'init':")}
|
||||
${yellow("--force")} Overwrite existing files
|
||||
${yellow("--path")} PATH Override credentials path
|
||||
|
||||
${cyan("Files Created:")}
|
||||
~/.super-multica/credentials.json5 LLM providers + tools config
|
||||
|
||||
${dim("Skill-specific API keys are stored in .env files within each skill's directory.")}
|
||||
${dim("Example: ~/.super-multica/skills/<skill-id>/.env")}
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Initialize credentials")}
|
||||
multica credentials init
|
||||
|
||||
${dim("# Force overwrite")}
|
||||
multica credentials init --force
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CredentialsOptions {
|
||||
const args = [...argv];
|
||||
const opts: CredentialsOptions = {
|
||||
command: "help",
|
||||
force: false,
|
||||
};
|
||||
|
||||
const positional: string[] = [];
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
opts.command = "help";
|
||||
return opts;
|
||||
}
|
||||
if (arg === "--force" || arg === "-f") {
|
||||
opts.force = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--path") {
|
||||
opts.pathOverride = args.shift();
|
||||
continue;
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
opts.command = (positional[0] || "help") as Command;
|
||||
return opts;
|
||||
}
|
||||
|
||||
function buildCoreTemplate(): string {
|
||||
return `{
|
||||
version: 1,
|
||||
llm: {
|
||||
// provider: "openai",
|
||||
providers: {
|
||||
// openai: { apiKey: "sk-...", baseUrl: "https://api.openai.com/v1", model: "gpt-4.1" }
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
// brave: { apiKey: "brv-..." },
|
||||
// perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" }
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function cmdInit(opts: CredentialsOptions): void {
|
||||
const path = opts.pathOverride ?? getCredentialsPath();
|
||||
if (existsSync(path) && !opts.force) {
|
||||
console.error(`${red("Error:")} Credentials file already exists at ${path}`);
|
||||
console.error("Use --force to overwrite.");
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, buildCoreTemplate(), "utf8");
|
||||
chmodSync(path, 0o600);
|
||||
console.log(`${green("Created:")} ${path}`);
|
||||
|
||||
console.log("");
|
||||
console.log("Edit this file to add your LLM provider credentials.");
|
||||
console.log(`${dim("Skill-specific API keys go in .env files within each skill's directory.")}`);
|
||||
}
|
||||
|
||||
function cmdShow(): void {
|
||||
const credentialsPath = getCredentialsPath();
|
||||
|
||||
console.log(`\n${cyan("Credential Files:")}\n`);
|
||||
|
||||
console.log(`${yellow("credentials.json5")}`);
|
||||
console.log(` Path: ${credentialsPath}`);
|
||||
console.log(` Exists: ${existsSync(credentialsPath) ? green("Yes") : red("No")}`);
|
||||
console.log("");
|
||||
|
||||
console.log(`${dim("Skill-specific API keys are stored in .env files within each skill's directory.")}`);
|
||||
console.log("");
|
||||
|
||||
if (!existsSync(credentialsPath)) {
|
||||
console.log(`${dim("Run 'multica credentials init' to create missing files.")}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdEdit(): Promise<void> {
|
||||
const credentialsPath = getCredentialsPath();
|
||||
const dir = dirname(credentialsPath);
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`${red("Error:")} Credentials directory does not exist: ${dir}`);
|
||||
console.error("Run 'multica credentials init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
// Open in default file manager
|
||||
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
||||
spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref();
|
||||
|
||||
console.log(`${green("Opened:")} ${dir}`);
|
||||
}
|
||||
|
||||
export async function credentialsCommand(args: string[]): Promise<void> {
|
||||
const opts = parseArgs(args);
|
||||
|
||||
switch (opts.command) {
|
||||
case "init":
|
||||
cmdInit(opts);
|
||||
break;
|
||||
case "show":
|
||||
cmdShow();
|
||||
break;
|
||||
case "edit":
|
||||
await cmdEdit();
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,466 +0,0 @@
|
|||
/**
|
||||
* Cron command - Manage scheduled tasks
|
||||
*
|
||||
* Usage:
|
||||
* multica cron status Show cron service status
|
||||
* multica cron list List all jobs
|
||||
* multica cron add <options> Add a new job
|
||||
* multica cron run <id> Run a job immediately
|
||||
* multica cron enable <id> Enable a job
|
||||
* multica cron disable <id> Disable a job
|
||||
* multica cron remove <id> Remove a job
|
||||
* multica cron logs <id> Show job run logs
|
||||
*/
|
||||
|
||||
import { cyan, yellow, green, dim, red, brightCyan } from "../colors.js";
|
||||
import {
|
||||
getCronService,
|
||||
formatSchedule,
|
||||
formatDuration,
|
||||
parseTimeInput,
|
||||
parseIntervalInput,
|
||||
isValidCronExpr,
|
||||
type CronSchedule,
|
||||
type CronJobInput,
|
||||
} from "@multica/core";
|
||||
|
||||
type Command = "status" | "list" | "add" | "run" | "enable" | "disable" | "remove" | "logs" | "help";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${brightCyan("Cron")} - Scheduled Task Management
|
||||
|
||||
${cyan("Usage:")} multica cron <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("status")} Show cron service status
|
||||
${yellow("list")} List all scheduled jobs
|
||||
${yellow("add")} [options] Create a new scheduled job
|
||||
${yellow("run")} <id> Run a job immediately
|
||||
${yellow("enable")} <id> Enable a disabled job
|
||||
${yellow("disable")} <id> Disable a job (keeps schedule)
|
||||
${yellow("remove")} <id> Delete a job
|
||||
${yellow("logs")} <id> Show run history for a job
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Add Options:")}
|
||||
${yellow("-n, --name")} <name> Job name (required)
|
||||
${yellow("--at")} <time> One-time at ISO timestamp or relative (e.g., "10m", "2h")
|
||||
${yellow("--every")} <interval> Repeat interval (e.g., "30m", "1h", "1d")
|
||||
${yellow("--cron")} <expr> Cron expression (5-field, e.g., "0 9 * * *")
|
||||
${yellow("--tz")} <timezone> Timezone for cron expression (e.g., "Asia/Shanghai")
|
||||
${yellow("--message")} <text> Message to inject or prompt for agent
|
||||
${yellow("--isolated")} Run in isolated session (default: main)
|
||||
${yellow("--delete-after-run")} Delete after one-time run completes
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Show service status")}
|
||||
multica cron status
|
||||
|
||||
${dim("# 10 minutes from now (one-shot)")}
|
||||
multica cron add -n "Reminder" --at "10m" --message "Time to take a break!"
|
||||
|
||||
${dim("# Every day at 9am Beijing time")}
|
||||
multica cron add -n "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \\
|
||||
--message "Good morning! Check your tasks."
|
||||
|
||||
${dim("# Every 30 minutes")}
|
||||
multica cron add -n "Health check" --every "30m" --message "System health check"
|
||||
|
||||
${dim("# Run a job now")}
|
||||
multica cron run abc12345
|
||||
|
||||
${dim("# View job logs")}
|
||||
multica cron logs abc12345
|
||||
`);
|
||||
}
|
||||
|
||||
function cmdStatus() {
|
||||
const service = getCronService();
|
||||
const status = service.status();
|
||||
|
||||
console.log(`\n${brightCyan("Cron Service Status")}\n`);
|
||||
console.log(` ${cyan("Running:")} ${status.running ? green("Yes") : red("No")}`);
|
||||
console.log(` ${cyan("Enabled:")} ${status.enabled ? green("Yes") : red("No")}`);
|
||||
console.log(` ${cyan("Jobs:")} ${status.jobCount} total, ${status.enabledJobCount} enabled`);
|
||||
if (status.nextWakeAtMs) {
|
||||
const nextWake = new Date(status.nextWakeAtMs);
|
||||
const relativeMs = status.nextWakeAtMs - Date.now();
|
||||
console.log(` ${cyan("Next wake:")} ${nextWake.toLocaleString()} (in ${formatDuration(relativeMs)})`);
|
||||
} else {
|
||||
console.log(` ${cyan("Next wake:")} ${dim("none scheduled")}`);
|
||||
}
|
||||
console.log(` ${cyan("Store:")} ${dim(status.storePath)}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function cmdList(args: string[]) {
|
||||
const service = getCronService();
|
||||
const showEnabled = args.includes("--enabled");
|
||||
const showDisabled = args.includes("--disabled");
|
||||
|
||||
let filter: { enabled?: boolean } | undefined;
|
||||
if (showEnabled) filter = { enabled: true };
|
||||
else if (showDisabled) filter = { enabled: false };
|
||||
|
||||
const jobs = service.list(filter);
|
||||
|
||||
if (jobs.length === 0) {
|
||||
console.log("\nNo cron jobs found.");
|
||||
console.log(`${dim("Create one with:")} multica cron add -n "Name" --at "10m" --message "Hello"`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${brightCyan("Scheduled Jobs")}\n`);
|
||||
|
||||
for (const job of jobs) {
|
||||
const statusIcon = job.enabled ? green("✓") : red("✗");
|
||||
const shortId = job.id.slice(0, 8);
|
||||
|
||||
console.log(`${statusIcon} ${yellow(job.name)} ${dim(`(${shortId})`)}`);
|
||||
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
|
||||
console.log(` ${cyan("Target:")} ${job.sessionTarget}`);
|
||||
|
||||
if (job.state.nextRunAtMs) {
|
||||
const nextRun = new Date(job.state.nextRunAtMs);
|
||||
const relativeMs = job.state.nextRunAtMs - Date.now();
|
||||
if (relativeMs > 0) {
|
||||
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()} ${dim(`(in ${formatDuration(relativeMs)})`)}`);
|
||||
} else {
|
||||
console.log(` ${cyan("Next run:")} ${dim("pending execution")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (job.state.lastRunAtMs) {
|
||||
const lastRun = new Date(job.state.lastRunAtMs);
|
||||
const statusColor = job.state.lastStatus === "ok" ? green : job.state.lastStatus === "error" ? red : yellow;
|
||||
console.log(` ${cyan("Last run:")} ${lastRun.toLocaleString()} ${statusColor(`[${job.state.lastStatus}]`)} ${dim(`(${formatDuration(job.state.lastDurationMs ?? 0)})`)}`);
|
||||
if (job.state.lastError) {
|
||||
console.log(` ${red("Error:")} ${job.state.lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(dim(`Total: ${jobs.length} job(s)`));
|
||||
}
|
||||
|
||||
function cmdAdd(args: string[]) {
|
||||
const service = getCronService();
|
||||
|
||||
// Parse arguments
|
||||
let name: string | undefined;
|
||||
let at: string | undefined;
|
||||
let every: string | undefined;
|
||||
let cronExpr: string | undefined;
|
||||
let tz: string | undefined;
|
||||
let message: string | undefined;
|
||||
let isolated = false;
|
||||
let deleteAfterRun = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
switch (arg) {
|
||||
case "-n":
|
||||
case "--name":
|
||||
name = args[++i];
|
||||
break;
|
||||
case "--at":
|
||||
at = args[++i];
|
||||
break;
|
||||
case "--every":
|
||||
every = args[++i];
|
||||
break;
|
||||
case "--cron":
|
||||
cronExpr = args[++i];
|
||||
break;
|
||||
case "--tz":
|
||||
tz = args[++i];
|
||||
break;
|
||||
case "--message":
|
||||
message = args[++i];
|
||||
break;
|
||||
case "--isolated":
|
||||
isolated = true;
|
||||
break;
|
||||
case "--delete-after-run":
|
||||
deleteAfterRun = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate
|
||||
if (!name) {
|
||||
console.error(`${red("Error:")} --name is required`);
|
||||
console.error(`${dim("Usage:")} multica cron add -n "Job name" --at "10m" --message "Hello"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
console.error(`${red("Error:")} --message is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse schedule
|
||||
let schedule: CronSchedule;
|
||||
if (at) {
|
||||
const atMs = parseTimeInput(at);
|
||||
if (!atMs) {
|
||||
console.error(`${red("Error:")} Invalid time format: ${at}`);
|
||||
console.error(`${dim("Examples:")} "10m", "2h", "2024-12-31T23:59:00Z"`);
|
||||
process.exit(1);
|
||||
}
|
||||
schedule = { kind: "at", atMs };
|
||||
} else if (every) {
|
||||
const everyMs = parseIntervalInput(every);
|
||||
if (!everyMs) {
|
||||
console.error(`${red("Error:")} Invalid interval format: ${every}`);
|
||||
console.error(`${dim("Examples:")} "30s", "5m", "2h", "1d"`);
|
||||
process.exit(1);
|
||||
}
|
||||
schedule = { kind: "every", everyMs };
|
||||
} else if (cronExpr) {
|
||||
if (!isValidCronExpr(cronExpr, tz)) {
|
||||
console.error(`${red("Error:")} Invalid cron expression: ${cronExpr}`);
|
||||
console.error(`${dim("Format:")} "minute hour day month weekday" (e.g., "0 9 * * *")`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Only include tz if it's defined (exactOptionalPropertyTypes)
|
||||
schedule = tz ? { kind: "cron", expr: cronExpr, tz } : { kind: "cron", expr: cronExpr };
|
||||
} else {
|
||||
console.error(`${red("Error:")} Must specify --at, --every, or --cron`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create job
|
||||
const input: CronJobInput = {
|
||||
name,
|
||||
enabled: true,
|
||||
deleteAfterRun,
|
||||
schedule,
|
||||
sessionTarget: isolated ? "isolated" : "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "system-event",
|
||||
text: message,
|
||||
},
|
||||
};
|
||||
|
||||
const job = service.add(input);
|
||||
|
||||
console.log(`\n${green("Created job:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}`);
|
||||
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
|
||||
if (job.state.nextRunAtMs) {
|
||||
const nextRun = new Date(job.state.nextRunAtMs);
|
||||
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function cmdRun(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
const force = args.includes("--force");
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
console.error(`${dim("Usage:")} multica cron run <id> [--force]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
console.error("Please provide a more specific ID.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
console.log(`Running job: ${job.name} (${job.id.slice(0, 8)})...`);
|
||||
|
||||
const result = await service.run(job.id, force);
|
||||
if (result.ok) {
|
||||
console.log(`${green("Success:")} Job executed`);
|
||||
} else {
|
||||
console.error(`${red("Error:")} ${result.reason}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdEnableDisable(args: string[], enabled: boolean) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
service.update(job.id, { enabled });
|
||||
|
||||
const action = enabled ? "Enabled" : "Disabled";
|
||||
console.log(`${green(action + ":")} ${job.name} (${job.id.slice(0, 8)})`);
|
||||
}
|
||||
|
||||
function cmdRemove(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
service.remove(job.id);
|
||||
console.log(`${green("Removed:")} ${job.name} (${job.id.slice(0, 8)})`);
|
||||
}
|
||||
|
||||
function cmdLogs(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
const limitArg = args.indexOf("--limit");
|
||||
const limitStr = limitArg !== -1 ? args[limitArg + 1] : undefined;
|
||||
const limit = limitStr ? parseInt(limitStr, 10) : 20;
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
console.error(`${dim("Usage:")} multica cron logs <id> [--limit N]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
const logs = service.getRunLogs(job.id, limit);
|
||||
|
||||
console.log(`\n${brightCyan("Run Logs:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}\n`);
|
||||
|
||||
if (logs.length === 0) {
|
||||
console.log(dim("No run logs found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const log of logs) {
|
||||
const timestamp = new Date(log.ts).toLocaleString();
|
||||
const statusColor = log.status === "ok" ? green : log.status === "error" ? red : yellow;
|
||||
const duration = log.durationMs ? formatDuration(log.durationMs) : "-";
|
||||
|
||||
console.log(` ${dim(timestamp)} ${statusColor(`[${log.status}]`)} ${dim(`(${duration})`)}`);
|
||||
if (log.error) {
|
||||
console.log(` ${red("Error:")} ${log.error}`);
|
||||
}
|
||||
if (log.summary) {
|
||||
console.log(` ${dim(log.summary)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dim(`Showing ${logs.length} most recent entries`)}`);
|
||||
}
|
||||
|
||||
export async function cronCommand(args: string[]): Promise<void> {
|
||||
const command = (args[0] || "help") as Command;
|
||||
const restArgs = args.slice(1);
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure service is started
|
||||
const service = getCronService();
|
||||
await service.start();
|
||||
|
||||
switch (command) {
|
||||
case "status":
|
||||
cmdStatus();
|
||||
break;
|
||||
case "list":
|
||||
cmdList(restArgs);
|
||||
break;
|
||||
case "add":
|
||||
cmdAdd(restArgs);
|
||||
break;
|
||||
case "run":
|
||||
await cmdRun(restArgs);
|
||||
break;
|
||||
case "enable":
|
||||
cmdEnableDisable(restArgs, true);
|
||||
break;
|
||||
case "disable":
|
||||
cmdEnableDisable(restArgs, false);
|
||||
break;
|
||||
case "remove":
|
||||
cmdRemove(restArgs);
|
||||
break;
|
||||
case "logs":
|
||||
cmdLogs(restArgs);
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
/**
|
||||
* Dev command - Start development servers
|
||||
*
|
||||
* Usage:
|
||||
* 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 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" | "web" | "desktop" | "help";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica dev [service]
|
||||
|
||||
${cyan("Services:")}
|
||||
${yellow("(default)")} Start Desktop app (with embedded Hub)
|
||||
${yellow("gateway")} Start Gateway server (:3000) - for remote clients
|
||||
${yellow("web")} Start Web app (:3001)
|
||||
${yellow("all")} Start all services (gateway + web)
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Architecture:")}
|
||||
Desktop App (standalone)
|
||||
└─ Embedded Hub + Agent Engine
|
||||
└─ (Optional) Gateway connection for remote access
|
||||
|
||||
Web App (requires Gateway)
|
||||
→ Gateway (WebSocket, :3000)
|
||||
→ Hub + Agent Engine
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Start desktop app (recommended for local development)")}
|
||||
multica dev
|
||||
|
||||
${dim("# Start desktop with remote Gateway for mobile access")}
|
||||
GATEWAY_URL=http://localhost:3000 multica dev &
|
||||
multica dev gateway
|
||||
|
||||
${dim("# Start web app with gateway")}
|
||||
multica dev gateway &
|
||||
multica dev web
|
||||
`);
|
||||
}
|
||||
|
||||
interface DevOptions {
|
||||
service: Service;
|
||||
watch: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): DevOptions {
|
||||
const args = [...argv];
|
||||
let service: Service = "desktop";
|
||||
let watch = true;
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { service: "help", watch };
|
||||
}
|
||||
if (arg === "--no-watch") {
|
||||
watch = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Service name
|
||||
if (["gateway", "web", "desktop", "all", "help"].includes(arg)) {
|
||||
service = arg as Service;
|
||||
}
|
||||
}
|
||||
|
||||
return { service, watch };
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], options: { name: string; color: string }) {
|
||||
console.log(`${options.color}[${options.name}]${"\x1b[0m"} Starting...`);
|
||||
|
||||
const child = spawn(command, args, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error(`${red(`[${options.name}]`)} Error: ${err.message}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`${red(`[${options.name}]`)} Exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
async function startGateway(watch: boolean) {
|
||||
const watchFlag = watch ? "--watch" : "";
|
||||
return runCommand("tsx", [watchFlag, "src/gateway/main.ts"].filter(Boolean), {
|
||||
name: "gateway",
|
||||
color: "\x1b[34m", // blue
|
||||
});
|
||||
}
|
||||
|
||||
async function startWeb() {
|
||||
return runCommand("pnpm", ["--filter", "@multica/web", "dev"], {
|
||||
name: "web",
|
||||
color: "\x1b[32m", // green
|
||||
});
|
||||
}
|
||||
|
||||
async function startDesktop() {
|
||||
return runCommand("pnpm", ["--filter", "@multica/desktop", "dev"], {
|
||||
name: "desktop",
|
||||
color: "\x1b[35m", // magenta
|
||||
});
|
||||
}
|
||||
|
||||
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[32m"}Web${"\x1b[0m"} → http://localhost:3001`);
|
||||
console.log("");
|
||||
|
||||
// Start all services
|
||||
const gateway = await startGateway(watch);
|
||||
const web = await startWeb();
|
||||
|
||||
// Handle Ctrl+C
|
||||
const cleanup = () => {
|
||||
console.log(`\n${dim("Stopping all services...")}`);
|
||||
gateway.kill();
|
||||
web.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// Wait for all to exit
|
||||
await Promise.all([
|
||||
new Promise((resolve) => gateway.on("exit", resolve)),
|
||||
new Promise((resolve) => web.on("exit", resolve)),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function devCommand(args: string[]): Promise<void> {
|
||||
const opts = parseArgs(args);
|
||||
|
||||
switch (opts.service) {
|
||||
case "gateway":
|
||||
console.log(`\n${cyan("Starting Gateway...")} → http://localhost:3000\n`);
|
||||
await startGateway(opts.watch);
|
||||
break;
|
||||
|
||||
case "web":
|
||||
console.log(`\n${cyan("Starting Web App...")} → http://localhost:3001\n`);
|
||||
await startWeb();
|
||||
break;
|
||||
|
||||
case "desktop":
|
||||
console.log(`\n${cyan("Starting Desktop App...")}\n`);
|
||||
await startDesktop();
|
||||
break;
|
||||
|
||||
case "all":
|
||||
await startAll(opts.watch);
|
||||
break;
|
||||
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
/**
|
||||
* Profile command - Manage agent profiles
|
||||
*
|
||||
* Usage:
|
||||
* multica profile list List all profiles
|
||||
* multica profile new <id> Create a new profile
|
||||
* multica profile show <id> Show profile contents
|
||||
* multica profile edit <id> Open profile in file manager
|
||||
* multica profile delete <id> Delete a profile
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, rmSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as readline from "node:readline";
|
||||
import {
|
||||
createAgentProfile,
|
||||
loadAgentProfile,
|
||||
getProfileDir,
|
||||
profileExists,
|
||||
} from "@multica/core";
|
||||
import { DATA_DIR } from "@multica/utils";
|
||||
import { cyan, yellow, green, dim, red, brightCyan, gray, colors } from "../colors.js";
|
||||
import { Agent } from "@multica/core";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SETUP_SKILL_PATH = join(__dirname, "../../../../skills/profile-setup/SKILL.md");
|
||||
|
||||
const PROFILES_DIR = join(DATA_DIR, "agent-profiles");
|
||||
|
||||
type Command = "new" | "list" | "show" | "edit" | "delete" | "setup" | "help";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica profile <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("list")} List all profiles
|
||||
${yellow("new")} <id> Create a new profile
|
||||
${yellow("setup")} <id> Interactive setup for a profile
|
||||
${yellow("show")} <id> Show profile contents
|
||||
${yellow("edit")} <id> Open profile directory in file manager
|
||||
${yellow("delete")} <id> Delete a profile
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Profile Structure:")}
|
||||
Each profile is a directory containing:
|
||||
- soul.md Agent identity, personality and behavior
|
||||
- user.md Information about the user
|
||||
- workspace.md Workspace rules and conventions
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Create a new profile")}
|
||||
multica profile new my-agent
|
||||
|
||||
${dim("# Interactive setup")}
|
||||
multica profile setup my-agent
|
||||
|
||||
${dim("# List all profiles")}
|
||||
multica profile list
|
||||
|
||||
${dim("# Use a profile")}
|
||||
multica chat --profile my-agent
|
||||
`);
|
||||
}
|
||||
|
||||
function cmdNew(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: multica profile new <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate profile ID
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(profileId)) {
|
||||
console.error("Error: Profile ID can only contain letters, numbers, hyphens, and underscores");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" already exists`);
|
||||
console.error(`Location: ${getProfileDir(profileId)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profile = createAgentProfile(profileId);
|
||||
const dir = getProfileDir(profileId);
|
||||
|
||||
console.log(`${green("Created profile:")} ${yellow(profile.id)}`);
|
||||
console.log(`${dim("Location:")} ${dir}`);
|
||||
console.log("");
|
||||
console.log("Files created:");
|
||||
console.log(" - soul.md (identity, personality and behavior)");
|
||||
console.log(" - user.md (information about the user)");
|
||||
console.log(" - workspace.md (workspace rules and conventions)");
|
||||
console.log("");
|
||||
console.log("Run interactive setup to personalize your agent:");
|
||||
console.log(` multica profile setup ${profileId}`);
|
||||
console.log("");
|
||||
console.log("Or start chatting directly:");
|
||||
console.log(` multica chat --profile ${profileId}`);
|
||||
}
|
||||
|
||||
function cmdList() {
|
||||
if (!existsSync(PROFILES_DIR)) {
|
||||
console.log("No profiles found.");
|
||||
console.log(`Create one with: multica profile new <id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = readdirSync(PROFILES_DIR, { withFileTypes: true });
|
||||
const profiles = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
console.log("No profiles found.");
|
||||
console.log(`Create one with: multica profile new <id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${cyan("Available profiles:")}\n`);
|
||||
for (const id of profiles) {
|
||||
const dir = getProfileDir(id);
|
||||
console.log(` ${yellow(id)}`);
|
||||
console.log(` ${dim(dir)}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(`${dim(`Total: ${profiles.length} profile(s)`)}`);
|
||||
}
|
||||
|
||||
function cmdShow(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: multica profile show <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profile = loadAgentProfile(profileId);
|
||||
if (!profile) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
console.error(`Create it with: multica profile new ${profileId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n${cyan("Profile:")} ${yellow(profile.id)}`);
|
||||
console.log(`${dim("Location:")} ${getProfileDir(profileId)}`);
|
||||
console.log("");
|
||||
|
||||
if (profile.soul) {
|
||||
console.log(`${green("=== soul.md ===")}`);
|
||||
console.log(profile.soul.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.user) {
|
||||
console.log(`${green("=== user.md ===")}`);
|
||||
console.log(profile.user.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.workspace) {
|
||||
console.log(`${green("=== workspace.md ===")}`);
|
||||
console.log(profile.workspace.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function cmdEdit(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: multica profile edit <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
console.error(`Create it with: multica profile new ${profileId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dir = getProfileDir(profileId);
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
// Open in default file manager
|
||||
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
||||
spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref();
|
||||
|
||||
console.log(`${green("Opened:")} ${dir}`);
|
||||
}
|
||||
|
||||
function cmdDelete(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: multica profile delete <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dir = getProfileDir(profileId);
|
||||
|
||||
try {
|
||||
rmSync(dir, { recursive: true });
|
||||
console.log(`${green("Deleted:")} ${profileId}`);
|
||||
} catch (err) {
|
||||
console.error(`${red("Error:")} Failed to delete profile: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load setup skill instructions from SKILL.md
|
||||
*/
|
||||
function loadSetupSkillInstructions(): string | undefined {
|
||||
try {
|
||||
if (!existsSync(SETUP_SKILL_PATH)) {
|
||||
return undefined;
|
||||
}
|
||||
const content = readFileSync(SETUP_SKILL_PATH, "utf-8");
|
||||
// Extract instructions after frontmatter (after the second ---)
|
||||
const parts = content.split("---");
|
||||
if (parts.length >= 3) {
|
||||
return parts.slice(2).join("---").trim();
|
||||
}
|
||||
return content;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive setup for a profile
|
||||
*/
|
||||
async function cmdSetup(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: multica profile setup <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
console.error(`Create it first with: multica profile new ${profileId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check TTY
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(colors.error("Error: Interactive setup requires a TTY."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load setup skill instructions
|
||||
const setupInstructions = loadSetupSkillInstructions();
|
||||
if (!setupInstructions) {
|
||||
console.error(colors.error("Error: Could not load setup skill instructions."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profileDir = getProfileDir(profileId);
|
||||
|
||||
// Build system prompt with setup instructions
|
||||
const systemPrompt = `${setupInstructions}
|
||||
|
||||
## Profile Context
|
||||
|
||||
You are setting up the profile "${profileId}".
|
||||
The profile directory is: ${profileDir}
|
||||
|
||||
Available profile files to update:
|
||||
- ${profileDir}/user.md - Information about the user
|
||||
- ${profileDir}/workspace.md - Workspace rules and conventions
|
||||
- ${profileDir}/config.json - Configuration (provider, model, etc.)
|
||||
|
||||
Start the setup conversation now.`;
|
||||
|
||||
// Create agent with setup instructions
|
||||
const agent = new Agent({
|
||||
profileId,
|
||||
systemPrompt,
|
||||
});
|
||||
|
||||
// Print welcome
|
||||
console.log("");
|
||||
console.log(cyan("╭─────────────────────────────────────────╮"));
|
||||
console.log(`${cyan("│")} ${brightCyan("Profile Setup Wizard")} ${cyan("│")}`);
|
||||
console.log(cyan("╰─────────────────────────────────────────╯"));
|
||||
console.log("");
|
||||
console.log(`${dim("Profile:")} ${yellow(profileId)}`);
|
||||
console.log(`${dim("Location:")} ${gray(profileDir)}`);
|
||||
console.log(`${dim("Type")} ${cyan("/exit")} ${dim("to finish setup.")}`);
|
||||
console.log("");
|
||||
|
||||
// Start the conversation with an initial prompt
|
||||
try {
|
||||
await agent.run("Start the setup process.");
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
|
||||
}
|
||||
|
||||
// Interactive loop
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const askQuestion = (prompt: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let running = true;
|
||||
|
||||
// Handle Ctrl+C
|
||||
process.on("SIGINT", () => {
|
||||
console.log(`\n${dim("Setup cancelled.")}`);
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
while (running) {
|
||||
const input = await askQuestion(`${brightCyan("You:")} `);
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Check for exit command
|
||||
if (trimmed.toLowerCase() === "/exit" || trimmed.toLowerCase() === "/quit" || trimmed.toLowerCase() === "/q") {
|
||||
console.log("");
|
||||
console.log(`${green("Setup complete!")} Your profile has been updated.`);
|
||||
console.log(`${dim("Start chatting with:")} multica chat --profile ${profileId}`);
|
||||
console.log("");
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Send to agent
|
||||
try {
|
||||
console.log("");
|
||||
await agent.run(trimmed);
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
export async function profileCommand(args: string[]): Promise<void> {
|
||||
const command = (args[0] || "help") as Command;
|
||||
const arg1 = args[1];
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "new":
|
||||
cmdNew(arg1);
|
||||
break;
|
||||
case "list":
|
||||
cmdList();
|
||||
break;
|
||||
case "show":
|
||||
cmdShow(arg1);
|
||||
break;
|
||||
case "edit":
|
||||
await cmdEdit(arg1);
|
||||
break;
|
||||
case "delete":
|
||||
cmdDelete(arg1);
|
||||
break;
|
||||
case "setup":
|
||||
await cmdSetup(arg1);
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
/**
|
||||
* Run command - Execute a single prompt non-interactively
|
||||
*
|
||||
* Usage:
|
||||
* multica run [options] <prompt>
|
||||
* echo "prompt" | multica run
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { Agent, Hub } from "@multica/core";
|
||||
import type { AgentOptions } from "@multica/core";
|
||||
import type { ToolsConfig } from "@multica/core";
|
||||
import { DATA_DIR } from "@multica/utils";
|
||||
import { cyan, yellow, dim } from "../colors.js";
|
||||
|
||||
type RunOptions = {
|
||||
profile?: string | undefined;
|
||||
provider?: string | undefined;
|
||||
model?: string | undefined;
|
||||
apiKey?: string | undefined;
|
||||
baseUrl?: string | undefined;
|
||||
system?: string | undefined;
|
||||
thinking?: string | undefined;
|
||||
reasoning?: string | undefined;
|
||||
cwd?: string | undefined;
|
||||
session?: string | undefined;
|
||||
debug?: boolean;
|
||||
runLog?: boolean;
|
||||
toolsAllow?: string[];
|
||||
toolsDeny?: string[];
|
||||
contextWindow?: number;
|
||||
help?: boolean;
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica run [options] <prompt>
|
||||
echo "prompt" | multica run
|
||||
|
||||
${cyan("Options:")}
|
||||
${yellow("--profile")} ID Load agent profile
|
||||
${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.)
|
||||
${yellow("--model")} NAME Model name
|
||||
${yellow("--api-key")} KEY API key (overrides environment)
|
||||
${yellow("--base-url")} URL Custom base URL for provider
|
||||
${yellow("--system")} TEXT System prompt (ignored if --profile set)
|
||||
${yellow("--thinking")} LEVEL Thinking level
|
||||
${yellow("--reasoning")} MODE Reasoning display mode (off, on, stream)
|
||||
${yellow("--cwd")} DIR Working directory
|
||||
${yellow("--session")} ID Session ID for persistence
|
||||
${yellow("--debug")} Enable debug logging
|
||||
${yellow("--run-log")} Enable structured run logging (run-log.jsonl)
|
||||
${yellow("--context-window")} N Override context window token count
|
||||
${yellow("--help")}, -h Show this help
|
||||
|
||||
${cyan("Tools Configuration:")}
|
||||
${yellow("--tools-allow")} T Allow specific tools (comma-separated)
|
||||
${yellow("--tools-deny")} T Deny specific tools (comma-separated)
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Run with default settings")}
|
||||
multica run "What is 2+2?"
|
||||
|
||||
${dim("# Use a specific profile")}
|
||||
multica run --profile coder "List files in this directory"
|
||||
|
||||
${dim("# Pipe input")}
|
||||
echo "Explain this code" | multica run
|
||||
|
||||
${dim("# Resume a session")}
|
||||
multica run --session abc123 "Continue from where we left off"
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): { opts: RunOptions; prompt: string } {
|
||||
const args = [...argv];
|
||||
const opts: RunOptions = {};
|
||||
const promptParts: string[] = [];
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
opts.help = true;
|
||||
break;
|
||||
}
|
||||
if (arg === "--profile") {
|
||||
opts.profile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model") {
|
||||
opts.model = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--api-key") {
|
||||
opts.apiKey = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base-url") {
|
||||
opts.baseUrl = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--system") {
|
||||
opts.system = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--thinking") {
|
||||
opts.thinking = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--reasoning") {
|
||||
opts.reasoning = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
opts.cwd = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.session = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--debug") {
|
||||
opts.debug = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--run-log") {
|
||||
opts.runLog = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-allow") {
|
||||
const value = args.shift();
|
||||
opts.toolsAllow = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-deny") {
|
||||
const value = args.shift();
|
||||
opts.toolsDeny = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--context-window") {
|
||||
const value = args.shift();
|
||||
opts.contextWindow = value ? parseInt(value, 10) : undefined;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
promptParts.push(...args);
|
||||
break;
|
||||
}
|
||||
promptParts.push(arg);
|
||||
}
|
||||
|
||||
return { opts, prompt: promptParts.join(" ") };
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
if (process.stdin.isTTY) return "";
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (data += chunk));
|
||||
process.stdin.on("end", () => resolve(data.trim()));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCommand(args: string[]): Promise<void> {
|
||||
const { opts, prompt } = parseArgs(args);
|
||||
|
||||
if (opts.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const stdinPrompt = await readStdin();
|
||||
const finalPrompt = prompt || stdinPrompt;
|
||||
|
||||
if (!finalPrompt) {
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build tools config if any tools options are set
|
||||
let toolsConfig: ToolsConfig | undefined;
|
||||
if (opts.toolsAllow || opts.toolsDeny) {
|
||||
toolsConfig = {};
|
||||
if (opts.toolsAllow) {
|
||||
toolsConfig.allow = opts.toolsAllow;
|
||||
}
|
||||
if (opts.toolsDeny) {
|
||||
toolsConfig.deny = opts.toolsDeny;
|
||||
}
|
||||
}
|
||||
|
||||
const enableRunLog = opts.runLog || !!process.env.MULTICA_RUN_LOG;
|
||||
|
||||
// Initialize Hub to enable full agent capabilities (sub-agents, channels, cron).
|
||||
// Matches Desktop environment where Hub is always active.
|
||||
// Gateway connection failures are non-blocking (auto-reconnect with backoff).
|
||||
const gatewayUrl = process.env.GATEWAY_URL || "http://localhost:3000";
|
||||
const hub = new Hub(gatewayUrl);
|
||||
|
||||
try {
|
||||
const agent = new Agent({
|
||||
profileId: opts.profile,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
apiKey: opts.apiKey,
|
||||
baseUrl: opts.baseUrl,
|
||||
systemPrompt: opts.system,
|
||||
thinkingLevel: opts.thinking as any,
|
||||
reasoningMode: opts.reasoning as AgentOptions["reasoningMode"],
|
||||
cwd: opts.cwd,
|
||||
sessionId: opts.session,
|
||||
debug: opts.debug,
|
||||
enableRunLog,
|
||||
tools: toolsConfig,
|
||||
contextWindowTokens: opts.contextWindow,
|
||||
});
|
||||
|
||||
const sessionDir = join(DATA_DIR, "sessions", agent.sessionId);
|
||||
|
||||
// If it's a newly created session, notify user of sessionId
|
||||
if (!opts.session) {
|
||||
console.error(`[session: ${agent.sessionId}]`);
|
||||
}
|
||||
if (enableRunLog) {
|
||||
console.error(`[session-dir: ${sessionDir}]`);
|
||||
}
|
||||
|
||||
const result = await agent.run(finalPrompt);
|
||||
if (result.error) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
hub.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
/**
|
||||
* Session command - Manage conversation sessions
|
||||
*
|
||||
* Usage:
|
||||
* multica session list List all sessions
|
||||
* multica session show <id> Show session details
|
||||
* multica session delete <id> Delete a session
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, unlinkSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DATA_DIR } from "@multica/utils";
|
||||
import { cyan, yellow, green, dim, red } from "../colors.js";
|
||||
|
||||
const SESSIONS_DIR = join(DATA_DIR, "sessions");
|
||||
|
||||
type Command = "list" | "show" | "delete" | "help";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica session <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("list")} List all sessions
|
||||
${yellow("show")} <id> Show session details (use --show-internal to include internal messages)
|
||||
${yellow("delete")} <id> Delete a session
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# List all sessions")}
|
||||
multica session list
|
||||
|
||||
${dim("# Show session details")}
|
||||
multica session show abc12345
|
||||
|
||||
${dim("# Delete a session")}
|
||||
multica session delete abc12345
|
||||
|
||||
${dim("# Resume a session")}
|
||||
multica --session abc12345
|
||||
multica chat --session abc12345
|
||||
`);
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mtime: Date;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
function getSessionInfo(sessionId: string): SessionInfo | null {
|
||||
const sessionPath = join(SESSIONS_DIR, `${sessionId}.jsonl`);
|
||||
if (!existsSync(sessionPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stat = statSync(sessionPath);
|
||||
const content = readFileSync(sessionPath, "utf8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
path: sessionPath,
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
messageCount: lines.length,
|
||||
};
|
||||
}
|
||||
|
||||
function listSessions(): SessionInfo[] {
|
||||
if (!existsSync(SESSIONS_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = readdirSync(SESSIONS_DIR);
|
||||
const sessions: SessionInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".jsonl")) continue;
|
||||
const sessionId = file.replace(".jsonl", "");
|
||||
const info = getSessionInfo(sessionId);
|
||||
if (info) {
|
||||
sessions.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modification time, newest first
|
||||
sessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function cmdList() {
|
||||
const sessions = listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log("No sessions found.");
|
||||
console.log(`${dim("Sessions are stored in:")} ${SESSIONS_DIR}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${cyan("Sessions:")}\n`);
|
||||
|
||||
for (const session of sessions) {
|
||||
const shortId = session.id.slice(0, 8);
|
||||
console.log(` ${yellow(shortId)} ${dim(formatDate(session.mtime))} ${dim(`${session.messageCount} msgs`)} ${dim(formatSize(session.size))}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim(`Total: ${sessions.length} session(s)`)}`);
|
||||
console.log(`${dim("Resume with:")} multica --session <id>`);
|
||||
}
|
||||
|
||||
function cmdShow(sessionId: string | undefined, showInternal = false) {
|
||||
if (!sessionId) {
|
||||
console.error("Error: Session ID is required");
|
||||
console.error("Usage: multica session show <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Support partial ID matching
|
||||
const sessions = listSessions();
|
||||
const matches = sessions.filter((s) => s.id.startsWith(sessionId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`Error: Session "${sessionId}" not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`Error: Multiple sessions match "${sessionId}":`);
|
||||
for (const s of matches) {
|
||||
console.error(` ${s.id.slice(0, 8)}`);
|
||||
}
|
||||
console.error("Please provide a more specific ID.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const session = matches[0]!;
|
||||
const content = readFileSync(session.path, "utf8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
|
||||
console.log(`\n${cyan("Session:")} ${yellow(session.id)}`);
|
||||
console.log(`${dim("Path:")} ${session.path}`);
|
||||
console.log(`${dim("Size:")} ${formatSize(session.size)}`);
|
||||
console.log(`${dim("Modified:")} ${formatDate(session.mtime)}`);
|
||||
console.log(`${dim("Messages:")} ${session.messageCount}`);
|
||||
console.log("");
|
||||
console.log(cyan("─".repeat(60)));
|
||||
console.log("");
|
||||
|
||||
// Parse and display messages as SessionEntry objects
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Only display message entries
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Skip internal messages unless --show-internal
|
||||
if (entry.internal && !showInternal) continue;
|
||||
|
||||
const msg = entry.message;
|
||||
if (!msg) continue;
|
||||
|
||||
const role = msg.role || "unknown";
|
||||
const roleColor = role === "user" ? green : role === "assistant" ? cyan : dim;
|
||||
const internalTag = entry.internal ? dim(" [internal]") : "";
|
||||
|
||||
console.log(`${roleColor(`[${role}]`)}${internalTag}`);
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
// Truncate long content
|
||||
const preview = msg.content.length > 500
|
||||
? msg.content.slice(0, 500) + "..."
|
||||
: msg.content;
|
||||
console.log(preview);
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") {
|
||||
const preview = part.text.length > 500
|
||||
? part.text.slice(0, 500) + "..."
|
||||
: part.text;
|
||||
console.log(preview);
|
||||
} else if (part.type === "tool_use") {
|
||||
console.log(`${dim(`[Tool: ${part.name}]`)}`);
|
||||
} else if (part.type === "tool_result") {
|
||||
console.log(`${dim(`[Tool Result]`)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
console.log(cyan("─".repeat(60)));
|
||||
console.log(`\n${dim("Resume with:")} multica --session ${session.id.slice(0, 8)}`);
|
||||
}
|
||||
|
||||
function cmdDelete(sessionId: string | undefined) {
|
||||
if (!sessionId) {
|
||||
console.error("Error: Session ID is required");
|
||||
console.error("Usage: multica session delete <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Support partial ID matching
|
||||
const sessions = listSessions();
|
||||
const matches = sessions.filter((s) => s.id.startsWith(sessionId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`Error: Session "${sessionId}" not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`Error: Multiple sessions match "${sessionId}":`);
|
||||
for (const s of matches) {
|
||||
console.error(` ${s.id.slice(0, 8)}`);
|
||||
}
|
||||
console.error("Please provide a more specific ID.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const session = matches[0]!;
|
||||
|
||||
try {
|
||||
unlinkSync(session.path);
|
||||
console.log(`${green("Deleted:")} ${session.id}`);
|
||||
} catch (err) {
|
||||
console.error(`${red("Error:")} Failed to delete session: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sessionCommand(args: string[]): Promise<void> {
|
||||
const command = (args[0] || "help") as Command;
|
||||
const arg1 = args[1];
|
||||
const showInternal = args.includes("--show-internal");
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "list":
|
||||
cmdList();
|
||||
break;
|
||||
case "show":
|
||||
cmdShow(arg1, showInternal);
|
||||
break;
|
||||
case "delete":
|
||||
cmdDelete(arg1);
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,527 +0,0 @@
|
|||
/**
|
||||
* Skills command - Manage agent skills
|
||||
*
|
||||
* Usage:
|
||||
* multica skills list List all skills
|
||||
* multica skills status [id] Show skill status
|
||||
* multica skills install <id> Install skill dependencies
|
||||
* multica skills add <source> Add skill from GitHub
|
||||
* multica skills remove <name> Remove a skill
|
||||
*/
|
||||
|
||||
import {
|
||||
SkillManager,
|
||||
installSkill,
|
||||
getInstallOptions,
|
||||
addSkill,
|
||||
removeSkill,
|
||||
listInstalledSkills,
|
||||
checkEligibilityDetailed,
|
||||
type DiagnosticItem,
|
||||
} from "@multica/core";
|
||||
import { cyan, yellow, green, dim, red } from "../colors.js";
|
||||
|
||||
type Command = "list" | "status" | "install" | "add" | "remove" | "help";
|
||||
|
||||
interface ParsedArgs {
|
||||
command: Command;
|
||||
args: string[];
|
||||
verbose: boolean;
|
||||
force: boolean;
|
||||
profile?: string | undefined;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica skills <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("list")} List all available skills
|
||||
${yellow("status")} [id] Show skill status (detailed diagnostics)
|
||||
${yellow("install")} <id> Install dependencies for a skill
|
||||
${yellow("add")} <source> Add skill from GitHub
|
||||
${yellow("remove")} <name> Remove an installed skill
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Options:")}
|
||||
${yellow("-v, --verbose")} Show more details
|
||||
${yellow("-f, --force")} Force overwrite existing skill
|
||||
${yellow("-p, --profile")} <id> Install to specific profile's skills directory
|
||||
|
||||
${cyan("Source Formats:")} ${dim("(for add command)")}
|
||||
owner/repo Clone entire repository
|
||||
owner/repo/skill-name Clone single skill directory
|
||||
owner/repo@branch Clone specific branch/tag
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# List all skills")}
|
||||
multica skills list
|
||||
|
||||
${dim("# Check skill status")}
|
||||
multica skills status commit
|
||||
|
||||
${dim("# Install skill dependencies")}
|
||||
multica skills install nano-pdf
|
||||
|
||||
${dim("# Add skills from GitHub")}
|
||||
multica skills add vercel-labs/agent-skills
|
||||
multica skills add vercel-labs/agent-skills/perplexity
|
||||
|
||||
${dim("# Remove a skill")}
|
||||
multica skills remove agent-skills
|
||||
|
||||
${dim("# Add skill to a specific profile")}
|
||||
multica skills add owner/repo --profile my-agent
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args = [...argv];
|
||||
let verbose = false;
|
||||
let force = false;
|
||||
let profile: string | undefined;
|
||||
const positional: string[] = [];
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--verbose" || arg === "-v") {
|
||||
verbose = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--force" || arg === "-f") {
|
||||
force = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--profile" || arg === "-p") {
|
||||
profile = args.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { command: "help", args: [], verbose, force, profile };
|
||||
}
|
||||
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
const command = (positional[0] ?? "help") as Command;
|
||||
const commandArgs = positional.slice(1);
|
||||
|
||||
return { command, args: commandArgs, verbose, force, profile };
|
||||
}
|
||||
|
||||
function cmdList(manager: SkillManager, verbose: boolean): void {
|
||||
const skills = manager.listAllSkillsWithStatus();
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log("No skills found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${cyan("Available Skills:")}\n`);
|
||||
|
||||
for (const skill of skills) {
|
||||
const status = skill.eligible ? "✓" : "✗";
|
||||
const statusColor = skill.eligible ? green : red;
|
||||
|
||||
console.log(` ${statusColor(status)} ${skill.emoji} ${skill.name} (${skill.id})`);
|
||||
console.log(` ${dim(skill.description)}`);
|
||||
console.log(` ${dim(`Source: ${skill.source}`)}`);
|
||||
|
||||
if (!skill.eligible && skill.reasons) {
|
||||
for (const reason of skill.reasons) {
|
||||
console.log(` ${red(`└ ${reason}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
const eligibleCount = skills.filter((s) => s.eligible).length;
|
||||
console.log(`${dim(`Total: ${skills.length} skills (${eligibleCount} eligible)`)}`);
|
||||
}
|
||||
|
||||
function cmdStatus(manager: SkillManager, skillId?: string, verbose?: boolean): void {
|
||||
if (!skillId) {
|
||||
cmdStatusSummary(manager, verbose);
|
||||
return;
|
||||
}
|
||||
cmdStatusDetail(manager, skillId, verbose);
|
||||
}
|
||||
|
||||
function cmdStatusSummary(manager: SkillManager, verbose?: boolean): void {
|
||||
const skills = manager.listAllSkillsWithStatus();
|
||||
const eligible = skills.filter((s) => s.eligible);
|
||||
const ineligible = skills.filter((s) => !s.eligible);
|
||||
|
||||
console.log(`\n${cyan("Skills Status Summary:")}\n`);
|
||||
console.log(` Total: ${skills.length}`);
|
||||
console.log(` ${green(`Eligible: ${eligible.length}`)}`);
|
||||
console.log(` ${red(`Ineligible: ${ineligible.length}`)}`);
|
||||
|
||||
if (ineligible.length > 0) {
|
||||
console.log("\n" + dim("─".repeat(45)));
|
||||
console.log("Ineligible Skills:");
|
||||
|
||||
const byIssue: Map<string, string[]> = new Map();
|
||||
for (const s of ineligible) {
|
||||
const skill = manager.getSkillFromAll(s.id);
|
||||
if (skill) {
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const mainIssue = detailed.diagnostics?.[0]?.type ?? "unknown";
|
||||
const existing = byIssue.get(mainIssue) ?? [];
|
||||
existing.push(s.id);
|
||||
byIssue.set(mainIssue, existing);
|
||||
}
|
||||
}
|
||||
|
||||
const issueLabels: Record<string, string> = {
|
||||
disabled: "Disabled in config",
|
||||
not_in_allowlist: "Not in allowlist",
|
||||
platform: "Platform mismatch",
|
||||
binary: "Missing binaries",
|
||||
any_binary: "Missing binaries (any)",
|
||||
env: "Missing environment variables",
|
||||
config: "Missing config values",
|
||||
unknown: "Unknown issues",
|
||||
};
|
||||
|
||||
for (const [issue, skillIds] of byIssue) {
|
||||
const label = issueLabels[issue] ?? issue;
|
||||
console.log(`\n ${yellow(label + ":")}`);
|
||||
for (const id of skillIds) {
|
||||
const skill = manager.getSkillFromAll(id);
|
||||
if (skill && verbose) {
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const diag = detailed.diagnostics?.[0];
|
||||
console.log(` - ${id}`);
|
||||
if (diag?.hint) {
|
||||
console.log(` ${cyan(`Hint: ${diag.hint}`)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` - ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n" + dim("─".repeat(45)));
|
||||
console.log(`${cyan("Tip:")} Run 'multica skills status <skill-id>' for detailed diagnostics`);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdStatusDetail(manager: SkillManager, skillId: string, verbose?: boolean): void {
|
||||
const skill = manager.getSkillFromAll(skillId);
|
||||
if (!skill) {
|
||||
console.error(`${red("Error:")} Skill not found: ${skillId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const metadata = skill.frontmatter.metadata;
|
||||
|
||||
console.log(`\n${metadata?.emoji ?? "🔧"} ${skill.frontmatter.name}`);
|
||||
console.log("═".repeat(50));
|
||||
console.log(`ID: ${skill.id}`);
|
||||
console.log(`Description: ${skill.frontmatter.description ?? "N/A"}`);
|
||||
console.log(`Version: ${skill.frontmatter.version ?? "N/A"}`);
|
||||
console.log(`Source: ${skill.source}`);
|
||||
console.log(`Path: ${skill.filePath}`);
|
||||
console.log(`Homepage: ${skill.frontmatter.homepage ?? metadata?.homepage ?? "N/A"}`);
|
||||
|
||||
console.log();
|
||||
console.log("─".repeat(50));
|
||||
console.log(`Status: ${detailed.eligible ? green("✓ ELIGIBLE") : red("✗ NOT ELIGIBLE")}`);
|
||||
|
||||
if (!detailed.eligible && detailed.diagnostics) {
|
||||
console.log("\nDiagnostics:");
|
||||
for (const diag of detailed.diagnostics) {
|
||||
printDiagnostic(diag);
|
||||
}
|
||||
}
|
||||
|
||||
const requirements = metadata?.requires;
|
||||
const hasBins = requirements?.bins?.length ?? metadata?.requiresBinaries?.length ?? 0;
|
||||
const hasAnyBins = requirements?.anyBins?.length ?? 0;
|
||||
const hasEnvs = requirements?.env?.length ?? metadata?.requiresEnv?.length ?? 0;
|
||||
|
||||
if (hasBins > 0 || hasAnyBins > 0 || hasEnvs > 0) {
|
||||
console.log("\n" + "─".repeat(50));
|
||||
console.log("Requirements:");
|
||||
|
||||
if (hasBins > 0) {
|
||||
const bins = requirements?.bins ?? metadata?.requiresBinaries ?? [];
|
||||
printRequirementStatus("Binaries (all required)", bins, checkBinaries);
|
||||
}
|
||||
|
||||
if (hasAnyBins > 0) {
|
||||
const anyBins = requirements?.anyBins ?? [];
|
||||
printRequirementStatus("Binaries (any one)", anyBins, checkBinaries, true);
|
||||
}
|
||||
|
||||
if (hasEnvs > 0) {
|
||||
const envs = requirements?.env ?? metadata?.requiresEnv ?? [];
|
||||
printRequirementStatus("Environment vars", envs, (e) => checkEnvVars(e, skill.env));
|
||||
}
|
||||
}
|
||||
|
||||
const installOptions = getInstallOptions(skill);
|
||||
if (installOptions.length > 0) {
|
||||
console.log("\n" + "─".repeat(50));
|
||||
console.log("Install Options:");
|
||||
for (const opt of installOptions) {
|
||||
const status = opt.available ? green("✓") : red("✗");
|
||||
console.log(` ${status} [${opt.id}] ${opt.label}`);
|
||||
if (!opt.available && opt.reason) {
|
||||
console.log(` └ ${opt.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!detailed.eligible) {
|
||||
console.log("\n" + "─".repeat(50));
|
||||
console.log(`${yellow("Quick Actions:")}`);
|
||||
|
||||
for (const diag of detailed.diagnostics ?? []) {
|
||||
if (diag.hint) {
|
||||
console.log(` → ${diag.hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (installOptions.length > 0) {
|
||||
console.log(` → multica skills install ${skillId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printDiagnostic(diag: DiagnosticItem): void {
|
||||
const typeColors: Record<string, (s: string) => string> = {
|
||||
disabled: yellow,
|
||||
not_in_allowlist: yellow,
|
||||
platform: dim,
|
||||
binary: red,
|
||||
any_binary: red,
|
||||
env: cyan,
|
||||
config: cyan,
|
||||
};
|
||||
|
||||
const color = typeColors[diag.type] ?? dim;
|
||||
|
||||
console.log(`\n ${color(`[${diag.type.toUpperCase()}]`)}`);
|
||||
console.log(` ${diag.message}`);
|
||||
|
||||
if (diag.values && diag.values.length > 0) {
|
||||
console.log(` Values: ${diag.values.join(", ")}`);
|
||||
}
|
||||
|
||||
if (diag.hint) {
|
||||
console.log(` ${cyan(`💡 ${diag.hint}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printRequirementStatus(
|
||||
label: string,
|
||||
items: string[],
|
||||
checker: (items: string[]) => Map<string, boolean>,
|
||||
anyMode: boolean = false,
|
||||
): void {
|
||||
const status = checker(items);
|
||||
const found = Array.from(status.entries()).filter(([, ok]) => ok).map(([name]) => name);
|
||||
const missing = Array.from(status.entries()).filter(([, ok]) => !ok).map(([name]) => name);
|
||||
|
||||
const allOk = anyMode ? found.length > 0 : missing.length === 0;
|
||||
const statusIcon = allOk ? green("✓") : red("✗");
|
||||
|
||||
console.log(`\n ${statusIcon} ${label}:`);
|
||||
for (const [name, ok] of status) {
|
||||
const icon = ok ? green("✓") : red("✗");
|
||||
console.log(` ${icon} ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkBinaries(bins: string[]): Map<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const bin of bins) {
|
||||
try {
|
||||
const cmd = process.platform === "win32" ? `where ${bin}` : `which ${bin}`;
|
||||
require("child_process").execSync(cmd, { stdio: "ignore" });
|
||||
result.set(bin, true);
|
||||
} catch {
|
||||
result.set(bin, false);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkEnvVars(envs: string[], skillEnv: Record<string, string>): Map<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const env of envs) {
|
||||
const found = Object.prototype.hasOwnProperty.call(skillEnv, env) || env in process.env;
|
||||
result.set(env, found);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise<void> {
|
||||
const skill = manager.getSkillFromAll(skillId);
|
||||
if (!skill) {
|
||||
console.error(`${red("Error:")} Skill not found: ${skillId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const installOptions = getInstallOptions(skill);
|
||||
if (installOptions.length === 0) {
|
||||
console.error(`${red("Error:")} Skill '${skillId}' has no install specifications.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!installId && installOptions.length > 1) {
|
||||
console.log(`\nMultiple install options available for '${skillId}':\n`);
|
||||
for (const opt of installOptions) {
|
||||
const status = opt.available ? "available" : `unavailable: ${opt.reason}`;
|
||||
console.log(` [${opt.id}] ${opt.label} (${status})`);
|
||||
}
|
||||
console.log(`\nUse: multica skills install ${skillId} <install-id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nInstalling dependencies for '${skillId}'...`);
|
||||
|
||||
const result = await installSkill({
|
||||
skill,
|
||||
installId,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n${green(`✓ ${result.message}`)}`);
|
||||
} else {
|
||||
console.error(`\n${red(`✗ ${result.message}`)}`);
|
||||
if (result.stderr) {
|
||||
console.error("\nError output:");
|
||||
console.error(result.stderr);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdAdd(source: string, force: boolean, profileId?: string): Promise<void> {
|
||||
const destination = profileId ? `profile '${profileId}'` : "global skills";
|
||||
console.log(`\nAdding skill from '${source}' to ${destination}...`);
|
||||
|
||||
const result = await addSkill({
|
||||
source,
|
||||
force,
|
||||
profileId,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n${green(`✓ ${result.message}`)}`);
|
||||
if (result.skills && result.skills.length > 1) {
|
||||
console.log("\nSkills found:");
|
||||
for (const name of result.skills) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
if (result.path) {
|
||||
console.log(`\nInstalled to: ${result.path}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`\n${red(`✗ ${result.message}`)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdRemove(name: string): Promise<void> {
|
||||
console.log(`\nRemoving skill '${name}'...`);
|
||||
|
||||
const result = await removeSkill(name);
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n${green(`✓ ${result.message}`)}`);
|
||||
} else {
|
||||
console.error(`\n${red(`✗ ${result.message}`)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdListInstalled(): Promise<void> {
|
||||
const skills = await listInstalledSkills();
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log("\nNo skills installed in ~/.super-multica/skills/");
|
||||
console.log("Use 'multica skills add <source>' to add skills.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nInstalled skills (~/.super-multica/skills/):\n");
|
||||
for (const name of skills) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
console.log(`\n${dim(`Total: ${skills.length} installed`)}`);
|
||||
}
|
||||
|
||||
export async function skillsCommand(args: string[]): Promise<void> {
|
||||
const { command, args: cmdArgs, verbose, force, profile } = parseArgs(args);
|
||||
|
||||
if (command === "help") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "add":
|
||||
if (!cmdArgs[0]) {
|
||||
console.error("Usage: multica skills add <source> [--force] [--profile <id>]");
|
||||
console.error("\nSource formats:");
|
||||
console.error(" owner/repo Clone entire repository");
|
||||
console.error(" owner/repo/skill-name Clone single skill directory");
|
||||
console.error(" owner/repo@branch Clone specific branch/tag");
|
||||
console.error("\nOptions:");
|
||||
console.error(" --force, -f Overwrite existing skill");
|
||||
console.error(" --profile, -p <id> Install to profile's skills directory");
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdAdd(cmdArgs[0], force, profile);
|
||||
return;
|
||||
|
||||
case "remove":
|
||||
if (!cmdArgs[0]) {
|
||||
console.error("Usage: multica skills remove <skill-name>");
|
||||
await cmdListInstalled();
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdRemove(cmdArgs[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = new SkillManager();
|
||||
|
||||
switch (command) {
|
||||
case "list":
|
||||
cmdList(manager, verbose);
|
||||
break;
|
||||
|
||||
case "status":
|
||||
cmdStatus(manager, cmdArgs[0], verbose);
|
||||
break;
|
||||
|
||||
case "install":
|
||||
if (!cmdArgs[0]) {
|
||||
console.error("Usage: multica skills install <skill-id> [install-id]");
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdInstall(manager, cmdArgs[0], cmdArgs[1]);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
/**
|
||||
* Tools command - Inspect and test tool policies
|
||||
*
|
||||
* Usage:
|
||||
* multica tools list [options] List available tools
|
||||
* multica tools groups Show all tool groups
|
||||
*/
|
||||
|
||||
import { createAllTools } from "@multica/core";
|
||||
import { filterTools, type ToolsConfig } from "@multica/core";
|
||||
import { TOOL_GROUPS, expandToolGroups } from "@multica/core";
|
||||
import { cyan, yellow, green, dim } from "../colors.js";
|
||||
|
||||
type Command = "list" | "groups" | "help";
|
||||
|
||||
interface ToolsOptions {
|
||||
command: Command;
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
provider?: string | undefined;
|
||||
isSubagent?: boolean;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica tools <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("list")} List available tools (with optional filtering)
|
||||
${yellow("groups")} Show all tool groups
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Options for 'list':")}
|
||||
${yellow("--allow")} TOOLS Allow specific tools (comma-separated)
|
||||
${yellow("--deny")} TOOLS Deny specific tools (comma-separated)
|
||||
${yellow("--provider")} NAME Apply provider-specific rules
|
||||
${yellow("--subagent")} Apply subagent restrictions
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# List all tools")}
|
||||
multica tools list
|
||||
|
||||
${dim("# List tools with allow/deny")}
|
||||
multica tools list --deny exec
|
||||
multica tools list --allow group:fs,web_fetch
|
||||
|
||||
${dim("# Show tool groups")}
|
||||
multica tools groups
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ToolsOptions {
|
||||
const args = [...argv];
|
||||
const raw = args.shift() || "help";
|
||||
|
||||
if (raw === "--help" || raw === "-h") {
|
||||
return { command: "help" };
|
||||
}
|
||||
|
||||
const command = raw as Command;
|
||||
const opts: ToolsOptions = { command };
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { command: "help" };
|
||||
}
|
||||
if (arg === "--allow") {
|
||||
const value = args.shift();
|
||||
opts.allow = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--deny") {
|
||||
const value = args.shift();
|
||||
opts.deny = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--subagent") {
|
||||
opts.isSubagent = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function cmdList(opts: ToolsOptions) {
|
||||
const allTools = createAllTools(process.cwd());
|
||||
|
||||
console.log(`\n${cyan("Tools Overview")}`);
|
||||
console.log(`Total tools available: ${allTools.length}\n`);
|
||||
|
||||
// Build config
|
||||
let config: ToolsConfig | undefined;
|
||||
if (opts.allow || opts.deny) {
|
||||
config = {};
|
||||
if (opts.allow) {
|
||||
config.allow = opts.allow;
|
||||
}
|
||||
if (opts.deny) {
|
||||
config.deny = opts.deny;
|
||||
}
|
||||
}
|
||||
|
||||
const filterOpts: import("@multica/core").FilterToolsOptions = {};
|
||||
if (config) {
|
||||
filterOpts.config = config;
|
||||
}
|
||||
if (opts.provider) {
|
||||
filterOpts.provider = opts.provider;
|
||||
}
|
||||
if (opts.isSubagent) {
|
||||
filterOpts.isSubagent = opts.isSubagent;
|
||||
}
|
||||
|
||||
const filtered = filterTools(allTools, filterOpts);
|
||||
|
||||
if (config || opts.provider || opts.isSubagent) {
|
||||
console.log("Applied filters:");
|
||||
if (opts.allow) console.log(` ${dim("Allow:")} ${opts.allow.join(", ")}`);
|
||||
if (opts.deny) console.log(` ${dim("Deny:")} ${opts.deny.join(", ")}`);
|
||||
if (opts.provider) console.log(` ${dim("Provider:")} ${opts.provider}`);
|
||||
if (opts.isSubagent) console.log(` ${dim("Subagent:")} true`);
|
||||
console.log("");
|
||||
console.log(`Tools after filtering: ${green(String(filtered.length))}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Tools:");
|
||||
for (const tool of filtered) {
|
||||
const desc = tool.description?.slice(0, 55) || "";
|
||||
console.log(` ${yellow(tool.name.padEnd(15))} ${dim(desc)}${desc.length >= 55 ? "..." : ""}`);
|
||||
}
|
||||
|
||||
if (filtered.length < allTools.length) {
|
||||
const removed = allTools.filter((t) => !filtered.find((f) => f.name === t.name));
|
||||
console.log("");
|
||||
console.log(`${dim(`Filtered out (${removed.length}):`)}`);
|
||||
for (const tool of removed) {
|
||||
console.log(` ${dim(tool.name)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cmdGroups() {
|
||||
console.log(`\n${cyan("Tool Groups:")}\n`);
|
||||
for (const [name, tools] of Object.entries(TOOL_GROUPS)) {
|
||||
console.log(` ${yellow(name)}:`);
|
||||
console.log(` ${dim(tools.join(", "))}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
export async function toolsCommand(args: string[]): Promise<void> {
|
||||
const opts = parseArgs(args);
|
||||
|
||||
switch (opts.command) {
|
||||
case "list":
|
||||
cmdList(opts);
|
||||
break;
|
||||
case "groups":
|
||||
cmdGroups();
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Multica CLI - Unified command-line interface
|
||||
*
|
||||
* Usage:
|
||||
* multica Interactive mode (default)
|
||||
* multica run <prompt> Run a single prompt
|
||||
* multica chat Interactive mode (explicit)
|
||||
* multica session <cmd> Session management
|
||||
* multica profile <cmd> Profile management
|
||||
* multica skills <cmd> Skills management
|
||||
* multica tools <cmd> Tool policy inspection
|
||||
* multica credentials <cmd> Credentials management
|
||||
* multica cron <cmd> Scheduled task management
|
||||
* multica dev [service] Development servers
|
||||
* multica help Show help
|
||||
*/
|
||||
|
||||
import { cyan, yellow, green, dim, brightCyan } from "./colors.js";
|
||||
|
||||
// Subcommand handlers (lazy imports for faster startup)
|
||||
type SubcommandHandler = (args: string[]) => Promise<void>;
|
||||
|
||||
const subcommands: Record<string, () => Promise<SubcommandHandler>> = {
|
||||
run: async () => (await import("./commands/run.js")).runCommand,
|
||||
chat: async () => (await import("./commands/chat.js")).chatCommand,
|
||||
session: async () => (await import("./commands/session.js")).sessionCommand,
|
||||
profile: async () => (await import("./commands/profile.js")).profileCommand,
|
||||
skills: async () => (await import("./commands/skills.js")).skillsCommand,
|
||||
tools: async () => (await import("./commands/tools.js")).toolsCommand,
|
||||
credentials: async () => (await import("./commands/credentials.js")).credentialsCommand,
|
||||
dev: async () => (await import("./commands/dev.js")).devCommand,
|
||||
cron: async () => (await import("./commands/cron.js")).cronCommand,
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${brightCyan("Multica CLI")} - AI Agent Framework
|
||||
|
||||
${cyan("Usage:")}
|
||||
${yellow("multica")} Start interactive mode (default)
|
||||
${yellow("multica run")} <prompt> Run a single prompt
|
||||
${yellow("multica chat")} [options] Start interactive mode
|
||||
${yellow("multica session")} <command> Manage sessions
|
||||
${yellow("multica profile")} <command> Manage agent profiles
|
||||
${yellow("multica skills")} <command> Manage skills
|
||||
${yellow("multica tools")} <command> Inspect tool policies
|
||||
${yellow("multica credentials")} <command> Manage credentials
|
||||
${yellow("multica cron")} <command> Manage scheduled tasks
|
||||
${yellow("multica dev")} [service] Start development servers
|
||||
${yellow("multica help")} Show this help
|
||||
|
||||
${cyan("Agent Options:")} ${dim("(for run/chat)")}
|
||||
${yellow("--profile")} ID Load agent profile
|
||||
${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.)
|
||||
${yellow("--model")} NAME Model name
|
||||
${yellow("--system")} TEXT System prompt
|
||||
${yellow("--session")} ID Resume session
|
||||
${yellow("--cwd")} DIR Working directory
|
||||
|
||||
${cyan("Commands:")}
|
||||
${green("session")}
|
||||
list List all sessions
|
||||
show <id> Show session details
|
||||
delete <id> Delete a session
|
||||
|
||||
${green("profile")}
|
||||
list List all profiles
|
||||
new <id> Create a new profile
|
||||
show <id> Show profile contents
|
||||
edit <id> Open profile in file manager
|
||||
delete <id> Delete a profile
|
||||
|
||||
${green("skills")}
|
||||
list List all skills
|
||||
status [id] Show skill status
|
||||
install <id> Install skill dependencies
|
||||
add <source> Add skill from GitHub
|
||||
remove <name> Remove a skill
|
||||
|
||||
${green("tools")}
|
||||
list [--profile P] List tools (with optional filter)
|
||||
groups Show tool groups
|
||||
profiles Show tool profiles
|
||||
|
||||
${green("credentials")}
|
||||
init [--force] Create credential files
|
||||
show Show credential paths
|
||||
edit Open credentials in editor
|
||||
|
||||
${green("cron")}
|
||||
status Show cron service status
|
||||
list List all scheduled jobs
|
||||
add [options] Create a new scheduled job
|
||||
run <id> Run a job immediately
|
||||
enable <id> Enable a job
|
||||
disable <id> Disable a job
|
||||
remove <id> Delete a job
|
||||
logs <id> Show job run logs
|
||||
|
||||
${green("dev")}
|
||||
${dim("(default)")} Start all services (gateway + console + web)
|
||||
gateway Start gateway only (:3000)
|
||||
console Start console only (:4000)
|
||||
web Start web app only (:3001)
|
||||
desktop Start desktop app
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Start interactive mode")}
|
||||
multica
|
||||
|
||||
${dim("# Run a single prompt")}
|
||||
multica run "What files are in this directory?"
|
||||
|
||||
${dim("# Use a specific profile")}
|
||||
multica chat --profile coder
|
||||
|
||||
${dim("# Resume a session")}
|
||||
multica --session abc123
|
||||
|
||||
${dim("# Start development servers")}
|
||||
multica dev
|
||||
multica dev gateway
|
||||
`);
|
||||
}
|
||||
|
||||
function printVersion() {
|
||||
// Read version from package.json would be ideal, but for now just print a placeholder
|
||||
console.log("multica 1.0.0");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Filter out standalone "--" (used by pnpm to pass args)
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== "--");
|
||||
|
||||
// Handle global flags
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
// If help is requested with a subcommand, delegate to that subcommand
|
||||
const firstArg = args[0];
|
||||
if (firstArg && !firstArg.startsWith("-") && subcommands[firstArg]) {
|
||||
const handler = await subcommands[firstArg]();
|
||||
await handler(["--help"]);
|
||||
return;
|
||||
}
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.includes("--version") || args.includes("-V")) {
|
||||
printVersion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine command
|
||||
const firstArg = args[0];
|
||||
|
||||
// No args or starts with -- means interactive mode
|
||||
if (!firstArg || firstArg.startsWith("-")) {
|
||||
const chatHandler = await subcommands.chat!();
|
||||
await chatHandler(args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's "help" command
|
||||
if (firstArg === "help") {
|
||||
const subcommand = args[1];
|
||||
if (subcommand && subcommands[subcommand]) {
|
||||
const handler = await subcommands[subcommand]();
|
||||
await handler(["--help"]);
|
||||
return;
|
||||
}
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a known subcommand
|
||||
if (subcommands[firstArg]) {
|
||||
const handler = await subcommands[firstArg]();
|
||||
await handler(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown command - show error and help
|
||||
console.error(`Unknown command: ${firstArg}`);
|
||||
console.error(`Run 'multica help' for usage information.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,544 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import * as readline from "readline";
|
||||
import { Agent } from "@multica/core";
|
||||
import type { AgentOptions } from "@multica/core";
|
||||
import { SkillManager } from "@multica/core";
|
||||
import { autocompleteInput, type AutocompleteOption } from "./autocomplete.js";
|
||||
import { colors, dim, cyan, brightCyan, yellow, green, gray } from "./colors.js";
|
||||
|
||||
type CliOptions = {
|
||||
profile?: string | undefined;
|
||||
provider?: string | undefined;
|
||||
model?: string | undefined;
|
||||
system?: string | undefined;
|
||||
thinking?: string | undefined;
|
||||
reasoning?: string | undefined;
|
||||
cwd?: string | undefined;
|
||||
session?: string | undefined;
|
||||
help?: boolean | undefined;
|
||||
};
|
||||
|
||||
const COMMANDS = {
|
||||
help: "Show this help message",
|
||||
exit: "Exit the CLI (aliases: quit, q)",
|
||||
clear: "Clear the current session and start fresh",
|
||||
session: "Show current session ID",
|
||||
new: "Start a new session",
|
||||
multiline: "Toggle multi-line input mode (end with a line containing only '.')",
|
||||
};
|
||||
|
||||
function printUsage() {
|
||||
console.log(`${cyan("Usage:")} pnpm agent:interactive [options]`);
|
||||
console.log("");
|
||||
console.log(`${cyan("Options:")}`);
|
||||
console.log(` ${yellow("--profile")} ID Load agent profile (identity, soul, tools)`);
|
||||
console.log(` ${yellow("--provider")} NAME LLM provider (e.g., openai, anthropic, kimi)`);
|
||||
console.log(` ${yellow("--model")} NAME Model name`);
|
||||
console.log(` ${yellow("--system")} TEXT System prompt (ignored if --profile is set)`);
|
||||
console.log(` ${yellow("--thinking")} LEVEL Thinking level`);
|
||||
console.log(` ${yellow("--reasoning")} MODE Reasoning display mode (off, on, stream)`);
|
||||
console.log(` ${yellow("--cwd")} DIR Working directory for commands`);
|
||||
console.log(` ${yellow("--session")} ID Session ID to resume`);
|
||||
console.log(` ${yellow("--help")}, -h Show this help`);
|
||||
console.log("");
|
||||
console.log(`${cyan("Commands")} (use during interaction):`);
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = [...argv];
|
||||
const opts: CliOptions = {};
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
opts.help = true;
|
||||
break;
|
||||
}
|
||||
if (arg === "--profile") {
|
||||
opts.profile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model") {
|
||||
opts.model = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--system") {
|
||||
opts.system = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--thinking") {
|
||||
opts.thinking = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--reasoning") {
|
||||
opts.reasoning = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
opts.cwd = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.session = args.shift();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printWelcome(sessionId: string, opts: CliOptions) {
|
||||
const border = cyan("│");
|
||||
const topBorder = cyan("╭─────────────────────────────────────────╮");
|
||||
const bottomBorder = cyan("╰─────────────────────────────────────────╯");
|
||||
|
||||
console.log(topBorder);
|
||||
console.log(`${border} ${brightCyan("Super Multica Interactive CLI")} ${border}`);
|
||||
console.log(bottomBorder);
|
||||
|
||||
// Show configuration
|
||||
const configLines: string[] = [];
|
||||
configLines.push(`${dim("Session:")} ${gray(sessionId.slice(0, 8))}...`);
|
||||
if (opts.profile) {
|
||||
configLines.push(`${dim("Profile:")} ${yellow(opts.profile)}`);
|
||||
}
|
||||
if (opts.provider) {
|
||||
configLines.push(`${dim("Provider:")} ${green(opts.provider)}`);
|
||||
}
|
||||
if (opts.model) {
|
||||
configLines.push(`${dim("Model:")} ${green(opts.model)}`);
|
||||
}
|
||||
|
||||
console.log(configLines.join(" "));
|
||||
console.log(`${dim("Type")} ${cyan("/help")} ${dim("for commands,")} ${cyan("/exit")} ${dim("to quit.")}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printHelp(skillManager?: SkillManager) {
|
||||
console.log(`\n${cyan("Built-in commands:")}`);
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
|
||||
}
|
||||
|
||||
// Show skill commands if available
|
||||
if (skillManager) {
|
||||
const reservedNames = new Set(Object.keys(COMMANDS));
|
||||
const skillCommands = skillManager.getSkillCommands({ reservedNames });
|
||||
if (skillCommands.length > 0) {
|
||||
console.log(`\n${cyan("Skill commands:")}`);
|
||||
for (const cmd of skillCommands) {
|
||||
console.log(` ${yellow(`/${cmd.name}`.padEnd(14))} ${dim(cmd.description)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Just type your message and press Enter to chat with the agent.")}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Status Bar - renders a persistent status line at the bottom of the terminal
|
||||
*/
|
||||
class StatusBar {
|
||||
private enabled: boolean;
|
||||
private currentStatus: string = "";
|
||||
private stream: NodeJS.WriteStream;
|
||||
|
||||
constructor(stream: NodeJS.WriteStream = process.stdout) {
|
||||
this.stream = stream;
|
||||
this.enabled = stream.isTTY === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status bar content
|
||||
*/
|
||||
update(parts: { session?: string; provider?: string; model?: string; tokens?: number }) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const segments: string[] = [];
|
||||
|
||||
if (parts.session) {
|
||||
segments.push(`${dim("session:")}${gray(parts.session.slice(0, 8))}`);
|
||||
}
|
||||
if (parts.provider) {
|
||||
segments.push(`${dim("provider:")}${green(parts.provider)}`);
|
||||
}
|
||||
if (parts.model) {
|
||||
segments.push(`${dim("model:")}${yellow(parts.model)}`);
|
||||
}
|
||||
if (parts.tokens !== undefined) {
|
||||
segments.push(`${dim("tokens:")}${cyan(String(parts.tokens))}`);
|
||||
}
|
||||
|
||||
this.currentStatus = segments.join(" ");
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the status bar
|
||||
*/
|
||||
private render() {
|
||||
if (!this.enabled || !this.currentStatus) return;
|
||||
|
||||
const termWidth = this.stream.columns || 80;
|
||||
const termHeight = this.stream.rows || 24;
|
||||
|
||||
// Save cursor, move to bottom, clear line, write status, restore cursor
|
||||
const statusLine = ` ${this.currentStatus} `.slice(0, termWidth);
|
||||
|
||||
this.stream.write(
|
||||
`\x1b[s` + // Save cursor
|
||||
`\x1b[${termHeight};1H` + // Move to last row
|
||||
`\x1b[7m` + // Inverse video (highlight)
|
||||
`\x1b[2K` + // Clear line
|
||||
statusLine.padEnd(termWidth) + // Write status padded to terminal width
|
||||
`\x1b[0m` + // Reset
|
||||
`\x1b[u` // Restore cursor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the status bar
|
||||
*/
|
||||
clear() {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const termHeight = this.stream.rows || 24;
|
||||
|
||||
this.stream.write(
|
||||
`\x1b[s` + // Save cursor
|
||||
`\x1b[${termHeight};1H` + // Move to last row
|
||||
`\x1b[2K` + // Clear line
|
||||
`\x1b[u` // Restore cursor
|
||||
);
|
||||
this.currentStatus = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily hide status bar (for clean output)
|
||||
*/
|
||||
hide() {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status bar again
|
||||
*/
|
||||
show() {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
class InteractiveCLI {
|
||||
private agent: Agent;
|
||||
private opts: CliOptions;
|
||||
private rl: readline.Interface | null = null;
|
||||
private multilineMode = false;
|
||||
private multilineBuffer: string[] = [];
|
||||
private running = true;
|
||||
private skillManager: SkillManager;
|
||||
private reservedNames: Set<string>;
|
||||
private statusBar: StatusBar;
|
||||
|
||||
constructor(opts: CliOptions) {
|
||||
this.opts = opts;
|
||||
this.agent = this.createAgent(opts.session);
|
||||
this.statusBar = new StatusBar();
|
||||
|
||||
// Initialize SkillManager for tab completion
|
||||
this.skillManager = new SkillManager({
|
||||
profileId: opts.profile,
|
||||
});
|
||||
|
||||
// Build list of reserved command names (built-in CLI commands)
|
||||
this.reservedNames = new Set(Object.keys(COMMANDS));
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
process.on("SIGINT", () => {
|
||||
this.statusBar.clear();
|
||||
console.log(`\n${dim("Goodbye!")}`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create readline interface (lazy initialization)
|
||||
* Only created when needed for multiline mode to avoid interfering with autocomplete
|
||||
*/
|
||||
private getReadline(): readline.Interface {
|
||||
if (!this.rl) {
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
this.rl.on("close", () => {
|
||||
this.running = false;
|
||||
this.statusBar.clear();
|
||||
console.log(`\n${dim("Goodbye!")}`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
return this.rl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close readline interface when not needed
|
||||
*/
|
||||
private closeReadline() {
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
this.rl = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for input
|
||||
*/
|
||||
private getSuggestions(input: string): AutocompleteOption[] {
|
||||
if (!input.startsWith("/")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prefix = input.slice(1).toLowerCase();
|
||||
const suggestions: AutocompleteOption[] = [];
|
||||
|
||||
// Add built-in command suggestions
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
if (cmd.toLowerCase().startsWith(prefix)) {
|
||||
suggestions.push({
|
||||
value: `/${cmd}`,
|
||||
label: desc.slice(0, 40),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add skill command suggestions
|
||||
const skillCommands = this.skillManager.getSkillCommands({ reservedNames: this.reservedNames });
|
||||
for (const cmd of skillCommands) {
|
||||
if (cmd.name.toLowerCase().startsWith(prefix)) {
|
||||
suggestions.push({
|
||||
value: `/${cmd.name}`,
|
||||
label: cmd.description.slice(0, 40),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: shorter first, then alphabetically
|
||||
suggestions.sort((a, b) => {
|
||||
if (a.value.length !== b.value.length) return a.value.length - b.value.length;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private createAgent(sessionId?: string): Agent {
|
||||
return new Agent({
|
||||
profileId: this.opts.profile,
|
||||
provider: this.opts.provider,
|
||||
model: this.opts.model,
|
||||
systemPrompt: this.opts.system,
|
||||
thinkingLevel: this.opts.thinking as AgentOptions["thinkingLevel"],
|
||||
reasoningMode: this.opts.reasoning as AgentOptions["reasoningMode"],
|
||||
cwd: this.opts.cwd,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
private prompt(): string {
|
||||
if (this.multilineMode) {
|
||||
return this.multilineBuffer.length === 0 ? cyan(">>> ") : cyan("... ");
|
||||
}
|
||||
return `${brightCyan("You:")} `;
|
||||
}
|
||||
|
||||
private updateStatusBar() {
|
||||
const statusUpdate: { session?: string; provider?: string; model?: string; tokens?: number } = {
|
||||
session: this.agent.sessionId,
|
||||
provider: this.opts.provider ?? "default",
|
||||
};
|
||||
if (this.opts.model) {
|
||||
statusUpdate.model = this.opts.model;
|
||||
}
|
||||
this.statusBar.update(statusUpdate);
|
||||
}
|
||||
|
||||
async run() {
|
||||
printWelcome(this.agent.sessionId, this.opts);
|
||||
this.updateStatusBar();
|
||||
await this.loop();
|
||||
}
|
||||
|
||||
private async loop() {
|
||||
while (this.running) {
|
||||
let input: string;
|
||||
|
||||
if (this.multilineMode) {
|
||||
// Use simple readline for multiline mode
|
||||
const lineInput = await this.readline(this.prompt());
|
||||
if (lineInput === null) break;
|
||||
input = lineInput;
|
||||
|
||||
if (input === ".") {
|
||||
// End of multiline input
|
||||
const fullInput = this.multilineBuffer.join("\n");
|
||||
this.multilineBuffer = [];
|
||||
this.multilineMode = false;
|
||||
// Close readline to avoid interfering with autocomplete
|
||||
this.closeReadline();
|
||||
if (fullInput.trim()) {
|
||||
await this.handleInput(fullInput);
|
||||
}
|
||||
} else {
|
||||
this.multilineBuffer.push(input);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use autocomplete input for normal mode
|
||||
try {
|
||||
this.statusBar.hide();
|
||||
input = await autocompleteInput({
|
||||
prompt: this.prompt(),
|
||||
getSuggestions: (text) => this.getSuggestions(text),
|
||||
maxSuggestions: 8,
|
||||
});
|
||||
this.statusBar.show();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
const handled = await this.handleCommand(trimmed);
|
||||
if (!handled) {
|
||||
await this.handleInput(trimmed);
|
||||
}
|
||||
} else {
|
||||
await this.handleInput(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readline(prompt: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.getReadline().question(prompt, (answer) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCommand(input: string): Promise<boolean> {
|
||||
const cmd = input.slice(1).toLowerCase().split(/\s+/)[0];
|
||||
|
||||
switch (cmd) {
|
||||
case "help":
|
||||
printHelp(this.skillManager);
|
||||
return true;
|
||||
|
||||
case "exit":
|
||||
case "quit":
|
||||
case "q":
|
||||
this.statusBar.clear();
|
||||
console.log(dim("Goodbye!"));
|
||||
this.running = false;
|
||||
this.closeReadline();
|
||||
process.exit(0);
|
||||
return true;
|
||||
|
||||
case "clear":
|
||||
this.agent = this.createAgent();
|
||||
this.updateStatusBar();
|
||||
console.log(`${green("Session cleared.")} ${dim("New session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
return true;
|
||||
|
||||
case "session":
|
||||
console.log(`${dim("Current session:")} ${cyan(this.agent.sessionId)}\n`);
|
||||
return true;
|
||||
|
||||
case "new":
|
||||
this.agent = this.createAgent();
|
||||
this.updateStatusBar();
|
||||
console.log(`${green("Started new session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
return true;
|
||||
|
||||
case "multiline":
|
||||
this.multilineMode = !this.multilineMode;
|
||||
if (this.multilineMode) {
|
||||
console.log(`${green("Multi-line mode enabled.")} ${dim("End input with a line containing only '.'")}`);
|
||||
this.multilineBuffer = [];
|
||||
} else {
|
||||
console.log(dim("Multi-line mode disabled."));
|
||||
this.multilineBuffer = [];
|
||||
// Close readline to avoid interfering with autocomplete
|
||||
this.closeReadline();
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
// Check if it's a skill command
|
||||
const invocation = this.skillManager.resolveCommand(input);
|
||||
if (invocation) {
|
||||
// Skill command found - send to agent with skill instructions as context
|
||||
const skillPrompt = invocation.args
|
||||
? `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}\n\nUser request: ${invocation.args}`
|
||||
: `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}`;
|
||||
await this.handleInput(skillPrompt);
|
||||
return true;
|
||||
}
|
||||
// Unknown command - let the agent handle it as-is
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInput(input: string) {
|
||||
try {
|
||||
console.log(""); // Add spacing before response
|
||||
this.statusBar.hide();
|
||||
const result = await this.agent.run(input);
|
||||
this.statusBar.show();
|
||||
if (result.error) {
|
||||
console.error(`\n${colors.error(`Error: ${result.error}`)}`);
|
||||
}
|
||||
console.log(""); // Add spacing after response
|
||||
} catch (err) {
|
||||
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if running in a TTY
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(colors.error("Error: Interactive CLI requires a TTY. Use agent:cli for non-interactive mode."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cli = new InteractiveCLI(opts);
|
||||
await cli.run();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import { Agent } from "@multica/core";
|
||||
|
||||
type CliOptions = {
|
||||
profile?: string | undefined;
|
||||
provider?: string | undefined;
|
||||
model?: string | undefined;
|
||||
apiKey?: string | undefined;
|
||||
baseUrl?: string | undefined;
|
||||
system?: string | undefined;
|
||||
thinking?: string | undefined;
|
||||
reasoning?: string | undefined;
|
||||
cwd?: string | undefined;
|
||||
session?: string | undefined;
|
||||
debug?: boolean | undefined;
|
||||
help?: boolean | undefined;
|
||||
// Tools configuration
|
||||
toolsProfile?: string | undefined;
|
||||
toolsAllow?: string[] | undefined;
|
||||
toolsDeny?: string[] | undefined;
|
||||
};
|
||||
|
||||
function printUsage() {
|
||||
console.log("Usage: pnpm agent:cli [options] <prompt>");
|
||||
console.log(" echo \"your prompt\" | pnpm agent:cli");
|
||||
console.log("");
|
||||
console.log("Options:");
|
||||
console.log(" --profile ID Load agent profile (identity, soul, tools)");
|
||||
console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)");
|
||||
console.log(" --model NAME Model name");
|
||||
console.log(" --api-key KEY API key (overrides environment variable)");
|
||||
console.log(" --base-url URL Custom base URL for the provider");
|
||||
console.log(" --system TEXT System prompt (ignored if --profile is set)");
|
||||
console.log(" --thinking LEVEL Thinking level");
|
||||
console.log(" --reasoning MODE Reasoning display mode (off, on, stream)");
|
||||
console.log(" --cwd DIR Working directory for commands");
|
||||
console.log(" --session ID Session ID for conversation persistence");
|
||||
console.log(" --debug Enable debug logging");
|
||||
console.log(" --help, -h Show this help");
|
||||
console.log("");
|
||||
console.log("Tools Configuration:");
|
||||
console.log(" --tools-profile PROFILE Tool profile (minimal, coding, web, full)");
|
||||
console.log(" --tools-allow TOOLS Allow specific tools (comma-separated, supports group:*)");
|
||||
console.log(" --tools-deny TOOLS Deny specific tools (comma-separated)");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(' pnpm agent:cli --tools-profile coding "list files"');
|
||||
console.log(' pnpm agent:cli --tools-profile minimal --tools-allow exec "run ls"');
|
||||
console.log(' pnpm agent:cli --tools-deny exec,process "read file.txt"');
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = [...argv];
|
||||
const opts: CliOptions = {};
|
||||
const promptParts: string[] = [];
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
opts.help = true;
|
||||
break;
|
||||
}
|
||||
if (arg === "--profile") {
|
||||
opts.profile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model") {
|
||||
opts.model = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--api-key") {
|
||||
opts.apiKey = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base-url") {
|
||||
opts.baseUrl = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--system") {
|
||||
opts.system = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--thinking") {
|
||||
opts.thinking = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--reasoning") {
|
||||
opts.reasoning = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
opts.cwd = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.session = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--debug") {
|
||||
opts.debug = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-profile") {
|
||||
opts.toolsProfile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-allow") {
|
||||
const value = args.shift();
|
||||
opts.toolsAllow = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-deny") {
|
||||
const value = args.shift();
|
||||
opts.toolsDeny = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
promptParts.push(...args);
|
||||
break;
|
||||
}
|
||||
promptParts.push(arg);
|
||||
}
|
||||
|
||||
return { opts, prompt: promptParts.join(" ") };
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return "";
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (data += chunk));
|
||||
process.stdin.on("end", () => resolve(data.trim()));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { opts, prompt } = parseArgs(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const stdinPrompt = await readStdin();
|
||||
const finalPrompt = prompt || stdinPrompt;
|
||||
if (!finalPrompt) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build tools config if any tools options are set
|
||||
let toolsConfig: import("@multica/core").ToolsConfig | undefined;
|
||||
if (opts.toolsAllow || opts.toolsDeny) {
|
||||
toolsConfig = {};
|
||||
if (opts.toolsAllow) {
|
||||
toolsConfig.allow = opts.toolsAllow;
|
||||
}
|
||||
if (opts.toolsDeny) {
|
||||
toolsConfig.deny = opts.toolsDeny;
|
||||
}
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
profileId: opts.profile,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
apiKey: opts.apiKey,
|
||||
baseUrl: opts.baseUrl,
|
||||
systemPrompt: opts.system,
|
||||
thinkingLevel: opts.thinking as any,
|
||||
reasoningMode: opts.reasoning as any,
|
||||
cwd: opts.cwd,
|
||||
sessionId: opts.session,
|
||||
debug: opts.debug,
|
||||
tools: toolsConfig,
|
||||
});
|
||||
|
||||
// If it's a newly created session, notify user of sessionId
|
||||
if (!opts.session) {
|
||||
console.error(`[session: ${agent.sessionId}]`);
|
||||
}
|
||||
|
||||
const result = await agent.run(finalPrompt);
|
||||
if (result.error) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolDisplayName,
|
||||
formatToolArgs,
|
||||
extractResultDetails,
|
||||
formatResultSummary,
|
||||
} from "./output.js";
|
||||
|
||||
describe("output", () => {
|
||||
describe("toolDisplayName", () => {
|
||||
it("should map known tool names to display names", () => {
|
||||
expect(toolDisplayName("read")).toBe("ReadFile");
|
||||
expect(toolDisplayName("write")).toBe("WriteFile");
|
||||
expect(toolDisplayName("edit")).toBe("EditFile");
|
||||
expect(toolDisplayName("glob")).toBe("Glob");
|
||||
expect(toolDisplayName("web_search")).toBe("WebSearch");
|
||||
expect(toolDisplayName("web_fetch")).toBe("WebFetch");
|
||||
});
|
||||
|
||||
it("should return original name for unknown tools", () => {
|
||||
expect(toolDisplayName("custom_tool")).toBe("custom_tool");
|
||||
expect(toolDisplayName("unknown")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatToolArgs", () => {
|
||||
it("should return empty string for null/undefined args", () => {
|
||||
expect(formatToolArgs("read", null)).toBe("");
|
||||
expect(formatToolArgs("read", undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for non-object args", () => {
|
||||
expect(formatToolArgs("read", "string")).toBe("");
|
||||
expect(formatToolArgs("read", 123)).toBe("");
|
||||
});
|
||||
|
||||
it("should format read tool args", () => {
|
||||
expect(formatToolArgs("read", { path: "/foo/bar.ts" })).toBe("/foo/bar.ts");
|
||||
expect(formatToolArgs("read", { file: "/foo/bar.ts" })).toBe("/foo/bar.ts");
|
||||
});
|
||||
|
||||
it("should format glob tool args", () => {
|
||||
expect(formatToolArgs("glob", { pattern: "**/*.ts" })).toBe("**/*.ts");
|
||||
expect(formatToolArgs("glob", { pattern: "**/*.ts", cwd: "/src" })).toBe("**/*.ts in /src");
|
||||
});
|
||||
|
||||
it("should format web_search tool args with truncation", () => {
|
||||
expect(formatToolArgs("web_search", { query: "short query" })).toBe("short query");
|
||||
const longQuery = "a".repeat(60);
|
||||
expect(formatToolArgs("web_search", { query: longQuery })).toBe("a".repeat(50) + "…");
|
||||
});
|
||||
|
||||
it("should format web_fetch tool args with URL parsing", () => {
|
||||
expect(formatToolArgs("web_fetch", { url: "https://example.com" })).toBe("example.com");
|
||||
expect(formatToolArgs("web_fetch", { url: "https://example.com/" })).toBe("example.com");
|
||||
expect(formatToolArgs("web_fetch", { url: "https://example.com/path/to/page" })).toBe(
|
||||
"example.com/path/to/page"
|
||||
);
|
||||
});
|
||||
|
||||
it("should truncate long URL paths", () => {
|
||||
const longPath = "/very/long/path/that/exceeds/thirty/characters/limit";
|
||||
expect(formatToolArgs("web_fetch", { url: `https://example.com${longPath}` })).toBe(
|
||||
"example.com" + longPath.slice(0, 30) + "…"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle invalid URLs gracefully", () => {
|
||||
expect(formatToolArgs("web_fetch", { url: "not-a-valid-url" })).toBe("not-a-valid-url");
|
||||
const longInvalid = "x".repeat(60);
|
||||
expect(formatToolArgs("web_fetch", { url: longInvalid })).toBe("x".repeat(50) + "…");
|
||||
});
|
||||
|
||||
it("should return empty string for unknown tools", () => {
|
||||
expect(formatToolArgs("unknown_tool", { foo: "bar" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractResultDetails", () => {
|
||||
it("should return null for null/undefined", () => {
|
||||
expect(extractResultDetails(null)).toBeNull();
|
||||
expect(extractResultDetails(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-objects", () => {
|
||||
expect(extractResultDetails("string")).toBeNull();
|
||||
expect(extractResultDetails(123)).toBeNull();
|
||||
});
|
||||
|
||||
it("should extract JSON from AgentMessage content array", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"count": 5, "files": ["a.ts", "b.ts"]}' }],
|
||||
};
|
||||
expect(extractResultDetails(result)).toEqual({ count: 5, files: ["a.ts", "b.ts"] });
|
||||
});
|
||||
|
||||
it("should skip non-text content items", () => {
|
||||
const result = {
|
||||
content: [
|
||||
{ type: "image", data: "..." },
|
||||
{ type: "text", text: '{"value": 42}' },
|
||||
],
|
||||
};
|
||||
expect(extractResultDetails(result)).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it("should handle invalid JSON gracefully", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "not json" }],
|
||||
};
|
||||
// Falls back to returning the object itself
|
||||
expect(extractResultDetails(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it("should prefer details when present", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "not json" }],
|
||||
details: { count: 3, truncated: false },
|
||||
};
|
||||
expect(extractResultDetails(result)).toEqual({ count: 3, truncated: false });
|
||||
});
|
||||
|
||||
it("should return direct object if no content array", () => {
|
||||
const result = { count: 10, truncated: true };
|
||||
expect(extractResultDetails(result)).toEqual({ count: 10, truncated: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResultSummary", () => {
|
||||
describe("glob", () => {
|
||||
it("should format file count from count field", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 5}' }] };
|
||||
expect(formatResultSummary("glob", result)).toBe("5 files");
|
||||
});
|
||||
|
||||
it("should format file count from files array length", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"files": ["a.ts", "b.ts", "c.ts"]}' }],
|
||||
};
|
||||
expect(formatResultSummary("glob", result)).toBe("3 files");
|
||||
});
|
||||
|
||||
it("should show + for truncated results", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 100, "truncated": true}' }] };
|
||||
expect(formatResultSummary("glob", result)).toBe("100+ files");
|
||||
});
|
||||
|
||||
it("should handle zero files", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 0, "files": []}' }] };
|
||||
expect(formatResultSummary("glob", result)).toBe("0 files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search", () => {
|
||||
it("should format error results", () => {
|
||||
const result = { content: [{ type: "text", text: '{"error": true, "message": "API error"}' }] };
|
||||
expect(formatResultSummary("web_search", result)).toBe("error: API error");
|
||||
});
|
||||
|
||||
it("should format Perplexity results with citations", () => {
|
||||
const result = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '{"content": "answer text", "citations": ["url1", "url2", "url3"]}',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(formatResultSummary("web_search", result)).toBe("3 citations");
|
||||
});
|
||||
|
||||
it("should format Brave results with count", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 10}' }] };
|
||||
expect(formatResultSummary("web_search", result)).toBe("10 results");
|
||||
});
|
||||
|
||||
it("should count results array if no count field", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"results": [{}, {}, {}]}' }],
|
||||
};
|
||||
expect(formatResultSummary("web_search", result)).toBe("3 results");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_fetch", () => {
|
||||
it("should format error results", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"error": true, "message": "404 Not Found"}' }],
|
||||
};
|
||||
expect(formatResultSummary("web_fetch", result)).toBe("error: 404 Not Found");
|
||||
});
|
||||
|
||||
it("should format title", () => {
|
||||
const result = { content: [{ type: "text", text: '{"title": "Example Page"}' }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe('"Example Page"');
|
||||
});
|
||||
|
||||
it("should truncate long titles", () => {
|
||||
const longTitle = "A".repeat(50);
|
||||
const result = { content: [{ type: "text", text: `{"title": "${longTitle}"}` }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe(`"${"A".repeat(30)}…"`);
|
||||
});
|
||||
|
||||
it("should format content length in KB", () => {
|
||||
const result = { content: [{ type: "text", text: '{"length": 2048}' }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe("2.0KB");
|
||||
});
|
||||
|
||||
it("should show cached indicator", () => {
|
||||
const result = { content: [{ type: "text", text: '{"cached": true}' }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe("cached");
|
||||
});
|
||||
|
||||
it("should combine multiple fields", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"title": "Page", "length": 1024, "cached": true}' }],
|
||||
};
|
||||
expect(formatResultSummary("web_fetch", result)).toBe('"Page", 1.0KB, cached');
|
||||
});
|
||||
});
|
||||
|
||||
describe("grep", () => {
|
||||
it("should return 'no matches' for empty results", () => {
|
||||
const result = { content: [{ type: "text", text: "No matches found" }] };
|
||||
expect(formatResultSummary("grep", result)).toBe("no matches");
|
||||
});
|
||||
|
||||
it("should count non-empty lines as matches", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "file.ts:1:match1\nfile.ts:2:match2\nfile.ts:3:match3" }],
|
||||
};
|
||||
expect(formatResultSummary("grep", result)).toBe("3 matches");
|
||||
});
|
||||
|
||||
it("should ignore empty lines when counting", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "file.ts:1:match1\n\nfile.ts:2:match2\n" }],
|
||||
};
|
||||
expect(formatResultSummary("grep", result)).toBe("2 matches");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty string for unknown tools", () => {
|
||||
const result = { content: [{ type: "text", text: '{"data": "value"}' }] };
|
||||
expect(formatResultSummary("unknown_tool", result)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for null result", () => {
|
||||
expect(formatResultSummary("glob", null)).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { colors, createSpinner, dim } from "./colors.js";
|
||||
import { extractText, extractThinking } from "@multica/core";
|
||||
import type { ReasoningMode } from "@multica/core";
|
||||
|
||||
export type AgentOutputState = {
|
||||
lastAssistantText: string;
|
||||
lastAssistantThinking: string;
|
||||
printedLen: number;
|
||||
printedThinkingLen: number;
|
||||
streaming: boolean;
|
||||
};
|
||||
|
||||
export type AgentOutput = {
|
||||
state: AgentOutputState;
|
||||
handleEvent: (event: AgentEvent) => void;
|
||||
};
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function toolDisplayName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
read: "ReadFile",
|
||||
write: "WriteFile",
|
||||
edit: "EditFile",
|
||||
exec: "Exec",
|
||||
process: "Process",
|
||||
grep: "Grep",
|
||||
find: "FindFiles",
|
||||
ls: "ListDir",
|
||||
glob: "Glob",
|
||||
web_search: "WebSearch",
|
||||
web_fetch: "WebFetch",
|
||||
};
|
||||
return map[name] || name;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function formatToolArgs(name: string, args: unknown): string {
|
||||
if (!args || typeof args !== "object") return "";
|
||||
const record = args as Record<string, unknown>;
|
||||
const get = (key: string) => (record[key] !== undefined ? String(record[key]) : "");
|
||||
switch (name) {
|
||||
case "read":
|
||||
return get("path") || get("file");
|
||||
case "write":
|
||||
return get("path") || get("file");
|
||||
case "edit":
|
||||
return get("path") || get("file");
|
||||
case "grep":
|
||||
return [get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
|
||||
case "find":
|
||||
return [get("glob") || get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
|
||||
case "ls":
|
||||
return get("path") || get("directory");
|
||||
case "exec":
|
||||
return get("command");
|
||||
case "process":
|
||||
return [get("action"), get("id")].filter(Boolean).join(" ");
|
||||
case "glob":
|
||||
return [get("pattern"), get("cwd")].filter(Boolean).join(" in ");
|
||||
case "web_search":
|
||||
return truncate(get("query"), 50);
|
||||
case "web_fetch": {
|
||||
const url = get("url");
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname + (parsed.pathname !== "/" ? truncate(parsed.pathname, 30) : "");
|
||||
} catch {
|
||||
return truncate(url, 50);
|
||||
}
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolLine(name: string, args: unknown, result?: unknown): string {
|
||||
const title = colors.toolName(toolDisplayName(name));
|
||||
const argText = formatToolArgs(name, args);
|
||||
const resultSummary = formatResultSummary(name, result);
|
||||
const bullet = colors.toolBullet("•");
|
||||
|
||||
let line = `${bullet} ${title}`;
|
||||
if (argText) {
|
||||
line += ` ${colors.toolArgs(`(${argText})`)}`;
|
||||
}
|
||||
if (resultSummary) {
|
||||
line += ` ${colors.toolArrow("→")} ${colors.toolArgs(resultSummary)}`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function extractResultDetails(result: unknown): Record<string, unknown> | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
|
||||
// Try to extract from AgentMessage content array (JSON result)
|
||||
const msg = result as { content?: Array<{ type: string; text?: string }> };
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const c of msg.content) {
|
||||
if (c.type === "text" && c.text) {
|
||||
try {
|
||||
return JSON.parse(c.text) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const withDetails = result as { details?: unknown };
|
||||
if (withDetails.details && typeof withDetails.details === "object") {
|
||||
return withDetails.details as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Try direct object access
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function formatResultSummary(name: string, result: unknown): string {
|
||||
const details = extractResultDetails(result);
|
||||
if (!details) return "";
|
||||
|
||||
switch (name) {
|
||||
case "glob": {
|
||||
const count = details.count ?? (Array.isArray(details.files) ? details.files.length : 0);
|
||||
const truncated = details.truncated ? "+" : "";
|
||||
return `${count}${truncated} files`;
|
||||
}
|
||||
case "web_search": {
|
||||
if (details.error) return `error: ${details.message || details.error}`;
|
||||
if (details.content) {
|
||||
// Perplexity result
|
||||
const citations = Array.isArray(details.citations) ? details.citations.length : 0;
|
||||
return `${citations} citations`;
|
||||
}
|
||||
// Brave result
|
||||
const count = details.count ?? (Array.isArray(details.results) ? details.results.length : 0);
|
||||
return `${count} results`;
|
||||
}
|
||||
case "web_fetch": {
|
||||
if (details.error) return `error: ${details.message || details.error}`;
|
||||
const parts: string[] = [];
|
||||
if (details.title) {
|
||||
parts.push(`"${truncate(String(details.title), 30)}"`);
|
||||
}
|
||||
if (typeof details.length === "number") {
|
||||
const kb = (details.length / 1024).toFixed(1);
|
||||
parts.push(`${kb}KB`);
|
||||
}
|
||||
if (details.cached) {
|
||||
parts.push("cached");
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
case "grep": {
|
||||
// Try to count matches from result text
|
||||
const text = extractText(result as AgentMessage | undefined);
|
||||
if (text.includes("No matches found")) return "no matches";
|
||||
const lines = text.split("\n").filter((l) => l.trim()).length;
|
||||
if (lines > 0) return `${lines} matches`;
|
||||
return "";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentOutput(params: {
|
||||
stdout: NodeJS.WritableStream;
|
||||
stderr: NodeJS.WritableStream;
|
||||
reasoningMode?: ReasoningMode;
|
||||
}): AgentOutput {
|
||||
const reasoningMode = params.reasoningMode ?? "stream";
|
||||
const state: AgentOutputState = {
|
||||
lastAssistantText: "",
|
||||
lastAssistantThinking: "",
|
||||
printedLen: 0,
|
||||
printedThinkingLen: 0,
|
||||
streaming: false,
|
||||
};
|
||||
|
||||
// Create spinner for thinking indicator
|
||||
const spinner = createSpinner({ stream: params.stderr });
|
||||
let pendingToolName = "";
|
||||
let pendingToolArgs: unknown = null;
|
||||
|
||||
const handleEvent = (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "message_start": {
|
||||
const msg = event.message;
|
||||
if (msg.role === "assistant") {
|
||||
// Stop any running spinner when assistant starts responding
|
||||
if (spinner.isSpinning()) {
|
||||
spinner.stop();
|
||||
}
|
||||
state.streaming = true;
|
||||
state.printedLen = 0;
|
||||
state.printedThinkingLen = 0;
|
||||
const text = extractText(msg);
|
||||
if (text.length > 0) {
|
||||
params.stdout.write(text);
|
||||
state.printedLen = text.length;
|
||||
}
|
||||
// Stream thinking content in real-time
|
||||
if (reasoningMode === "stream") {
|
||||
const thinking = extractThinking(msg);
|
||||
if (thinking.length > 0) {
|
||||
params.stderr.write(dim(thinking));
|
||||
state.printedThinkingLen = thinking.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "message_update": {
|
||||
const msg = event.message;
|
||||
if (msg.role === "assistant") {
|
||||
const text = extractText(msg);
|
||||
if (text.length > state.printedLen) {
|
||||
params.stdout.write(text.slice(state.printedLen));
|
||||
state.printedLen = text.length;
|
||||
}
|
||||
// Stream thinking content in real-time
|
||||
if (reasoningMode === "stream") {
|
||||
const thinking = extractThinking(msg);
|
||||
if (thinking.length > state.printedThinkingLen) {
|
||||
params.stderr.write(dim(thinking.slice(state.printedThinkingLen)));
|
||||
state.printedThinkingLen = thinking.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
const msg = event.message;
|
||||
if (msg.role === "assistant") {
|
||||
const text = extractText(msg);
|
||||
if (text.length > state.printedLen) {
|
||||
params.stdout.write(text.slice(state.printedLen));
|
||||
state.printedLen = text.length;
|
||||
}
|
||||
if (state.streaming) params.stdout.write("\n");
|
||||
state.streaming = false;
|
||||
state.lastAssistantText = text;
|
||||
|
||||
// Extract and store thinking content (skip when off)
|
||||
const thinking = reasoningMode !== "off" ? extractThinking(msg) : "";
|
||||
state.lastAssistantThinking = thinking;
|
||||
|
||||
// Show thinking at end for "on" mode
|
||||
if (reasoningMode === "on" && thinking) {
|
||||
params.stderr.write(`\n${dim("--- Thinking ---")}\n`);
|
||||
params.stderr.write(dim(thinking));
|
||||
params.stderr.write(`\n${dim("--- End Thinking ---")}\n`);
|
||||
}
|
||||
// Finish streaming thinking with a newline
|
||||
if (reasoningMode === "stream" && state.printedThinkingLen > 0) {
|
||||
params.stderr.write("\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
pendingToolName = event.toolName;
|
||||
pendingToolArgs = event.args;
|
||||
const title = colors.toolName(toolDisplayName(event.toolName));
|
||||
const argText = formatToolArgs(event.toolName, event.args);
|
||||
const displayText = argText ? `${title} ${colors.toolArgs(`(${argText})`)}` : title;
|
||||
spinner.start(displayText);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_update": {
|
||||
// Show real-time output updates (e.g., from exec tool)
|
||||
const updateText = extractText(event.partialResult);
|
||||
if (updateText && pendingToolName) {
|
||||
const title = colors.toolName(toolDisplayName(pendingToolName));
|
||||
const preview = colors.toolArgs(updateText.slice(-50).replace(/\n/g, " "));
|
||||
spinner.update(`${title} ${colors.toolArrow("→")} ${preview}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
// Stop spinner and show final result with summary
|
||||
const details = extractResultDetails(event.result);
|
||||
const errorField = details?.error;
|
||||
const hasError =
|
||||
event.isError ||
|
||||
Boolean(errorField) ||
|
||||
details?.success === false;
|
||||
if (hasError) {
|
||||
const errorText =
|
||||
(typeof details?.message === "string" && details.message) ||
|
||||
(typeof errorField === "string" && errorField) ||
|
||||
extractText(event.result) ||
|
||||
"Tool failed";
|
||||
const bullet = colors.toolError("✗");
|
||||
const title = colors.toolName(toolDisplayName(event.toolName));
|
||||
spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`);
|
||||
} else {
|
||||
spinner.stop(formatToolLine(event.toolName, pendingToolArgs, event.result));
|
||||
}
|
||||
pendingToolName = "";
|
||||
pendingToolArgs = null;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return { state, handleEvent };
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Agent Profile CLI
|
||||
*
|
||||
* Commands:
|
||||
* new <id> Create a new profile with default templates
|
||||
* list List all profiles
|
||||
* show <id> Show profile contents
|
||||
* edit <id> Open profile directory
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createAgentProfile,
|
||||
loadAgentProfile,
|
||||
getProfileDir,
|
||||
profileExists,
|
||||
} from "@multica/core";
|
||||
import { DATA_DIR } from "@multica/utils";
|
||||
|
||||
const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles");
|
||||
|
||||
type Command = "new" | "list" | "show" | "edit" | "help";
|
||||
|
||||
function printUsage() {
|
||||
console.log("Usage: pnpm profile <command> [options]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" new <id> Create a new profile with default templates");
|
||||
console.log(" list List all profiles");
|
||||
console.log(" show <id> Show profile contents");
|
||||
console.log(" edit <id> Open profile directory in Finder/file manager");
|
||||
console.log(" help Show this help");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" pnpm profile new my-agent");
|
||||
console.log(" pnpm profile list");
|
||||
console.log(" pnpm profile show my-agent");
|
||||
}
|
||||
|
||||
function cmdNew(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: pnpm profile new <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate profile ID
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(profileId)) {
|
||||
console.error("Error: Profile ID can only contain letters, numbers, hyphens, and underscores");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" already exists`);
|
||||
console.error(`Location: ${getProfileDir(profileId)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profile = createAgentProfile(profileId);
|
||||
const dir = getProfileDir(profileId);
|
||||
|
||||
console.log(`Created profile: ${profile.id}`);
|
||||
console.log(`Location: ${dir}`);
|
||||
console.log("");
|
||||
console.log("Files created:");
|
||||
console.log(" - soul.md (identity, personality and behavior)");
|
||||
console.log(" - user.md (information about the user)");
|
||||
console.log(" - workspace.md (workspace rules and conventions)");
|
||||
console.log("");
|
||||
console.log("Edit these files to customize your agent, then run:");
|
||||
console.log(` pnpm agent:cli --profile ${profileId} "Hello"`);
|
||||
}
|
||||
|
||||
function cmdList() {
|
||||
if (!existsSync(DEFAULT_BASE_DIR)) {
|
||||
console.log("No profiles found.");
|
||||
console.log(`Create one with: pnpm profile new <id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = readdirSync(DEFAULT_BASE_DIR, { withFileTypes: true });
|
||||
const profiles = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
console.log("No profiles found.");
|
||||
console.log(`Create one with: pnpm profile new <id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Available profiles:");
|
||||
console.log("");
|
||||
for (const id of profiles) {
|
||||
const dir = getProfileDir(id);
|
||||
console.log(` ${id}`);
|
||||
console.log(` ${dir}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(`Total: ${profiles.length} profile(s)`);
|
||||
}
|
||||
|
||||
function cmdShow(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: pnpm profile show <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profile = loadAgentProfile(profileId);
|
||||
if (!profile) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
console.error(`Create it with: pnpm profile new ${profileId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Profile: ${profile.id}`);
|
||||
console.log(`Location: ${getProfileDir(profileId)}`);
|
||||
console.log("");
|
||||
|
||||
if (profile.soul) {
|
||||
console.log("=== soul.md ===");
|
||||
console.log(profile.soul.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.user) {
|
||||
console.log("=== user.md ===");
|
||||
console.log(profile.user.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.workspace) {
|
||||
console.log("=== workspace.md ===");
|
||||
console.log(profile.workspace.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function cmdEdit(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: pnpm profile edit <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
console.error(`Create it with: pnpm profile new ${profileId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dir = getProfileDir(profileId);
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
// Open in default file manager
|
||||
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
||||
spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref();
|
||||
|
||||
console.log(`Opened: ${dir}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = (args[0] || "help") as Command;
|
||||
const arg1 = args[1];
|
||||
|
||||
switch (command) {
|
||||
case "new":
|
||||
cmdNew(arg1);
|
||||
break;
|
||||
case "list":
|
||||
cmdList();
|
||||
break;
|
||||
case "show":
|
||||
cmdShow(arg1);
|
||||
break;
|
||||
case "edit":
|
||||
await cmdEdit(arg1);
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printUsage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,540 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Skills CLI
|
||||
*
|
||||
* Command-line interface for managing skills
|
||||
*
|
||||
* Usage:
|
||||
* pnpm skills:cli list List all skills
|
||||
* pnpm skills:cli status [id] Show skill status
|
||||
* pnpm skills:cli install <id> Install skill dependencies
|
||||
* pnpm skills:cli add <source> Add skill from GitHub
|
||||
* pnpm skills:cli remove <name> Remove an installed skill
|
||||
*/
|
||||
|
||||
import {
|
||||
SkillManager,
|
||||
installSkill,
|
||||
getInstallOptions,
|
||||
addSkill,
|
||||
removeSkill,
|
||||
listInstalledSkills,
|
||||
checkEligibilityDetailed,
|
||||
type DiagnosticItem,
|
||||
} from "@multica/core";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type Command = "list" | "status" | "install" | "add" | "remove" | "help";
|
||||
|
||||
interface ParsedArgs {
|
||||
command: Command;
|
||||
args: string[];
|
||||
verbose: boolean;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Argument Parsing
|
||||
// ============================================================================
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args = [...argv];
|
||||
let verbose = false;
|
||||
let force = false;
|
||||
const positional: string[] = [];
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--verbose" || arg === "-v") {
|
||||
verbose = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--force" || arg === "-f") {
|
||||
force = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { command: "help", args: [], verbose, force };
|
||||
}
|
||||
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
const command = (positional[0] ?? "help") as Command;
|
||||
const commandArgs = positional.slice(1);
|
||||
|
||||
return { command, args: commandArgs, verbose, force };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Commands
|
||||
// ============================================================================
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Skills CLI - Manage super-multica skills
|
||||
|
||||
Usage:
|
||||
pnpm skills:cli <command> [options]
|
||||
|
||||
Commands:
|
||||
list List all available skills
|
||||
status [id] Show detailed status of a skill (or all skills)
|
||||
install <id> Install dependencies for a skill
|
||||
add <source> Add skill from GitHub (owner/repo or owner/repo/skill)
|
||||
remove <name> Remove an installed skill
|
||||
|
||||
Options:
|
||||
-v, --verbose Show more details
|
||||
-f, --force Force overwrite existing skill
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
pnpm skills:cli list
|
||||
pnpm skills:cli status commit
|
||||
pnpm skills:cli install nano-pdf
|
||||
pnpm skills:cli add vercel-labs/agent-skills
|
||||
pnpm skills:cli add vercel-labs/agent-skills/perplexity
|
||||
pnpm skills:cli remove agent-skills
|
||||
`);
|
||||
}
|
||||
|
||||
function cmdList(manager: SkillManager, verbose: boolean): void {
|
||||
const skills = manager.listAllSkillsWithStatus();
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log("No skills found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nAvailable Skills:\n");
|
||||
|
||||
for (const skill of skills) {
|
||||
const status = skill.eligible ? "✓" : "✗";
|
||||
const statusColor = skill.eligible ? "\x1b[32m" : "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
console.log(` ${statusColor}${status}${reset} ${skill.emoji} ${skill.name} (${skill.id})`);
|
||||
console.log(` ${skill.description}`);
|
||||
console.log(` Source: ${skill.source}`);
|
||||
|
||||
if (!skill.eligible && skill.reasons) {
|
||||
for (const reason of skill.reasons) {
|
||||
console.log(` ${statusColor}└ ${reason}${reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
const eligibleCount = skills.filter((s) => s.eligible).length;
|
||||
console.log(`Total: ${skills.length} skills (${eligibleCount} eligible)`);
|
||||
}
|
||||
|
||||
function cmdStatus(manager: SkillManager, skillId?: string, verbose?: boolean): void {
|
||||
if (!skillId) {
|
||||
// Show summary status with diagnostics
|
||||
cmdStatusSummary(manager, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show specific skill status with detailed diagnostics
|
||||
cmdStatusDetail(manager, skillId, verbose);
|
||||
}
|
||||
|
||||
function cmdStatusSummary(manager: SkillManager, verbose?: boolean): void {
|
||||
const skills = manager.listAllSkillsWithStatus();
|
||||
const eligible = skills.filter((s) => s.eligible);
|
||||
const ineligible = skills.filter((s) => !s.eligible);
|
||||
|
||||
console.log("\nSkills Status Summary:\n");
|
||||
console.log(` Total: ${skills.length}`);
|
||||
console.log(` \x1b[32mEligible: ${eligible.length}\x1b[0m`);
|
||||
console.log(` \x1b[31mIneligible: ${ineligible.length}\x1b[0m`);
|
||||
|
||||
if (ineligible.length > 0) {
|
||||
console.log("\n─────────────────────────────────────────");
|
||||
console.log("Ineligible Skills:");
|
||||
|
||||
// Group by issue type
|
||||
const byIssue: Map<string, string[]> = new Map();
|
||||
for (const s of ineligible) {
|
||||
const skill = manager.getSkillFromAll(s.id);
|
||||
if (skill) {
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const mainIssue = detailed.diagnostics?.[0]?.type ?? "unknown";
|
||||
const existing = byIssue.get(mainIssue) ?? [];
|
||||
existing.push(s.id);
|
||||
byIssue.set(mainIssue, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Print grouped issues
|
||||
const issueLabels: Record<string, string> = {
|
||||
disabled: "Disabled in config",
|
||||
not_in_allowlist: "Not in allowlist",
|
||||
platform: "Platform mismatch",
|
||||
binary: "Missing binaries",
|
||||
any_binary: "Missing binaries (any)",
|
||||
env: "Missing environment variables",
|
||||
config: "Missing config values",
|
||||
unknown: "Unknown issues",
|
||||
};
|
||||
|
||||
for (const [issue, skillIds] of byIssue) {
|
||||
const label = issueLabels[issue] ?? issue;
|
||||
console.log(`\n \x1b[33m${label}:\x1b[0m`);
|
||||
for (const id of skillIds) {
|
||||
const skill = manager.getSkillFromAll(id);
|
||||
if (skill && verbose) {
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const diag = detailed.diagnostics?.[0];
|
||||
console.log(` - ${id}`);
|
||||
if (diag?.hint) {
|
||||
console.log(` \x1b[36mHint: ${diag.hint}\x1b[0m`);
|
||||
}
|
||||
} else {
|
||||
console.log(` - ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n─────────────────────────────────────────");
|
||||
console.log(`\x1b[36mTip: Run 'pnpm skills:cli status <skill-id>' for detailed diagnostics\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdStatusDetail(manager: SkillManager, skillId: string, verbose?: boolean): void {
|
||||
const skill = manager.getSkillFromAll(skillId);
|
||||
if (!skill) {
|
||||
console.error(`Skill not found: ${skillId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const metadata = skill.frontmatter.metadata;
|
||||
|
||||
console.log(`\n${metadata?.emoji ?? "🔧"} ${skill.frontmatter.name}`);
|
||||
console.log("═".repeat(50));
|
||||
console.log(`ID: ${skill.id}`);
|
||||
console.log(`Description: ${skill.frontmatter.description ?? "N/A"}`);
|
||||
console.log(`Version: ${skill.frontmatter.version ?? "N/A"}`);
|
||||
console.log(`Source: ${skill.source}`);
|
||||
console.log(`Path: ${skill.filePath}`);
|
||||
console.log(`Homepage: ${skill.frontmatter.homepage ?? metadata?.homepage ?? "N/A"}`);
|
||||
|
||||
console.log();
|
||||
console.log("─".repeat(50));
|
||||
console.log(`Status: ${detailed.eligible ? "\x1b[32m✓ ELIGIBLE\x1b[0m" : "\x1b[31m✗ NOT ELIGIBLE\x1b[0m"}`);
|
||||
|
||||
// Show detailed diagnostics
|
||||
if (!detailed.eligible && detailed.diagnostics) {
|
||||
console.log("\nDiagnostics:");
|
||||
for (const diag of detailed.diagnostics) {
|
||||
printDiagnostic(diag);
|
||||
}
|
||||
}
|
||||
|
||||
// Show requirements summary
|
||||
const requirements = metadata?.requires;
|
||||
const hasBins = requirements?.bins?.length ?? metadata?.requiresBinaries?.length ?? 0;
|
||||
const hasAnyBins = requirements?.anyBins?.length ?? 0;
|
||||
const hasEnvs = requirements?.env?.length ?? metadata?.requiresEnv?.length ?? 0;
|
||||
|
||||
if (hasBins > 0 || hasAnyBins > 0 || hasEnvs > 0) {
|
||||
console.log("\n─".repeat(50));
|
||||
console.log("Requirements:");
|
||||
|
||||
if (hasBins > 0) {
|
||||
const bins = requirements?.bins ?? metadata?.requiresBinaries ?? [];
|
||||
printRequirementStatus("Binaries (all required)", bins, checkBinaries);
|
||||
}
|
||||
|
||||
if (hasAnyBins > 0) {
|
||||
const anyBins = requirements?.anyBins ?? [];
|
||||
printRequirementStatus("Binaries (any one)", anyBins, checkBinaries, true);
|
||||
}
|
||||
|
||||
if (hasEnvs > 0) {
|
||||
const envs = requirements?.env ?? metadata?.requiresEnv ?? [];
|
||||
printRequirementStatus("Environment vars", envs, (e) => checkEnvVars(e, skill.env));
|
||||
}
|
||||
}
|
||||
|
||||
// Show install options
|
||||
const installOptions = getInstallOptions(skill);
|
||||
if (installOptions.length > 0) {
|
||||
console.log("\n─".repeat(50));
|
||||
console.log("Install Options:");
|
||||
for (const opt of installOptions) {
|
||||
const status = opt.available ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
|
||||
console.log(` ${status} [${opt.id}] ${opt.label}`);
|
||||
if (!opt.available && opt.reason) {
|
||||
console.log(` └ ${opt.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show quick actions if not eligible
|
||||
if (!detailed.eligible) {
|
||||
console.log("\n─".repeat(50));
|
||||
console.log("\x1b[33mQuick Actions:\x1b[0m");
|
||||
|
||||
for (const diag of detailed.diagnostics ?? []) {
|
||||
if (diag.hint) {
|
||||
console.log(` → ${diag.hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (installOptions.length > 0) {
|
||||
console.log(` → pnpm skills:cli install ${skillId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printDiagnostic(diag: DiagnosticItem): void {
|
||||
const typeColors: Record<string, string> = {
|
||||
disabled: "\x1b[33m",
|
||||
not_in_allowlist: "\x1b[33m",
|
||||
platform: "\x1b[35m",
|
||||
binary: "\x1b[31m",
|
||||
any_binary: "\x1b[31m",
|
||||
env: "\x1b[34m",
|
||||
config: "\x1b[36m",
|
||||
};
|
||||
|
||||
const color = typeColors[diag.type] ?? "\x1b[37m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
console.log(`\n ${color}[${diag.type.toUpperCase()}]${reset}`);
|
||||
console.log(` ${diag.message}`);
|
||||
|
||||
if (diag.values && diag.values.length > 0) {
|
||||
console.log(` Values: ${diag.values.join(", ")}`);
|
||||
}
|
||||
|
||||
if (diag.hint) {
|
||||
console.log(` \x1b[36m💡 ${diag.hint}${reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printRequirementStatus(
|
||||
label: string,
|
||||
items: string[],
|
||||
checker: (items: string[]) => Map<string, boolean>,
|
||||
anyMode: boolean = false,
|
||||
): void {
|
||||
const status = checker(items);
|
||||
const found = Array.from(status.entries()).filter(([, ok]) => ok).map(([name]) => name);
|
||||
const missing = Array.from(status.entries()).filter(([, ok]) => !ok).map(([name]) => name);
|
||||
|
||||
const allOk = anyMode ? found.length > 0 : missing.length === 0;
|
||||
const statusIcon = allOk ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
|
||||
|
||||
console.log(`\n ${statusIcon} ${label}:`);
|
||||
for (const [name, ok] of status) {
|
||||
const icon = ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
|
||||
console.log(` ${icon} ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkBinaries(bins: string[]): Map<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const bin of bins) {
|
||||
try {
|
||||
const cmd = process.platform === "win32" ? `where ${bin}` : `which ${bin}`;
|
||||
require("child_process").execSync(cmd, { stdio: "ignore" });
|
||||
result.set(bin, true);
|
||||
} catch {
|
||||
result.set(bin, false);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkEnvVars(envs: string[], skillEnv: Record<string, string>): Map<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const env of envs) {
|
||||
const found = Object.prototype.hasOwnProperty.call(skillEnv, env) || env in process.env;
|
||||
result.set(env, found);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise<void> {
|
||||
const skill = manager.getSkillFromAll(skillId);
|
||||
if (!skill) {
|
||||
console.error(`Skill not found: ${skillId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const installOptions = getInstallOptions(skill);
|
||||
if (installOptions.length === 0) {
|
||||
console.error(`Skill '${skillId}' has no install specifications.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show available options if multiple
|
||||
if (!installId && installOptions.length > 1) {
|
||||
console.log(`\nMultiple install options available for '${skillId}':\n`);
|
||||
for (const opt of installOptions) {
|
||||
const status = opt.available ? "available" : `unavailable: ${opt.reason}`;
|
||||
console.log(` [${opt.id}] ${opt.label} (${status})`);
|
||||
}
|
||||
console.log(`\nUse: pnpm skills:cli install ${skillId} <install-id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nInstalling dependencies for '${skillId}'...`);
|
||||
|
||||
const result = await installSkill({
|
||||
skill,
|
||||
installId,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
|
||||
} else {
|
||||
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
|
||||
if (result.stderr) {
|
||||
console.error("\nError output:");
|
||||
console.error(result.stderr);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add/Remove Commands
|
||||
// ============================================================================
|
||||
|
||||
async function cmdAdd(source: string, force: boolean): Promise<void> {
|
||||
console.log(`\nAdding skill from '${source}'...`);
|
||||
|
||||
const result = await addSkill({
|
||||
source,
|
||||
force,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
|
||||
if (result.skills && result.skills.length > 1) {
|
||||
console.log("\nSkills found:");
|
||||
for (const name of result.skills) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
if (result.path) {
|
||||
console.log(`\nInstalled to: ${result.path}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdRemove(name: string): Promise<void> {
|
||||
console.log(`\nRemoving skill '${name}'...`);
|
||||
|
||||
const result = await removeSkill(name);
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
|
||||
} else {
|
||||
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdListInstalled(): Promise<void> {
|
||||
const skills = await listInstalledSkills();
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log("\nNo skills installed in ~/.super-multica/skills/");
|
||||
console.log("Use 'pnpm skills:cli add <source>' to add skills.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nInstalled skills (~/.super-multica/skills/):\n");
|
||||
for (const name of skills) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
console.log(`\nTotal: ${skills.length} installed`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main
|
||||
// ============================================================================
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { command, args, verbose, force } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (command === "help") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "add":
|
||||
if (!args[0]) {
|
||||
console.error("Usage: pnpm skills:cli add <source> [--force]");
|
||||
console.error("\nSource formats:");
|
||||
console.error(" owner/repo Clone entire repository");
|
||||
console.error(" owner/repo/skill-name Clone single skill directory");
|
||||
console.error(" owner/repo@branch Clone specific branch/tag");
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdAdd(args[0], force);
|
||||
return;
|
||||
|
||||
case "remove":
|
||||
if (!args[0]) {
|
||||
console.error("Usage: pnpm skills:cli remove <skill-name>");
|
||||
await cmdListInstalled();
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdRemove(args[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Commands that need SkillManager
|
||||
const manager = new SkillManager();
|
||||
|
||||
switch (command) {
|
||||
case "list":
|
||||
cmdList(manager, verbose);
|
||||
break;
|
||||
|
||||
case "status":
|
||||
cmdStatus(manager, args[0], verbose);
|
||||
break;
|
||||
|
||||
case "install":
|
||||
if (!args[0]) {
|
||||
console.error("Usage: pnpm skills:cli install <skill-id> [install-id]");
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdInstall(manager, args[0], args[1]);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI tool to inspect and test tool policy configuration.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tools:cli list # List all available tools
|
||||
* pnpm tools:cli list --allow group:fs # List tools after allowing fs group
|
||||
* pnpm tools:cli list --deny exec # List tools after denying exec
|
||||
* pnpm tools:cli groups # Show all tool groups
|
||||
*/
|
||||
|
||||
import { createAllTools } from "@multica/core";
|
||||
import { filterTools, type ToolsConfig } from "@multica/core";
|
||||
import { TOOL_GROUPS, expandToolGroups } from "@multica/core";
|
||||
|
||||
type Command = "list" | "groups" | "help";
|
||||
|
||||
interface CliOptions {
|
||||
command: Command;
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
provider?: string | undefined;
|
||||
isSubagent?: boolean;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log("Usage: pnpm tools:cli <command> [options]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" list List available tools (with optional filtering)");
|
||||
console.log(" groups Show all tool groups");
|
||||
console.log(" help Show this help");
|
||||
console.log("");
|
||||
console.log("Options for 'list':");
|
||||
console.log(" --allow TOOLS Allow specific tools (comma-separated)");
|
||||
console.log(" --deny TOOLS Deny specific tools (comma-separated)");
|
||||
console.log(" --provider NAME Apply provider-specific rules");
|
||||
console.log(" --subagent Apply subagent restrictions");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" pnpm tools:cli list");
|
||||
console.log(" pnpm tools:cli list --deny exec");
|
||||
console.log(" pnpm tools:cli list --allow group:fs,web_fetch");
|
||||
console.log(" pnpm tools:cli groups");
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const args = [...argv];
|
||||
const command = (args.shift() || "help") as Command;
|
||||
|
||||
const opts: CliOptions = { command };
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--allow") {
|
||||
const value = args.shift();
|
||||
opts.allow = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--deny") {
|
||||
const value = args.shift();
|
||||
opts.deny = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--subagent") {
|
||||
opts.isSubagent = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function listTools(opts: CliOptions) {
|
||||
const allTools = createAllTools(process.cwd());
|
||||
|
||||
console.log(`Total tools available: ${allTools.length}`);
|
||||
console.log("");
|
||||
|
||||
// Build config
|
||||
let config: ToolsConfig | undefined;
|
||||
if (opts.allow || opts.deny) {
|
||||
config = {};
|
||||
if (opts.allow) {
|
||||
config.allow = opts.allow;
|
||||
}
|
||||
if (opts.deny) {
|
||||
config.deny = opts.deny;
|
||||
}
|
||||
}
|
||||
|
||||
const filterOpts: import("@multica/core").FilterToolsOptions = {};
|
||||
if (config) {
|
||||
filterOpts.config = config;
|
||||
}
|
||||
if (opts.provider) {
|
||||
filterOpts.provider = opts.provider;
|
||||
}
|
||||
if (opts.isSubagent) {
|
||||
filterOpts.isSubagent = opts.isSubagent;
|
||||
}
|
||||
|
||||
const filtered = filterTools(allTools, filterOpts);
|
||||
|
||||
if (config || opts.provider || opts.isSubagent) {
|
||||
console.log("Applied filters:");
|
||||
if (opts.allow) console.log(` Allow: ${opts.allow.join(", ")}`);
|
||||
if (opts.deny) console.log(` Deny: ${opts.deny.join(", ")}`);
|
||||
if (opts.provider) console.log(` Provider: ${opts.provider}`);
|
||||
if (opts.isSubagent) console.log(` Subagent: true`);
|
||||
console.log("");
|
||||
console.log(`Tools after filtering: ${filtered.length}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Tools:");
|
||||
for (const tool of filtered) {
|
||||
const desc = tool.description?.slice(0, 60) || "";
|
||||
console.log(` ${tool.name.padEnd(15)} ${desc}${desc.length >= 60 ? "..." : ""}`);
|
||||
}
|
||||
|
||||
if (filtered.length < allTools.length) {
|
||||
const removed = allTools.filter((t) => !filtered.find((f) => f.name === t.name));
|
||||
console.log("");
|
||||
console.log(`Filtered out (${removed.length}):`);
|
||||
for (const tool of removed) {
|
||||
console.log(` ${tool.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showGroups() {
|
||||
console.log("Tool Groups:");
|
||||
console.log("");
|
||||
for (const [name, tools] of Object.entries(TOOL_GROUPS)) {
|
||||
console.log(` ${name}:`);
|
||||
console.log(` ${tools.join(", ")}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
switch (opts.command) {
|
||||
case "list":
|
||||
listTools(opts);
|
||||
break;
|
||||
case "groups":
|
||||
showGroups();
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printUsage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
external: [
|
||||
/^node:/,
|
||||
],
|
||||
})
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# Gateway WebSocket URL (dev)
|
||||
MAIN_VITE_GATEWAY_URL=https://multica-dev.copilothub.ai
|
||||
# Web URL for OAuth login (dev)
|
||||
MAIN_VITE_WEB_URL=http://localhost:3000
|
||||
# API URL for renderer (dev)
|
||||
RENDERER_VITE_MULTICA_API_URL=https://api-dev.copilothub.ai
|
||||
# API URL for core/tools and main process bridge (dev)
|
||||
MULTICA_API_URL=https://api-dev.copilothub.ai
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
# =============================================================================
|
||||
# Multica Desktop Environment Configuration
|
||||
# =============================================================================
|
||||
#
|
||||
# This file documents all available environment variables for Desktop builds.
|
||||
# Copy this file and rename it based on your target environment.
|
||||
#
|
||||
# =============================================================================
|
||||
# Build Commands
|
||||
# =============================================================================
|
||||
#
|
||||
# Development (no .env file needed):
|
||||
# pnpm dev # Uses hardcoded dev defaults
|
||||
#
|
||||
# Staging Build:
|
||||
# pnpm build:staging # Uses .env.staging
|
||||
#
|
||||
# Production Build:
|
||||
# pnpm build:production # Uses .env.production
|
||||
#
|
||||
# Default Build (uses .env.production):
|
||||
# pnpm build:desktop # Same as build:production
|
||||
#
|
||||
# =============================================================================
|
||||
# Build Output
|
||||
# =============================================================================
|
||||
#
|
||||
# After build completes, installer packages are located at:
|
||||
#
|
||||
# apps/desktop/release/{version}/
|
||||
# ├── Multica-{version}-arm64.dmg # macOS Apple Silicon
|
||||
# ├── Multica-{version}-x64.dmg # macOS Intel
|
||||
# ├── Multica-Windows-{version}-Setup.exe # Windows
|
||||
# ├── Multica-Linux-{version}.AppImage # Linux AppImage
|
||||
# └── Multica-Linux-{version}.deb # Linux deb
|
||||
#
|
||||
# Compiled code (before packaging) is in:
|
||||
# apps/desktop/out/
|
||||
#
|
||||
# =============================================================================
|
||||
# Configuration Guide
|
||||
# =============================================================================
|
||||
#
|
||||
# For production builds, create .env.production with the following variables:
|
||||
#
|
||||
# Required:
|
||||
# MAIN_VITE_GATEWAY_URL - WebSocket Gateway URL for remote connections
|
||||
# MAIN_VITE_WEB_URL - Web App URL for OAuth login flow
|
||||
# RENDERER_VITE_MULTICA_API_URL - API URL for renderer process (UI requests)
|
||||
# MULTICA_API_URL - API URL for core/tools and main process bridge
|
||||
#
|
||||
# Example .env.production:
|
||||
# MAIN_VITE_GATEWAY_URL=https://gateway.multica.ai
|
||||
# MAIN_VITE_WEB_URL=https://www.multica.ai
|
||||
# RENDERER_VITE_MULTICA_API_URL=https://api.multica.ai
|
||||
# MULTICA_API_URL=https://api.multica.ai
|
||||
#
|
||||
# =============================================================================
|
||||
# Variable Naming Convention
|
||||
# =============================================================================
|
||||
#
|
||||
# MAIN_VITE_* - Main process only (Node.js, full system access)
|
||||
# RENDERER_VITE_* - Renderer process only (browser context)
|
||||
# VITE_* - All processes
|
||||
# MULTICA_* - Core/CLI/Gateway (read via process.env at runtime)
|
||||
# Also available in main process via envPrefix config
|
||||
#
|
||||
# =============================================================================
|
||||
# Environment Variables
|
||||
# =============================================================================
|
||||
|
||||
# MAIN_VITE_GATEWAY_URL
|
||||
# WebSocket Gateway URL - Hub connects here for remote device access (QR code pairing)
|
||||
# Production example: https://gateway.multica.ai
|
||||
MAIN_VITE_GATEWAY_URL=http://localhost:3000
|
||||
|
||||
# MAIN_VITE_WEB_URL
|
||||
# Web App URL - Desktop opens this URL for user login (OAuth flow)
|
||||
# Production example: https://www.multica.ai
|
||||
MAIN_VITE_WEB_URL=http://localhost:3000
|
||||
|
||||
# RENDERER_VITE_MULTICA_API_URL
|
||||
# API URL for renderer process - Used by the React UI for login, user info, etc.
|
||||
# Production example: https://api.multica.ai
|
||||
RENDERER_VITE_MULTICA_API_URL=http://localhost:8080
|
||||
|
||||
# MULTICA_API_URL
|
||||
# API URL for core/tools and main process bridge - Used by agent engine tools
|
||||
# (web-search, finance) and bridged to process.env in packaged builds.
|
||||
# Read by core package via process.env.MULTICA_API_URL at runtime.
|
||||
# Production example: https://api.multica.ai
|
||||
MULTICA_API_URL=http://localhost:8080
|
||||
|
||||
# SMC_DATA_DIR
|
||||
# Root data directory override - Isolates dev data from production.
|
||||
# When set, all data (sessions, credentials, profiles, app-state, etc.)
|
||||
# is stored under this directory instead of ~/.super-multica.
|
||||
# Supports ~ expansion. Set automatically by scripts/dev-local.sh.
|
||||
# SMC_DATA_DIR=~/.super-multica-dev
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# Gateway WebSocket URL
|
||||
MAIN_VITE_GATEWAY_URL=https://gateway.multica.ai
|
||||
# Web URL for OAuth login
|
||||
MAIN_VITE_WEB_URL=https://www.multica.ai
|
||||
# API URL for renderer (UI requests)
|
||||
RENDERER_VITE_MULTICA_API_URL=https://api.multica.ai
|
||||
# API URL for core/tools and main process bridge
|
||||
MULTICA_API_URL=https://api.multica.ai
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
},
|
||||
}
|
||||
8
apps/desktop/.gitignore
vendored
8
apps/desktop/.gitignore
vendored
|
|
@ -1,8 +0,0 @@
|
|||
# Build outputs (keep build/ for icons and entitlements)
|
||||
!build/
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
out
|
||||
release
|
||||
*.local
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 233 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 160 B |
Binary file not shown.
|
Before Width: | Height: | Size: 252 B |
|
|
@ -1,3 +0,0 @@
|
|||
provider: github
|
||||
owner: multica-ai
|
||||
repo: super-multica
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// @see - https://www.electron.build/configuration/configuration
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.multica.app",
|
||||
"asar": true,
|
||||
"productName": "Multica",
|
||||
"directories": {
|
||||
"buildResources": "build",
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"files": [
|
||||
"out",
|
||||
"build/trayTemplate*.png",
|
||||
"!**/.vscode/*",
|
||||
"!src/*",
|
||||
"!electron.vite.config.{js,ts,mjs,cjs}",
|
||||
"!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml}",
|
||||
"!{.env,.env.*,.npmrc}",
|
||||
"!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
|
||||
],
|
||||
"mac": {
|
||||
"hardenedRuntime": true,
|
||||
"notarize": true,
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${arch}-mac.${ext}",
|
||||
"extendInfo": {
|
||||
"NSCameraUsageDescription": "Application requests access to the device's camera.",
|
||||
"NSMicrophoneUsageDescription": "Application requests access to the device's microphone.",
|
||||
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
|
||||
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
|
||||
},
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Multica",
|
||||
"schemes": ["multica"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"executableName": "multica",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"shortcutName": "${productName}",
|
||||
"uninstallDisplayName": "${productName}",
|
||||
"createDesktopShortcut": "always"
|
||||
},
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Multica",
|
||||
"schemes": ["multica"]
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"maintainer": "multica.ai",
|
||||
"category": "Utility",
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}"
|
||||
},
|
||||
"npmRebuild": false,
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "multica-ai",
|
||||
"repo": "super-multica"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import path from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
envPrefix: ['MAIN_VITE_', 'MULTICA_'],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
format: 'cjs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/renderer/src'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
{
|
||||
"name": "@multica/desktop",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"description": "Multica Desktop - AI Agent Hub",
|
||||
"author": "Multica AI <dev@multica.ai>",
|
||||
"homepage": "https://github.com/multica-ai/super-multica",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:onboarding": "electron-vite dev -- --force-onboarding",
|
||||
"build": "electron-vite build && electron-builder --publish never",
|
||||
"build:staging": "electron-vite build --mode staging && electron-builder --publish never",
|
||||
"build:production": "electron-vite build --mode production && electron-builder --publish never",
|
||||
"preview": "electron-vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/hooks": "workspace:*",
|
||||
"@multica/sdk": "workspace:*",
|
||||
"@multica/store": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/utils": "workspace:*",
|
||||
"electron-updater": "^6.7.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"socket.io-client": "catalog:",
|
||||
"uuid": "catalog:",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^33.4.11",
|
||||
"electron-builder": "^26.7.0",
|
||||
"electron-builder-squirrel-windows": "^26.7.0",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^5.1.6"
|
||||
},
|
||||
"main": "./out/main/index.js"
|
||||
}
|
||||
274
apps/desktop/src/main/electron-env.d.ts
vendored
274
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -1,274 +0,0 @@
|
|||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
// Environment variables loaded from .env files
|
||||
// See: .env.example, .env.staging, .env.production
|
||||
interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_GATEWAY_URL: string
|
||||
readonly MAIN_VITE_WEB_URL: string
|
||||
readonly MULTICA_API_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬─┬ dist
|
||||
* │ │ └── index.html
|
||||
* │ │
|
||||
* │ ├─┬ dist-electron
|
||||
* │ │ ├── main.js
|
||||
* │ │ └── preload.js
|
||||
* │
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ElectronAPI type definitions
|
||||
// ============================================================================
|
||||
|
||||
interface HubStatus {
|
||||
hubId: string
|
||||
status: string
|
||||
agentCount: number
|
||||
gatewayConnected: boolean
|
||||
gatewayUrl?: string
|
||||
defaultAgent?: {
|
||||
agentId: string
|
||||
status: string
|
||||
} | null
|
||||
}
|
||||
|
||||
interface AgentInfo {
|
||||
agentId: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ToolInfo {
|
||||
name: string
|
||||
group: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
source: 'bundled' | 'global' | 'profile'
|
||||
triggers: string[]
|
||||
}
|
||||
|
||||
interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
clientName?: string
|
||||
}
|
||||
|
||||
interface DeviceEntryInfo {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationIds: string[]
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
interface SkillAddResult {
|
||||
ok: boolean
|
||||
message: string
|
||||
path?: string
|
||||
skills?: string[]
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
profileId: string | undefined
|
||||
name: string | undefined
|
||||
userContent: string | undefined
|
||||
}
|
||||
|
||||
interface LocalChatEvent {
|
||||
agentId: string
|
||||
conversationId: string
|
||||
streamId?: string
|
||||
type?: 'error'
|
||||
content?: string
|
||||
event?: {
|
||||
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
|
||||
id?: string
|
||||
message?: {
|
||||
role: string
|
||||
content?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalChatApproval {
|
||||
approvalId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
command: string
|
||||
cwd?: string
|
||||
riskLevel: 'safe' | 'needs-review' | 'dangerous'
|
||||
riskReasons: string[]
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
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 ChannelAccountStateInfo {
|
||||
channelId: string
|
||||
accountId: string
|
||||
status: 'stopped' | 'starting' | 'running' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
type MessageSource =
|
||||
| { type: 'local' }
|
||||
| { type: 'gateway'; deviceId: string }
|
||||
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
|
||||
|
||||
interface InboundMessageEvent {
|
||||
agentId: string
|
||||
conversationId: string
|
||||
content: string
|
||||
source: MessageSource
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
app: {
|
||||
getFlags: () => Promise<{ forceOnboarding: boolean }>
|
||||
}
|
||||
appState: {
|
||||
getOnboardingCompleted: () => Promise<boolean>
|
||||
setOnboardingCompleted: (completed: boolean) => Promise<void>
|
||||
}
|
||||
hub: {
|
||||
init: () => Promise<unknown>
|
||||
getStatus: () => Promise<HubStatus>
|
||||
getAgentInfo: () => Promise<AgentInfo | null>
|
||||
info: () => Promise<unknown>
|
||||
reconnect: (url: string) => Promise<unknown>
|
||||
listConversations: () => Promise<unknown>
|
||||
createConversation: (id?: string) => Promise<unknown>
|
||||
getConversation: (id: string) => Promise<unknown>
|
||||
closeConversation: (id: string) => Promise<unknown>
|
||||
sendMessage: (agentId: string, content: string, conversationId: string) => Promise<unknown>
|
||||
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise<unknown>
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void
|
||||
offDeviceConfirmRequest: () => void
|
||||
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
|
||||
listDevices: () => Promise<DeviceEntryInfo[]>
|
||||
revokeDevice: (deviceId: string) => Promise<{ ok: boolean }>
|
||||
onConnectionStateChanged: (callback: (state: string) => void) => void
|
||||
offConnectionStateChanged: () => void
|
||||
onDevicesChanged: (callback: () => void) => void
|
||||
offDevicesChanged: () => void
|
||||
onInboundMessage: (callback: (event: InboundMessageEvent) => void) => () => void
|
||||
onConversationsChanged: (callback: () => void) => () => void
|
||||
offInboundMessage: () => void
|
||||
}
|
||||
tools: {
|
||||
list: () => Promise<ToolInfo[]>
|
||||
toggle: (name: string) => Promise<unknown>
|
||||
setStatus: (name: string, enabled: boolean) => Promise<unknown>
|
||||
active: () => Promise<unknown>
|
||||
reload: () => Promise<unknown>
|
||||
}
|
||||
skills: {
|
||||
list: () => Promise<SkillInfo[]>
|
||||
get: (id: string) => Promise<unknown>
|
||||
toggle: (id: string) => Promise<unknown>
|
||||
setStatus: (id: string, enabled: boolean) => Promise<unknown>
|
||||
reload: () => Promise<unknown>
|
||||
add: (source: string, options?: { name?: string; force?: boolean }) => Promise<SkillAddResult>
|
||||
remove: (name: string) => Promise<SkillAddResult>
|
||||
}
|
||||
agent: {
|
||||
status: () => Promise<unknown>
|
||||
}
|
||||
profile: {
|
||||
get: () => Promise<ProfileData>
|
||||
updateName: (name: 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 }>
|
||||
test: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }>
|
||||
}
|
||||
channels: {
|
||||
listStates: () => Promise<ChannelAccountStateInfo[]>
|
||||
getConfig: () => Promise<Record<string, Record<string, Record<string, unknown>> | undefined>>
|
||||
saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }>
|
||||
removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
stop: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
start: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
}
|
||||
cron: {
|
||||
list: () => Promise<unknown[]>
|
||||
toggle: (jobId: string) => Promise<{ ok: boolean }>
|
||||
remove: (jobId: string) => Promise<{ ok: boolean }>
|
||||
}
|
||||
heartbeat: {
|
||||
last: () => Promise<unknown>
|
||||
setEnabled: (enabled: boolean) => Promise<{ ok: boolean; enabled?: boolean; error?: string }>
|
||||
wake: (reason?: string) => Promise<{ ok: boolean; result?: unknown; error?: string }>
|
||||
}
|
||||
localChat: {
|
||||
subscribe: (conversationId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean; token?: number; isRunning?: boolean }>
|
||||
unsubscribe: (conversationId: string, token?: number) => Promise<{ ok: boolean; skipped?: boolean; alreadyUnsubscribed?: boolean }>
|
||||
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number; isRunning?: boolean }>
|
||||
send: (conversationId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
|
||||
abort: (conversationId: string) => Promise<{ ok?: boolean; error?: string }>
|
||||
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
|
||||
onEvent: (callback: (event: LocalChatEvent) => void) => () => void
|
||||
offEvent: () => void
|
||||
onApproval: (callback: (approval: LocalChatApproval) => void) => () => void
|
||||
offApproval: () => void
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
ipcRenderer: import('electron').IpcRenderer
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
// Patch console methods to handle EPIPE errors in Electron main process
|
||||
// This MUST be done before any other imports that might use console
|
||||
// EPIPE happens when stdout/stderr pipes are closed unexpectedly
|
||||
const originalConsoleLog = console.log.bind(console)
|
||||
const originalConsoleError = console.error.bind(console)
|
||||
const originalConsoleWarn = console.warn.bind(console)
|
||||
|
||||
const safeLog = (...args: unknown[]) => {
|
||||
try {
|
||||
originalConsoleLog(...args)
|
||||
} catch {
|
||||
// Ignore EPIPE errors silently
|
||||
}
|
||||
}
|
||||
|
||||
const safeError = (...args: unknown[]) => {
|
||||
try {
|
||||
originalConsoleError(...args)
|
||||
} catch {
|
||||
// Ignore EPIPE errors silently
|
||||
}
|
||||
}
|
||||
|
||||
const safeWarn = (...args: unknown[]) => {
|
||||
try {
|
||||
originalConsoleWarn(...args)
|
||||
} catch {
|
||||
// Ignore EPIPE errors silently
|
||||
}
|
||||
}
|
||||
|
||||
// Override global console
|
||||
console.log = safeLog
|
||||
console.error = safeError
|
||||
console.warn = safeWarn
|
||||
|
||||
// Also handle process stdout/stderr EPIPE errors
|
||||
process.stdout?.on?.('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') return // Ignore
|
||||
throw err
|
||||
})
|
||||
process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') return // Ignore
|
||||
throw err
|
||||
})
|
||||
|
||||
// Bridge Vite build-time env to process.env for externalized @multica/core
|
||||
// In dev mode, electron-vite already loads .env into process.env;
|
||||
// In packaged builds, only import.meta.env has the value (injected at build time).
|
||||
if (import.meta.env.MULTICA_API_URL) {
|
||||
process.env.MULTICA_API_URL ??= import.meta.env.MULTICA_API_URL
|
||||
}
|
||||
|
||||
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation, setAuthMainWindow, handleAuthDeepLink } from './ipc/index.js'
|
||||
import { appStateManager } from '@multica/core'
|
||||
import { createUpdater, AutoUpdater } from './updater/index.js'
|
||||
import { createTray, destroyTray } from './tray.js'
|
||||
|
||||
// CJS output will have __dirname natively, but TypeScript source needs this for type checking
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// APP_ROOT points to apps/desktop (two levels up from out/main/)
|
||||
process.env.APP_ROOT = path.join(__dirname, '../..')
|
||||
|
||||
// electron-vite uses ELECTRON_RENDERER_URL for dev server
|
||||
export const VITE_DEV_SERVER_URL = process.env['ELECTRON_RENDERER_URL']
|
||||
// electron-vite outputs to out/ directory
|
||||
export const MAIN_DIST = path.join(__dirname)
|
||||
export const RENDERER_DIST = path.join(__dirname, '../renderer')
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
|
||||
|
||||
// CLI flags
|
||||
const forceOnboarding = process.argv.includes('--force-onboarding')
|
||||
|
||||
let win: BrowserWindow | null
|
||||
let updater: AutoUpdater
|
||||
let isQuitting = false
|
||||
|
||||
// ============================================================================
|
||||
// Custom Protocol for Auth (multica://)
|
||||
// ============================================================================
|
||||
|
||||
// Register custom protocol - must be called before app.whenReady()
|
||||
if (process.defaultApp) {
|
||||
// Development: need to pass the script path
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient('multica', process.execPath, [path.resolve(process.argv[1])])
|
||||
}
|
||||
} else {
|
||||
// Production
|
||||
app.setAsDefaultProtocolClient('multica')
|
||||
}
|
||||
|
||||
// Handle protocol URL on macOS (when app is already running)
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
console.log('[Auth] Received open-url:', url)
|
||||
if (url.startsWith('multica://')) {
|
||||
handleAuthDeepLink(url)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle second instance (Windows/Linux - when app is already running)
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', (_event, commandLine) => {
|
||||
// Show and focus window
|
||||
if (win) {
|
||||
if (!win.isVisible()) win.show()
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.focus()
|
||||
}
|
||||
// Handle protocol URL from command line (Windows)
|
||||
const url = commandLine.find(arg => arg.startsWith('multica://'))
|
||||
if (url) {
|
||||
console.log('[Auth] Received second-instance URL:', url)
|
||||
handleAuthDeepLink(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 500,
|
||||
minHeight: 520,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 16, y: 17 }, // Vertically centered in 48px header
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.cjs'),
|
||||
// Enable node integration for IPC
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Open external links in system browser instead of inside Electron
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// Track renderer crashes for debugging
|
||||
win.webContents.on('render-process-gone', (_event, details) => {
|
||||
console.error('[Window] Renderer process gone:', details.reason, details.exitCode)
|
||||
})
|
||||
|
||||
// Hide window on close instead of quitting (tray keeps running)
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault()
|
||||
// On macOS, hiding a fullscreen window causes a black screen.
|
||||
// Exit fullscreen first, then hide.
|
||||
if (win?.isFullScreen()) {
|
||||
win.once('leave-full-screen', () => {
|
||||
win?.hide()
|
||||
})
|
||||
win.setFullScreen(false)
|
||||
} else {
|
||||
win?.hide()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL)
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep app running with tray on all platforms
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
} else if (win && !win.isVisible()) {
|
||||
win.show()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true
|
||||
destroyTray()
|
||||
cleanupAll()
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Reset onboarding if --force-onboarding flag is passed (for development testing)
|
||||
if (forceOnboarding) {
|
||||
console.log('[dev] Resetting onboarding state...')
|
||||
appStateManager.resetOnboarding()
|
||||
console.log('[dev] Onboarding state reset')
|
||||
}
|
||||
|
||||
// App-level IPC handlers
|
||||
ipcMain.handle('app:getFlags', () => ({ forceOnboarding }))
|
||||
|
||||
// Register all IPC handlers before creating window
|
||||
registerAllIpcHandlers()
|
||||
|
||||
// Initialize Hub and create default agent
|
||||
await initializeApp()
|
||||
|
||||
createWindow()
|
||||
|
||||
// Initialize auto-updater
|
||||
const forceDevUpdate = process.env.FORCE_DEV_UPDATE === 'true'
|
||||
updater = createUpdater(forceDevUpdate)
|
||||
updater.setMainWindow(() => win)
|
||||
|
||||
// Set up device confirmation flow, auth, and tray (requires window)
|
||||
if (win) {
|
||||
setupDeviceConfirmation(win)
|
||||
setAuthMainWindow(win)
|
||||
createTray(win, {
|
||||
onCheckForUpdates: () => updater.checkForUpdates(),
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-check for updates in production (or when forced in dev)
|
||||
const isDev = !!VITE_DEV_SERVER_URL
|
||||
if (!isDev || forceDevUpdate) {
|
||||
win?.once('ready-to-show', () => {
|
||||
updater.checkForUpdates()
|
||||
})
|
||||
}
|
||||
|
||||
// Update IPC handlers
|
||||
ipcMain.handle('update:check', async () => {
|
||||
await updater.checkForUpdates()
|
||||
})
|
||||
|
||||
ipcMain.handle('update:download', async () => {
|
||||
await updater.downloadUpdate()
|
||||
})
|
||||
|
||||
ipcMain.handle('update:install', () => {
|
||||
updater.quitAndInstall()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
/**
|
||||
* Agent IPC handlers for Electron main process.
|
||||
*
|
||||
* These handlers get tool information from the real Agent instance
|
||||
* managed by the Hub.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCurrentHub } from './hub.js'
|
||||
|
||||
// Tool groups (for UI display grouping)
|
||||
const TOOL_GROUPS: Record<string, string[]> = {
|
||||
'group:fs': ['read', 'write', 'edit', 'glob'],
|
||||
'group:runtime': ['exec', 'process'],
|
||||
'group:web': ['web_search', 'web_fetch'],
|
||||
'group:subagent': ['delegate'],
|
||||
'group:cron': ['cron'],
|
||||
}
|
||||
|
||||
// All known tool names (for display when agent not available)
|
||||
const ALL_KNOWN_TOOLS = [
|
||||
...TOOL_GROUPS['group:fs'],
|
||||
...TOOL_GROUPS['group:runtime'],
|
||||
...TOOL_GROUPS['group:web'],
|
||||
...TOOL_GROUPS['group:subagent'],
|
||||
...TOOL_GROUPS['group:cron'],
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the group for a tool name.
|
||||
*/
|
||||
function getToolGroup(name: string): string {
|
||||
for (const [groupKey, tools] of Object.entries(TOOL_GROUPS)) {
|
||||
const groupId = groupKey.replace('group:', '')
|
||||
if (tools.includes(name)) {
|
||||
return groupId
|
||||
}
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default agent from Hub.
|
||||
*/
|
||||
function getDefaultAgent() {
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return null
|
||||
|
||||
const conversationIds = hub.listConversations()
|
||||
if (conversationIds.length === 0) return null
|
||||
|
||||
return hub.getConversation(conversationIds[0]) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Agent-related IPC handlers.
|
||||
*/
|
||||
export function registerAgentIpcHandlers(): void {
|
||||
// ============================================================================
|
||||
// Agent lifecycle
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get agent status
|
||||
*/
|
||||
ipcMain.handle('agent:status', async () => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return {
|
||||
running: false,
|
||||
error: 'No agent available',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
running: !agent.closed,
|
||||
agentId: agent.sessionId,
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tools management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get list of all tools with their enabled status.
|
||||
* Returns active tools from the real Agent instance.
|
||||
*/
|
||||
ipcMain.handle('tools:list', async () => {
|
||||
const agent = getDefaultAgent()
|
||||
|
||||
if (!agent) {
|
||||
// Fallback: return all known tools as disabled when no agent
|
||||
return ALL_KNOWN_TOOLS.map((name) => ({
|
||||
name,
|
||||
enabled: false,
|
||||
group: getToolGroup(name),
|
||||
}))
|
||||
}
|
||||
|
||||
// Get active tools from agent
|
||||
const activeTools = agent.getActiveTools()
|
||||
const activeSet = new Set(activeTools)
|
||||
|
||||
// Build list with all known tools, marking which are active
|
||||
const toolList = ALL_KNOWN_TOOLS.map((name) => ({
|
||||
name,
|
||||
enabled: activeSet.has(name),
|
||||
group: getToolGroup(name),
|
||||
}))
|
||||
|
||||
// Add any active tools not in our known list
|
||||
for (const name of activeTools) {
|
||||
if (!ALL_KNOWN_TOOLS.includes(name)) {
|
||||
toolList.push({
|
||||
name,
|
||||
enabled: true,
|
||||
group: getToolGroup(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return toolList
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggle a tool's enabled status.
|
||||
* Persists the change to profile config and reloads tools.
|
||||
*/
|
||||
ipcMain.handle('tools:toggle', async (_event, toolName: string) => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
// Check current status
|
||||
const activeTools = agent.getActiveTools()
|
||||
const isCurrentlyEnabled = activeTools.includes(toolName)
|
||||
|
||||
// Toggle the tool status (enable if disabled, disable if enabled)
|
||||
const result = agent.setToolStatus(toolName, !isCurrentlyEnabled)
|
||||
if (!result) {
|
||||
return { error: 'No profile loaded - cannot persist tool status' }
|
||||
}
|
||||
|
||||
// Get updated status
|
||||
const newActiveTools = agent.getActiveTools()
|
||||
const isNowEnabled = newActiveTools.includes(toolName)
|
||||
|
||||
return {
|
||||
name: toolName,
|
||||
enabled: isNowEnabled,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Set a tool's enabled status explicitly.
|
||||
* Persists the change to profile config and reloads tools.
|
||||
*/
|
||||
ipcMain.handle('tools:setStatus', async (_event, toolName: string, enabled: boolean) => {
|
||||
console.log(`[IPC] tools:setStatus called for: ${toolName}, enabled: ${enabled}`)
|
||||
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
// Set the tool status and persist to profile config
|
||||
const result = agent.setToolStatus(toolName, enabled)
|
||||
if (!result) {
|
||||
return { error: 'No profile loaded - cannot persist tool status' }
|
||||
}
|
||||
|
||||
console.log(`[IPC] Tool ${toolName} status set to ${enabled}. Config: allow=${result.allow?.join(',') ?? 'none'}, deny=${result.deny?.join(',') ?? 'none'}`)
|
||||
|
||||
// Get updated status
|
||||
const activeTools = agent.getActiveTools()
|
||||
const isEnabled = activeTools.includes(toolName)
|
||||
|
||||
return {
|
||||
name: toolName,
|
||||
enabled: isEnabled,
|
||||
config: result,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get currently active tools in the agent.
|
||||
*/
|
||||
ipcMain.handle('tools:active', async () => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return []
|
||||
}
|
||||
return agent.getActiveTools()
|
||||
})
|
||||
|
||||
/**
|
||||
* Force reload tools in the agent.
|
||||
* This picks up any changes made to credentials.json5.
|
||||
*/
|
||||
ipcMain.handle('tools:reload', async () => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
const reloadedTools = agent.reloadTools()
|
||||
console.log(`[IPC] Reloaded ${reloadedTools.length} tools: ${reloadedTools.join(', ')}`)
|
||||
|
||||
return reloadedTools
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup agent resources.
|
||||
*/
|
||||
export function cleanupAgent(): void {
|
||||
// Agent cleanup is handled by Hub
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* App State IPC handlers for Electron main process.
|
||||
*
|
||||
* Manages application-level state like onboarding status.
|
||||
* State is persisted to ~/.super-multica/app-state.json
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { appStateManager } from '@multica/core'
|
||||
|
||||
/**
|
||||
* Register all App State IPC handlers.
|
||||
*/
|
||||
export function registerAppStateIpcHandlers(): void {
|
||||
/**
|
||||
* Get onboarding completed status.
|
||||
*/
|
||||
ipcMain.handle('appState:getOnboardingCompleted', async (): Promise<boolean> => {
|
||||
return appStateManager.getOnboardingCompleted()
|
||||
})
|
||||
|
||||
/**
|
||||
* Set onboarding completed status.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'appState:setOnboardingCompleted',
|
||||
async (_event, completed: boolean): Promise<void> => {
|
||||
appStateManager.setOnboardingCompleted(completed)
|
||||
console.log(`[IPC] Onboarding completed set to: ${completed}`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,482 +0,0 @@
|
|||
/**
|
||||
* Auth IPC Handlers
|
||||
*
|
||||
* Desktop login flow, based on CAP project:
|
||||
* - Dev mode: Start local HTTP Server, Web redirects back after login
|
||||
* - Prod mode: Use Deep Link (multica://), Web redirects via deep link
|
||||
*
|
||||
* Reference: https://github.com/CapSoftware/Cap
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import { ipcMain, shell, BrowserWindow } from "electron";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { DATA_DIR, generateEncryptedId, isValidEncryptedId } from "@multica/utils";
|
||||
import type { AuthUser } from "@multica/types";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type { AuthUser };
|
||||
|
||||
export interface AuthData {
|
||||
sid: string;
|
||||
user: AuthUser;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
// Internal type for the full file structure (deviceId is always present)
|
||||
interface AuthFileData {
|
||||
sid?: string;
|
||||
user?: AuthUser;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device ID - 设备唯一标识
|
||||
// ============================================================================
|
||||
|
||||
const AUTH_FILE_PATH = join(DATA_DIR, "auth.json");
|
||||
|
||||
/**
|
||||
* Read raw auth file data, handling all edge cases.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
function readAuthFile(): Partial<AuthFileData> | null {
|
||||
try {
|
||||
if (!existsSync(AUTH_FILE_PATH)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = readFileSync(AUTH_FILE_PATH, "utf8").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
if (typeof data !== "object" || data === null) {
|
||||
console.warn("[Auth] Invalid auth file format, ignoring");
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Partial<AuthFileData>;
|
||||
} catch (error) {
|
||||
// JSON parse error or file read error
|
||||
console.error("[Auth] Failed to read auth file:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write auth file data to disk.
|
||||
*/
|
||||
function writeAuthFile(data: Partial<AuthFileData>): boolean {
|
||||
try {
|
||||
mkdirSync(dirname(AUTH_FILE_PATH), { recursive: true });
|
||||
writeFileSync(AUTH_FILE_PATH, JSON.stringify(data, null, 2), "utf8");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to write auth file:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a persistent Device ID.
|
||||
* Device ID persists across logins/logouts - it represents the device, not the user.
|
||||
* The stored value is encrypted (40 hex chars).
|
||||
*/
|
||||
export function getOrCreateDeviceId(): string {
|
||||
const existing = readAuthFile();
|
||||
|
||||
// If we have a valid encrypted deviceId, return it
|
||||
if (existing?.deviceId && isValidEncryptedId(existing.deviceId)) {
|
||||
return existing.deviceId;
|
||||
}
|
||||
|
||||
// Generate new encrypted deviceId
|
||||
const newDeviceId = generateEncryptedId();
|
||||
console.log("[Auth] Generated new Device ID:", newDeviceId.slice(0, 8) + "...");
|
||||
|
||||
// Preserve any existing auth data while adding deviceId
|
||||
const dataToSave: Partial<AuthFileData> = existing
|
||||
? { ...existing, deviceId: newDeviceId }
|
||||
: { deviceId: newDeviceId };
|
||||
|
||||
if (!writeAuthFile(dataToSave)) {
|
||||
console.error("[Auth] Failed to persist new Device ID");
|
||||
}
|
||||
|
||||
return newDeviceId;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage - 认证数据持久化
|
||||
// ============================================================================
|
||||
|
||||
function loadAuthData(): AuthData | null {
|
||||
try {
|
||||
const data = readAuthFile();
|
||||
|
||||
if (!data?.sid || !data?.user?.uid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sid: data.sid,
|
||||
user: data.user,
|
||||
deviceId: data.deviceId,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to load auth data:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save auth data to disk.
|
||||
* @param sid Session ID
|
||||
* @param user User info
|
||||
* @param passedDeviceId Optional Device ID from Web browser (encrypted 40-char format).
|
||||
* If provided and valid, use it; otherwise fall back to local Device ID.
|
||||
*/
|
||||
function saveAuthData(sid: string, user: AuthUser, passedDeviceId?: string): boolean {
|
||||
try {
|
||||
// Use passed deviceId from Web if valid, otherwise use local one
|
||||
let deviceId: string;
|
||||
if (passedDeviceId && isValidEncryptedId(passedDeviceId)) {
|
||||
deviceId = passedDeviceId;
|
||||
console.log("[Auth] Using Device ID from Web browser:", deviceId.slice(0, 8) + "...");
|
||||
} else {
|
||||
deviceId = getOrCreateDeviceId();
|
||||
if (passedDeviceId) {
|
||||
console.warn("[Auth] Invalid Device ID from Web, using local:", passedDeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
const data: AuthFileData = { sid, user, deviceId };
|
||||
|
||||
if (!writeAuthFile(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("[Auth] Auth data saved successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to save auth data:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear auth data (logout) while preserving Device ID.
|
||||
* Device ID persists across logins - it represents the device, not the user.
|
||||
*/
|
||||
function clearAuthData(): boolean {
|
||||
try {
|
||||
// Read existing data to preserve deviceId
|
||||
const existing = readAuthFile();
|
||||
const deviceId = existing?.deviceId || getOrCreateDeviceId();
|
||||
|
||||
// Write back only the deviceId
|
||||
const preserved: Partial<AuthFileData> = { deviceId };
|
||||
|
||||
if (!writeAuthFile(preserved)) {
|
||||
console.error("[Auth] Failed to preserve Device ID during logout");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("[Auth] Auth data cleared (Device ID preserved)");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to clear auth data:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Login - 登录流程
|
||||
// ============================================================================
|
||||
|
||||
let authServer: http.Server | null = null;
|
||||
let mainWindowRef: BrowserWindow | null = null;
|
||||
|
||||
/**
|
||||
* 设置主窗口引用(用于发送 auth callback 和聚焦窗口)
|
||||
*/
|
||||
export function setMainWindow(win: BrowserWindow): void {
|
||||
mainWindowRef = win;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功后的回调 HTML 页面
|
||||
* 参考:Cap/apps/desktop/src/components/callback.template.ts
|
||||
*/
|
||||
const callbackHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multica Auth</title>
|
||||
<style>
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
text-align: center;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.container {
|
||||
padding: 30px;
|
||||
max-width: 400px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #12161F;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Sign in successful</h1>
|
||||
<p>Please return to Multica app</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
/**
|
||||
* 开发模式:创建本地 HTTP Server 接收登录回调
|
||||
* 参考:Cap/apps/desktop/src/utils/auth.ts - createLocalServerSession
|
||||
*/
|
||||
async function createLocalServerSession(): Promise<number> {
|
||||
// 如果已有 server,先关闭
|
||||
if (authServer) {
|
||||
authServer.close();
|
||||
authServer = null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log("[Auth] Local server received request:", req.url);
|
||||
|
||||
try {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
|
||||
// 处理回调请求(只接受 /callback 路径)
|
||||
if (url.pathname === "/callback") {
|
||||
const sid = url.searchParams.get("sid");
|
||||
const userJson = url.searchParams.get("user");
|
||||
const deviceId = url.searchParams.get("deviceId");
|
||||
|
||||
// 返回成功页面
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
});
|
||||
res.end(callbackHtml);
|
||||
|
||||
console.log("[Auth] Parsed params:", { sid, userJson, deviceId: deviceId?.slice(0, 8) + "..." });
|
||||
|
||||
if (sid && userJson) {
|
||||
try {
|
||||
// URLSearchParams already decodes, so just parse JSON directly
|
||||
const user = JSON.parse(userJson) as AuthUser;
|
||||
console.log("[Auth] Received auth callback:", {
|
||||
sid: sid.substring(0, 8) + "...",
|
||||
user: user.name,
|
||||
deviceId: deviceId ? deviceId.slice(0, 8) + "..." : "not provided",
|
||||
});
|
||||
|
||||
// 保存认证数据(使用 Web 传递的 deviceId)
|
||||
saveAuthData(sid, user, deviceId || undefined);
|
||||
|
||||
// 通知渲染进程
|
||||
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
|
||||
mainWindowRef.webContents.send("auth:callback", { sid, user, deviceId });
|
||||
// 聚焦窗口
|
||||
if (mainWindowRef.isMinimized()) mainWindowRef.restore();
|
||||
mainWindowRef.focus();
|
||||
} else {
|
||||
console.log("[Auth] ERROR: mainWindowRef is null or destroyed!");
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("[Auth] Failed to parse user data:", parseError);
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭 server
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
authServer = null;
|
||||
}, 1000);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end("Not Found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error handling request:", error);
|
||||
res.writeHead(500);
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
console.error("[Auth] Server error:", err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 监听随机端口
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
const port = address.port;
|
||||
console.log("[Auth] Local server started on port:", port);
|
||||
authServer = server;
|
||||
resolve(port);
|
||||
} else {
|
||||
reject(new Error("Failed to get server address"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始登录流程
|
||||
* 参考:Cap/apps/desktop/src/utils/auth.ts - createSignInMutation
|
||||
*/
|
||||
async function startLogin(): Promise<void> {
|
||||
const isDev = !!process.env.ELECTRON_RENDERER_URL;
|
||||
const webUrl =
|
||||
(import.meta as unknown as { env: Record<string, string> }).env
|
||||
.MAIN_VITE_WEB_URL || "http://localhost:3000";
|
||||
|
||||
console.log("[Auth] Starting login, isDev:", isDev, "webUrl:", webUrl);
|
||||
|
||||
if (isDev) {
|
||||
// 开发模式:启动本地 Server,Web 回调到这个 Server
|
||||
try {
|
||||
const port = await createLocalServerSession();
|
||||
const loginUrl = `${webUrl}/api/desktop/session?port=${port}&platform=web`;
|
||||
console.log("[Auth] Opening login URL:", loginUrl);
|
||||
shell.openExternal(loginUrl);
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to start local server:", error);
|
||||
}
|
||||
} else {
|
||||
// 生产模式:直接打开登录页,通过 deep link 回调
|
||||
const loginUrl = `${webUrl}/api/desktop/session?platform=desktop`;
|
||||
console.log("[Auth] Opening login URL:", loginUrl);
|
||||
shell.openExternal(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Deep Link 回调(生产模式)
|
||||
* 在 main/index.ts 的 app.on('open-url') 中调用
|
||||
*/
|
||||
export function handleAuthDeepLink(url: string): void {
|
||||
console.log("[Auth] Handling deep link:", url);
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// multica://focus - just focus the window
|
||||
if (parsedUrl.host === "focus" || parsedUrl.pathname === "//focus") {
|
||||
console.log("[Auth] Focus request received");
|
||||
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
|
||||
if (mainWindowRef.isMinimized()) mainWindowRef.restore();
|
||||
mainWindowRef.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// multica://auth?sid=xxx&user=xxx&deviceId=xxx
|
||||
if (
|
||||
parsedUrl.host === "auth" ||
|
||||
parsedUrl.pathname === "//auth" ||
|
||||
parsedUrl.pathname === "/auth"
|
||||
) {
|
||||
const sid = parsedUrl.searchParams.get("sid");
|
||||
const userJson = parsedUrl.searchParams.get("user");
|
||||
const deviceId = parsedUrl.searchParams.get("deviceId");
|
||||
|
||||
if (sid && userJson) {
|
||||
const user = JSON.parse(decodeURIComponent(userJson)) as AuthUser;
|
||||
console.log("[Auth] Deep link auth received:", {
|
||||
sid: sid.substring(0, 8) + "...",
|
||||
user: user.name,
|
||||
deviceId: deviceId ? deviceId.slice(0, 8) + "..." : "not provided",
|
||||
});
|
||||
|
||||
// 保存认证数据(使用 Web 传递的 deviceId)
|
||||
saveAuthData(sid, user, deviceId || undefined);
|
||||
|
||||
// 通知渲染进程
|
||||
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
|
||||
mainWindowRef.webContents.send("auth:callback", { sid, user, deviceId });
|
||||
if (mainWindowRef.isMinimized()) mainWindowRef.restore();
|
||||
mainWindowRef.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to handle deep link:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IPC Handlers
|
||||
// ============================================================================
|
||||
|
||||
export function registerAuthHandlers(): void {
|
||||
// 加载认证数据
|
||||
ipcMain.handle("auth:load", () => {
|
||||
return loadAuthData();
|
||||
});
|
||||
|
||||
// 保存认证数据(支持传入 deviceId)
|
||||
ipcMain.handle("auth:save", (_, sid: string, user: AuthUser, deviceId?: string) => {
|
||||
return saveAuthData(sid, user, deviceId);
|
||||
});
|
||||
|
||||
// 清除认证数据(登出)
|
||||
ipcMain.handle("auth:clear", () => {
|
||||
return clearAuthData();
|
||||
});
|
||||
|
||||
// 开始登录
|
||||
ipcMain.handle("auth:startLogin", () => {
|
||||
return startLogin();
|
||||
});
|
||||
|
||||
// 获取 Device ID(已加密的 40 字符格式)
|
||||
ipcMain.handle("auth:getDeviceId", () => {
|
||||
return getOrCreateDeviceId();
|
||||
});
|
||||
|
||||
// 获取 Device-Id header 值(与 getDeviceId 相同,已加密)
|
||||
ipcMain.handle("auth:getDeviceIdHeader", () => {
|
||||
return getOrCreateDeviceId();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
/**
|
||||
* Channel IPC handlers for Electron main process.
|
||||
*
|
||||
* Manages channel account configuration, start/stop lifecycle.
|
||||
* The Channels page in the renderer uses these to configure
|
||||
* Telegram (and future channels) with immediate effect.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCurrentHub } from './hub.js'
|
||||
import { credentialManager, listChannels } from '@multica/core'
|
||||
|
||||
/** Validate that a string is a safe identifier (alphanumeric, dashes, underscores) */
|
||||
function isValidId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 64
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a token string for safe display: show first 5 and last 5 chars.
|
||||
* Returns undefined if the input is not a string.
|
||||
*/
|
||||
function maskToken(token: unknown): string | undefined {
|
||||
if (typeof token !== 'string' || token.length === 0) return undefined
|
||||
if (token.length <= 12) return '*'.repeat(token.length)
|
||||
return `${token.slice(0, 5)}${'*'.repeat(10)}${token.slice(-5)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Channel-related IPC handlers.
|
||||
*/
|
||||
export function registerChannelsIpcHandlers(): void {
|
||||
/**
|
||||
* List all channel account states (running / stopped / error).
|
||||
* Waits for any "starting" status to settle before returning.
|
||||
*/
|
||||
ipcMain.handle('channels:listStates', async () => {
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return []
|
||||
|
||||
let states = hub.channelManager.listAccountStates()
|
||||
|
||||
// Wait for "starting" status to settle (max 3.5s, since internal timeout is 3s)
|
||||
const start = Date.now()
|
||||
while (states.some(s => s.status === 'starting') && Date.now() - start < 3500) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
states = hub.channelManager.listAccountStates()
|
||||
}
|
||||
|
||||
return states
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the channels config from credentials.json5.
|
||||
* Returns a sanitized version with tokens masked (not the raw secret values).
|
||||
*/
|
||||
ipcMain.handle('channels:getConfig', async () => {
|
||||
const raw = credentialManager.getChannelsConfig()
|
||||
// Mask secret values before sending to renderer
|
||||
const masked: Record<string, Record<string, Record<string, unknown>> | undefined> = {}
|
||||
for (const [channelId, accounts] of Object.entries(raw)) {
|
||||
if (!accounts) continue
|
||||
const maskedAccounts: Record<string, Record<string, unknown>> = {}
|
||||
for (const [accountId, accountConfig] of Object.entries(accounts)) {
|
||||
const maskedConfig = { ...accountConfig }
|
||||
if ('botToken' in maskedConfig) {
|
||||
maskedConfig.botToken = maskToken(maskedConfig.botToken)
|
||||
}
|
||||
maskedAccounts[accountId] = maskedConfig
|
||||
}
|
||||
masked[channelId] = maskedAccounts
|
||||
}
|
||||
return masked
|
||||
})
|
||||
|
||||
/**
|
||||
* Save a channel account token and start the bot immediately.
|
||||
* Flow: validate → write to credentials.json5 → start the channel account.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'channels:saveToken',
|
||||
async (_event, channelId: string, accountId: string, token: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
|
||||
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
|
||||
if (typeof token !== 'string' || token.trim().length === 0 || token.length > 256) {
|
||||
return { ok: false, error: 'Invalid token' }
|
||||
}
|
||||
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return { ok: false, error: 'Hub not initialized' }
|
||||
|
||||
// Find the plugin to validate channelId
|
||||
const plugin = listChannels().find((p) => p.id === channelId)
|
||||
if (!plugin) return { ok: false, error: `Unknown channel: ${channelId}` }
|
||||
|
||||
// Persist config to credentials.json5
|
||||
credentialManager.setChannelAccountConfig(channelId, accountId, { botToken: token })
|
||||
console.log(`[IPC] Channel config saved: ${channelId}:${accountId}`)
|
||||
|
||||
// Stop existing account if running (e.g. token update)
|
||||
hub.channelManager.stopAccount(channelId, accountId)
|
||||
|
||||
// Start the account with the new config
|
||||
await hub.channelManager.startAccount(channelId, accountId, { botToken: token })
|
||||
console.log(`[IPC] Channel started: ${channelId}:${accountId}`)
|
||||
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[IPC] Failed to save channel token: ${message}`)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Remove a channel account token and stop the bot.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'channels:removeToken',
|
||||
async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
try {
|
||||
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
|
||||
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
|
||||
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return { ok: false, error: 'Hub not initialized' }
|
||||
|
||||
// Stop the account
|
||||
hub.channelManager.stopAccount(channelId, accountId)
|
||||
|
||||
// Remove from credentials.json5
|
||||
credentialManager.removeChannelAccountConfig(channelId, accountId)
|
||||
console.log(`[IPC] Channel config removed: ${channelId}:${accountId}`)
|
||||
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[IPC] Failed to remove channel token: ${message}`)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop a channel account without removing its config.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'channels:stop',
|
||||
async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
|
||||
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return { ok: false, error: 'Hub not initialized' }
|
||||
hub.channelManager.stopAccount(channelId, accountId)
|
||||
return { ok: true }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Start a channel account using its saved config.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'channels:start',
|
||||
async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
try {
|
||||
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
|
||||
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
|
||||
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return { ok: false, error: 'Hub not initialized' }
|
||||
|
||||
// Read config from credentials
|
||||
const config = credentialManager.getChannelsConfig()
|
||||
const accountConfig = config[channelId]?.[accountId]
|
||||
if (!accountConfig) {
|
||||
return { ok: false, error: `No config found for ${channelId}:${accountId}` }
|
||||
}
|
||||
|
||||
await hub.channelManager.startAccount(channelId, accountId, accountConfig)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/**
|
||||
* Cron IPC handlers for Electron main process.
|
||||
*
|
||||
* These handlers expose CronService operations to the renderer process
|
||||
* for the Cron Jobs management page.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCronService, formatSchedule } from '@multica/core'
|
||||
|
||||
/**
|
||||
* Register all Cron-related IPC handlers.
|
||||
*/
|
||||
export function registerCronIpcHandlers(): void {
|
||||
/**
|
||||
* List all cron jobs with formatted display fields.
|
||||
*/
|
||||
ipcMain.handle('cron:list', async () => {
|
||||
const service = getCronService()
|
||||
const jobs = service.list()
|
||||
|
||||
return jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
description: job.description,
|
||||
enabled: job.enabled,
|
||||
schedule: formatSchedule(job.schedule),
|
||||
sessionTarget: job.sessionTarget,
|
||||
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
|
||||
lastStatus: job.state.lastStatus ?? null,
|
||||
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
|
||||
lastDurationMs: job.state.lastDurationMs ?? null,
|
||||
lastError: job.state.lastError ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggle a cron job's enabled status.
|
||||
*/
|
||||
ipcMain.handle('cron:toggle', async (_event, jobId: string) => {
|
||||
const service = getCronService()
|
||||
const job = service.get(jobId)
|
||||
if (!job) {
|
||||
return { error: `Job not found: ${jobId}` }
|
||||
}
|
||||
|
||||
const updated = service.update(jobId, { enabled: !job.enabled })
|
||||
if (!updated) {
|
||||
return { error: `Failed to update job: ${jobId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
enabled: updated.enabled,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Remove a cron job.
|
||||
*/
|
||||
ipcMain.handle('cron:remove', async (_event, jobId: string) => {
|
||||
const service = getCronService()
|
||||
const removed = service.remove(jobId)
|
||||
if (!removed) {
|
||||
return { error: `Job not found: ${jobId}` }
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* Heartbeat IPC handlers for Electron main process.
|
||||
*/
|
||||
import { ipcMain } from "electron";
|
||||
import { getCurrentHub } from "./hub.js";
|
||||
|
||||
export function registerHeartbeatIpcHandlers(): void {
|
||||
ipcMain.handle("heartbeat:last", async () => {
|
||||
const hub = getCurrentHub();
|
||||
if (!hub) return null;
|
||||
return hub.getLastHeartbeat();
|
||||
});
|
||||
|
||||
ipcMain.handle("heartbeat:setEnabled", async (_event, enabled: boolean) => {
|
||||
const hub = getCurrentHub();
|
||||
if (!hub) {
|
||||
return { ok: false, error: "Hub not initialized" };
|
||||
}
|
||||
if (typeof enabled !== "boolean") {
|
||||
return { ok: false, error: "enabled must be boolean" };
|
||||
}
|
||||
|
||||
hub.setHeartbeatsEnabled(enabled);
|
||||
return { ok: true, enabled };
|
||||
});
|
||||
|
||||
ipcMain.handle("heartbeat:wake", async (_event, reason?: string) => {
|
||||
const hub = getCurrentHub();
|
||||
if (!hub) {
|
||||
return { ok: false, error: "Hub not initialized" };
|
||||
}
|
||||
|
||||
const result = await hub.runHeartbeatOnce({
|
||||
reason: typeof reason === "string" ? reason.trim() || "manual" : "manual",
|
||||
});
|
||||
|
||||
return { ok: result.status !== "failed", result };
|
||||
});
|
||||
}
|
||||
|
|
@ -1,607 +0,0 @@
|
|||
/**
|
||||
* Hub IPC handlers for Electron main process.
|
||||
*
|
||||
* Creates and manages a Hub instance that connects to the Gateway.
|
||||
* This follows the same pattern as the Console app.
|
||||
*/
|
||||
import { ipcMain, type BrowserWindow } from 'electron'
|
||||
import { Hub, type AsyncAgent } from '@multica/core'
|
||||
import type { ConnectionState } from '@multica/sdk'
|
||||
|
||||
// Singleton Hub instance
|
||||
let hub: Hub | null = null
|
||||
let defaultConversationId: string | null = null
|
||||
let mainWindowRef: BrowserWindow | null = null
|
||||
|
||||
function isConversationBusy(conversation: AsyncAgent): boolean {
|
||||
return conversation.isRunning
|
||||
|| conversation.isStreaming
|
||||
|| conversation.hasQueuedMessages()
|
||||
|| conversation.getPendingWrites() > 0
|
||||
}
|
||||
|
||||
interface IpcAgentSubscription {
|
||||
token: number
|
||||
unsubscribe: () => void
|
||||
}
|
||||
|
||||
// Track which agents have active IPC subscriptions (for local direct chat)
|
||||
const ipcAgentSubscriptions = new Map<string, IpcAgentSubscription>()
|
||||
let nextIpcSubscriptionToken = 1
|
||||
|
||||
// Resolve gateway URL: GATEWAY_URL env > MAIN_VITE_GATEWAY_URL (.env file)
|
||||
const gatewayUrl =
|
||||
process.env.GATEWAY_URL || import.meta.env.MAIN_VITE_GATEWAY_URL
|
||||
|
||||
/**
|
||||
* Safe log function that catches EPIPE errors.
|
||||
* Electron main process stdout can be closed unexpectedly.
|
||||
*/
|
||||
function safeLog(...args: unknown[]): void {
|
||||
try {
|
||||
console.log(...args)
|
||||
} catch {
|
||||
// Ignore EPIPE errors when stdout is closed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Hub on app startup.
|
||||
* Creates Hub and a default conversation automatically.
|
||||
*/
|
||||
export async function initializeHub(): Promise<void> {
|
||||
if (hub) {
|
||||
safeLog('[Desktop] Hub already initialized')
|
||||
return
|
||||
}
|
||||
|
||||
safeLog(`[Desktop] Initializing Hub, connecting to Gateway: ${gatewayUrl}`)
|
||||
|
||||
hub = new Hub(gatewayUrl)
|
||||
|
||||
// Create default conversation if none exists
|
||||
const conversations = hub.listConversations()
|
||||
if (conversations.length === 0) {
|
||||
safeLog('[Desktop] Creating default conversation...')
|
||||
const conversation = hub.createConversation()
|
||||
defaultConversationId = conversation.sessionId
|
||||
safeLog(`[Desktop] Default conversation created: ${defaultConversationId}`)
|
||||
} else {
|
||||
defaultConversationId = conversations[0]
|
||||
safeLog(`[Desktop] Using existing conversation: ${defaultConversationId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the Hub instance.
|
||||
*/
|
||||
function getHub(): Hub {
|
||||
if (!hub) {
|
||||
safeLog(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`)
|
||||
hub = new Hub(gatewayUrl)
|
||||
}
|
||||
return hub
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default agent.
|
||||
*/
|
||||
export function getDefaultAgent(): AsyncAgent | null {
|
||||
if (!hub || !defaultConversationId) return null
|
||||
return hub.getConversation(defaultConversationId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub info returned to renderer.
|
||||
*/
|
||||
export interface HubInfo {
|
||||
hubId: string
|
||||
url: string
|
||||
connectionState: ConnectionState
|
||||
agentCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent info returned to renderer.
|
||||
*/
|
||||
export interface AgentInfo {
|
||||
id: string
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Hub-related IPC handlers.
|
||||
*/
|
||||
export function registerHubIpcHandlers(): void {
|
||||
/**
|
||||
* Initialize the Hub (creates singleton if not exists).
|
||||
*/
|
||||
ipcMain.handle('hub:init', async () => {
|
||||
await initializeHub()
|
||||
const h = getHub()
|
||||
return {
|
||||
hubId: h.hubId,
|
||||
url: h.url,
|
||||
connectionState: h.connectionState,
|
||||
defaultConversationId,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get Hub status info.
|
||||
*/
|
||||
ipcMain.handle('hub:info', async (): Promise<HubInfo> => {
|
||||
const h = getHub()
|
||||
return {
|
||||
hubId: h.hubId,
|
||||
url: h.url,
|
||||
connectionState: h.connectionState,
|
||||
agentCount: h.listConversations().length,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get Hub status with default agent info (for home page).
|
||||
*/
|
||||
ipcMain.handle('hub:getStatus', async () => {
|
||||
const h = getHub()
|
||||
const agent = getDefaultAgent()
|
||||
|
||||
return {
|
||||
hubId: h.hubId,
|
||||
status: h.connectionState === 'connected' ? 'ready' : h.connectionState,
|
||||
agentCount: h.listConversations().length,
|
||||
gatewayConnected: h.connectionState === 'connected',
|
||||
gatewayUrl: h.url,
|
||||
defaultAgent: agent
|
||||
? {
|
||||
agentId: defaultConversationId ?? agent.sessionId,
|
||||
status: agent.closed ? 'closed' : 'idle',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get default agent info.
|
||||
*/
|
||||
ipcMain.handle('hub:getAgentInfo', async () => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
agentId: defaultConversationId ?? agent.sessionId,
|
||||
status: agent.closed ? 'closed' : 'idle',
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reconnect Hub to a different Gateway URL.
|
||||
*/
|
||||
ipcMain.handle('hub:reconnect', async (_event, url: string) => {
|
||||
const h = getHub()
|
||||
h.reconnect(url)
|
||||
return { url: h.url }
|
||||
})
|
||||
|
||||
/**
|
||||
* List all conversations.
|
||||
*/
|
||||
ipcMain.handle('hub:listConversations', async (): Promise<AgentInfo[]> => {
|
||||
const h = getHub()
|
||||
const conversationIds = h.listConversations()
|
||||
return conversationIds.map((id) => {
|
||||
const conversation = h.getConversation(id)
|
||||
return {
|
||||
id,
|
||||
closed: conversation?.closed ?? true,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a new conversation.
|
||||
*/
|
||||
ipcMain.handle('hub:createConversation', async (_event, id?: string) => {
|
||||
const h = getHub()
|
||||
const conversation = h.createConversation(id)
|
||||
return {
|
||||
id: conversation.sessionId,
|
||||
closed: conversation.closed,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get a specific conversation.
|
||||
*/
|
||||
ipcMain.handle('hub:getConversation', async (_event, id: string) => {
|
||||
const h = getHub()
|
||||
const conversation = h.getConversation(id)
|
||||
if (!conversation) {
|
||||
return { error: `Conversation not found: ${id}` }
|
||||
}
|
||||
return {
|
||||
id: conversation.sessionId,
|
||||
closed: conversation.closed,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Close/delete a conversation.
|
||||
*/
|
||||
ipcMain.handle('hub:closeConversation', async (_event, id: string) => {
|
||||
const h = getHub()
|
||||
const result = h.closeConversation(id)
|
||||
return { ok: result }
|
||||
})
|
||||
|
||||
/**
|
||||
* Send a message to an agent (for remote clients via Gateway).
|
||||
* Note: For local direct chat, use 'localChat:send' instead.
|
||||
*/
|
||||
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId: string) => {
|
||||
const h = getHub()
|
||||
const resolvedConversationId = conversationId
|
||||
const agent = h.getConversation(resolvedConversationId)
|
||||
if (!agent) {
|
||||
return { error: `Conversation not found: ${resolvedConversationId}` }
|
||||
}
|
||||
if (agent.closed) {
|
||||
return { error: `Conversation is closed: ${resolvedConversationId}` }
|
||||
}
|
||||
h.channelManager.clearLastRoute()
|
||||
agent.write(content)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Subscribe to local agent events (for direct IPC chat without Gateway).
|
||||
* Uses agent.subscribe() which supports multiple subscribers.
|
||||
*/
|
||||
ipcMain.handle('localChat:subscribe', async (_event, conversationId: string) => {
|
||||
const h = getHub()
|
||||
const conversation = h.getConversation(conversationId)
|
||||
if (!conversation) {
|
||||
return { error: `Conversation not found: ${conversationId}` }
|
||||
}
|
||||
if (conversation.closed) {
|
||||
return { error: `Conversation is closed: ${conversationId}` }
|
||||
}
|
||||
const logicalAgentId = h.getConversationAgentId(conversationId) ?? conversationId
|
||||
|
||||
const existingSubscription = ipcAgentSubscriptions.get(conversationId)
|
||||
if (existingSubscription) {
|
||||
const refreshedToken = nextIpcSubscriptionToken++
|
||||
ipcAgentSubscriptions.set(conversationId, {
|
||||
token: refreshedToken,
|
||||
unsubscribe: existingSubscription.unsubscribe,
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
alreadySubscribed: true,
|
||||
token: refreshedToken,
|
||||
isRunning: isConversationBusy(conversation),
|
||||
}
|
||||
}
|
||||
|
||||
// Track current stream ID for message grouping
|
||||
let currentStreamId: string | null = null
|
||||
|
||||
// Subscribe to agent events using the multi-subscriber mechanism
|
||||
const unsubscribe = conversation.subscribe((event) => {
|
||||
if (!mainWindowRef || mainWindowRef.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Compaction and error events: forward with no stream tracking
|
||||
const isPassthroughEvent =
|
||||
event.type === 'compaction_start' || event.type === 'compaction_end' || event.type === 'agent_error'
|
||||
if (isPassthroughEvent) {
|
||||
safeLog(`[IPC] Sending ${event.type} event to renderer`)
|
||||
mainWindowRef.webContents.send('localChat:event', {
|
||||
agentId: logicalAgentId,
|
||||
conversationId,
|
||||
event,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Filter events same as Hub.consumeAgent()
|
||||
const maybeMessage = (event as { message?: { role?: string } }).message
|
||||
const isAssistantMessage = maybeMessage?.role === 'assistant'
|
||||
const shouldForward =
|
||||
((event.type === 'message_start' || event.type === 'message_update' || event.type === 'message_end') && isAssistantMessage)
|
||||
|| event.type === 'tool_execution_start'
|
||||
|| event.type === 'tool_execution_update'
|
||||
|| event.type === 'tool_execution_end'
|
||||
|
||||
if (!shouldForward) return
|
||||
|
||||
// Track stream ID for message grouping (extract from event.message.id, same as Hub.beginStream)
|
||||
if (event.type === 'message_start') {
|
||||
const msgId = (event as { message?: { id?: string } }).message?.id
|
||||
currentStreamId = msgId ?? `stream-${Date.now()}`
|
||||
safeLog(`[IPC] Starting stream: ${currentStreamId}`)
|
||||
}
|
||||
|
||||
safeLog(`[IPC] Sending event to renderer: ${event.type}, streamId: ${currentStreamId}`)
|
||||
mainWindowRef.webContents.send('localChat:event', {
|
||||
agentId: logicalAgentId,
|
||||
conversationId,
|
||||
streamId: currentStreamId,
|
||||
event,
|
||||
})
|
||||
|
||||
if (event.type === 'message_end') {
|
||||
safeLog(`[IPC] Ending stream: ${currentStreamId}`)
|
||||
currentStreamId = null
|
||||
}
|
||||
})
|
||||
|
||||
const token = nextIpcSubscriptionToken++
|
||||
ipcAgentSubscriptions.set(conversationId, { token, unsubscribe })
|
||||
|
||||
// Register local approval handler so exec approval requests route via IPC
|
||||
h.setLocalApprovalHandler(conversationId, (payload) => {
|
||||
if (!mainWindowRef || mainWindowRef.isDestroyed()) return
|
||||
safeLog(`[IPC] Sending approval request to renderer: ${payload.approvalId}`)
|
||||
mainWindowRef.webContents.send('localChat:approval', payload)
|
||||
})
|
||||
|
||||
safeLog(`[IPC] Local chat subscribed to conversation: ${conversationId}`)
|
||||
|
||||
return { ok: true, token, isRunning: isConversationBusy(conversation) }
|
||||
})
|
||||
|
||||
/**
|
||||
* Unsubscribe from local agent events.
|
||||
*/
|
||||
ipcMain.handle('localChat:unsubscribe', async (_event, conversationId: string, token?: number) => {
|
||||
const subscription = ipcAgentSubscriptions.get(conversationId)
|
||||
if (!subscription) {
|
||||
return { ok: true, alreadyUnsubscribed: true }
|
||||
}
|
||||
|
||||
if (typeof token === 'number' && token !== subscription.token) {
|
||||
safeLog(`[IPC] Skip stale local chat unsubscribe: conversation=${conversationId}, token=${token}`)
|
||||
return { ok: true, skipped: true }
|
||||
}
|
||||
|
||||
subscription.unsubscribe()
|
||||
ipcAgentSubscriptions.delete(conversationId)
|
||||
getHub().removeLocalApprovalHandler(conversationId)
|
||||
safeLog(`[IPC] Local chat unsubscribed from conversation: ${conversationId}`)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Get message history for local chat with pagination.
|
||||
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
|
||||
* tool results, thinking blocks, etc. — same format as the Gateway RPC.
|
||||
*
|
||||
* Reads from session storage (not in-memory state) so that internal
|
||||
* orchestration messages are excluded by default.
|
||||
*/
|
||||
ipcMain.handle('localChat:getHistory', async (
|
||||
_event,
|
||||
conversationId: string,
|
||||
options?: { offset?: number; limit?: number },
|
||||
) => {
|
||||
const h = getHub()
|
||||
const agent = h.getConversation(conversationId)
|
||||
if (!agent) {
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
contextWindowTokens: undefined,
|
||||
isRunning: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await agent.ensureInitialized()
|
||||
const allMessages = agent.loadSessionMessagesForDisplay()
|
||||
const contextWindowTokens = agent.getContextWindowTokens()
|
||||
const isRunning = isConversationBusy(agent)
|
||||
const total = allMessages.length
|
||||
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
|
||||
const limit = options?.limit ?? 200
|
||||
const offset = options?.offset ?? Math.max(0, total - limit)
|
||||
const sliced = allMessages.slice(offset, offset + limit)
|
||||
return { messages: sliced, total, offset, limit, contextWindowTokens, isRunning }
|
||||
} catch {
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
contextWindowTokens: undefined,
|
||||
isRunning: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Send a message via local direct IPC (no Gateway).
|
||||
* Events will be pushed to renderer via 'localChat:event' channel.
|
||||
*/
|
||||
ipcMain.handle('localChat:send', async (_event, conversationId: string, content: string) => {
|
||||
const h = getHub()
|
||||
const resolvedConversationId = conversationId
|
||||
const agent = h.getConversation(resolvedConversationId)
|
||||
if (!agent) {
|
||||
return { error: `Conversation not found: ${resolvedConversationId}` }
|
||||
}
|
||||
if (agent.closed) {
|
||||
return { error: `Conversation is closed: ${resolvedConversationId}` }
|
||||
}
|
||||
|
||||
// Must be subscribed first to receive events
|
||||
if (!ipcAgentSubscriptions.has(resolvedConversationId)) {
|
||||
return { error: 'Not subscribed to conversation events. Call subscribe first.' }
|
||||
}
|
||||
|
||||
h.channelManager.clearLastRoute()
|
||||
const source = { type: 'local' as const }
|
||||
// Broadcast as local source (for consistency, though UI already knows)
|
||||
h.broadcastInbound({
|
||||
agentId: h.getConversationAgentId(resolvedConversationId) ?? resolvedConversationId,
|
||||
conversationId: resolvedConversationId,
|
||||
content,
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
agent.write(content, { source })
|
||||
safeLog(`[IPC] Local chat message sent to conversation: ${resolvedConversationId}`)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Abort the current agent run for local chat.
|
||||
*/
|
||||
ipcMain.handle('localChat:abort', async (_event, conversationId: string) => {
|
||||
const h = getHub()
|
||||
const resolvedConversationId = conversationId
|
||||
const agent = h.getConversation(resolvedConversationId)
|
||||
if (!agent) {
|
||||
return { error: `Conversation not found: ${resolvedConversationId}` }
|
||||
}
|
||||
agent.abort()
|
||||
safeLog(`[IPC] Abort sent to conversation: ${resolvedConversationId}`)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve an exec approval request for local chat.
|
||||
*/
|
||||
ipcMain.handle('localChat:resolveExecApproval', async (_event, approvalId: string, decision: string) => {
|
||||
const h = getHub()
|
||||
const ok = h.resolveExecApproval(approvalId, decision as 'allow-once' | 'allow-always' | 'deny')
|
||||
return { ok }
|
||||
})
|
||||
|
||||
/**
|
||||
* Register a one-time token for device verification.
|
||||
* Called by the QR code component when a token is generated or refreshed.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'hub:registerToken',
|
||||
async (_event, token: string, agentId: string, conversationId: string, expiresAt: number) => {
|
||||
const h = getHub()
|
||||
h.registerToken(token, agentId, conversationId, expiresAt)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* List all verified (whitelisted) devices.
|
||||
*/
|
||||
ipcMain.handle('hub:listDevices', async () => {
|
||||
const h = getHub()
|
||||
return h.deviceStore.listDevices()
|
||||
})
|
||||
|
||||
/**
|
||||
* Revoke a device from the whitelist.
|
||||
*/
|
||||
ipcMain.handle('hub:revokeDevice', async (_event, deviceId: string) => {
|
||||
const h = getHub()
|
||||
const ok = h.deviceStore.revokeDevice(deviceId)
|
||||
// Notify renderer that device list changed
|
||||
if (ok && mainWindowRef && !mainWindowRef.isDestroyed()) {
|
||||
mainWindowRef.webContents.send('hub:devices-changed')
|
||||
}
|
||||
return { ok }
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up device confirmation flow between Hub (main process) and renderer.
|
||||
* Also stores window reference for local chat IPC events.
|
||||
* Must be called after both Hub initialization and window creation.
|
||||
*/
|
||||
export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): void {
|
||||
// Store reference for local chat IPC
|
||||
mainWindowRef = mainWindow
|
||||
const h = getHub()
|
||||
const pendingConfirms = new Map<string, (allowed: boolean) => void>()
|
||||
|
||||
// Listen for renderer responses to device confirm dialogs
|
||||
ipcMain.on('hub:device-confirm-response', (_event, deviceId: string, allowed: boolean) => {
|
||||
const resolve = pendingConfirms.get(deviceId)
|
||||
if (resolve) {
|
||||
pendingConfirms.delete(deviceId)
|
||||
resolve(allowed)
|
||||
}
|
||||
})
|
||||
|
||||
// Register confirm handler on Hub — sends request to renderer, awaits response
|
||||
h.setConfirmHandler((deviceId: string, agentId: string, conversationId: string, meta) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// Auto-reject if user doesn't respond within 60 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
pendingConfirms.delete(deviceId)
|
||||
resolve(false)
|
||||
}, 60_000)
|
||||
pendingConfirms.set(deviceId, (allowed: boolean) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(allowed)
|
||||
// Notify renderer that device list changed when a device is approved
|
||||
if (allowed && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hub:devices-changed')
|
||||
}
|
||||
})
|
||||
mainWindow.webContents.send('hub:device-confirm-request', deviceId, agentId, conversationId, meta)
|
||||
})
|
||||
})
|
||||
|
||||
// Forward connection state changes to renderer
|
||||
h.onConnectionStateChange((state) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hub:connection-state-changed', state)
|
||||
}
|
||||
})
|
||||
|
||||
// Forward inbound messages from all sources (gateway, channel) to renderer
|
||||
h.onInboundMessage((event) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hub:inbound-message', event)
|
||||
}
|
||||
})
|
||||
|
||||
// Forward conversation list changes (e.g. created from Telegram / RPC).
|
||||
h.onConversationsChanged(() => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hub:conversations-changed')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Hub resources.
|
||||
*/
|
||||
export function cleanupHub(): void {
|
||||
// Unsubscribe all IPC listeners
|
||||
for (const subscription of ipcAgentSubscriptions.values()) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
ipcAgentSubscriptions.clear()
|
||||
|
||||
if (hub) {
|
||||
safeLog('[Desktop] Shutting down Hub')
|
||||
hub.shutdown()
|
||||
hub = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Hub instance (for use by other IPC modules).
|
||||
*/
|
||||
export function getCurrentHub(): Hub | null {
|
||||
return hub
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/**
|
||||
* IPC handlers index - register all handlers from main process.
|
||||
*/
|
||||
export { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
|
||||
export { registerSkillsIpcHandlers } from './skills.js'
|
||||
export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation, getDefaultAgent } from './hub.js'
|
||||
export { registerProfileIpcHandlers } from './profile.js'
|
||||
export { registerProviderIpcHandlers } from './provider.js'
|
||||
export { registerChannelsIpcHandlers } from './channels.js'
|
||||
export { registerCronIpcHandlers } from './cron.js'
|
||||
export { registerHeartbeatIpcHandlers } from './heartbeat.js'
|
||||
export { registerAppStateIpcHandlers } from './app-state.js'
|
||||
export { registerAuthHandlers, setMainWindow as setAuthMainWindow, handleAuthDeepLink } from './auth.js'
|
||||
|
||||
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
|
||||
import { registerAuthHandlers } from './auth.js'
|
||||
import { registerSkillsIpcHandlers } from './skills.js'
|
||||
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
|
||||
import { registerProfileIpcHandlers } from './profile.js'
|
||||
import { registerProviderIpcHandlers } from './provider.js'
|
||||
import { registerChannelsIpcHandlers } from './channels.js'
|
||||
import { registerCronIpcHandlers } from './cron.js'
|
||||
import { registerHeartbeatIpcHandlers } from './heartbeat.js'
|
||||
import { registerAppStateIpcHandlers } from './app-state.js'
|
||||
|
||||
/**
|
||||
* Register all IPC handlers.
|
||||
* Call this in main.ts after app is ready.
|
||||
*/
|
||||
export function registerAllIpcHandlers(): void {
|
||||
registerHubIpcHandlers()
|
||||
registerAgentIpcHandlers()
|
||||
registerSkillsIpcHandlers()
|
||||
registerProfileIpcHandlers()
|
||||
registerProviderIpcHandlers()
|
||||
registerChannelsIpcHandlers()
|
||||
registerCronIpcHandlers()
|
||||
registerHeartbeatIpcHandlers()
|
||||
registerAppStateIpcHandlers()
|
||||
registerAuthHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Hub and create default agent.
|
||||
* Call this after IPC handlers are registered.
|
||||
*/
|
||||
export async function initializeApp(): Promise<void> {
|
||||
console.log('[Desktop] Initializing app...')
|
||||
await initializeHub()
|
||||
console.log('[Desktop] App initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all resources.
|
||||
* Call this before app quits.
|
||||
*/
|
||||
export function cleanupAll(): void {
|
||||
cleanupHub()
|
||||
cleanupAgent()
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* Profile IPC handlers for Electron main process.
|
||||
*
|
||||
* Manages agent profile settings like name and user.md content.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCurrentHub } from './hub.js'
|
||||
|
||||
/**
|
||||
* Get the default agent from Hub.
|
||||
*/
|
||||
function getDefaultAgent() {
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return null
|
||||
|
||||
const conversationIds = hub.listConversations()
|
||||
if (conversationIds.length === 0) return null
|
||||
|
||||
return hub.getConversation(conversationIds[0]) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile data returned to renderer.
|
||||
*/
|
||||
export interface ProfileData {
|
||||
profileId: string | undefined
|
||||
name: string | undefined
|
||||
userContent: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Profile-related IPC handlers.
|
||||
*/
|
||||
export function registerProfileIpcHandlers(): void {
|
||||
/**
|
||||
* Get profile data (name + user content).
|
||||
*/
|
||||
ipcMain.handle('profile:get', async (): Promise<ProfileData> => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return {
|
||||
profileId: undefined,
|
||||
name: undefined,
|
||||
userContent: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profileId: agent.getProfileId(),
|
||||
name: agent.getAgentName(),
|
||||
userContent: agent.getUserContent(),
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Update agent display name.
|
||||
*/
|
||||
ipcMain.handle('profile:updateName', async (_event, name: string) => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
agent.setAgentName(name)
|
||||
return { ok: true, name }
|
||||
})
|
||||
|
||||
/**
|
||||
* Update user.md content.
|
||||
*/
|
||||
ipcMain.handle('profile:updateUser', async (_event, content: string) => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
console.error('[Profile IPC] No agent available for updateUser')
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
console.log('[Profile IPC] Updating user content:', content.substring(0, 50) + '...')
|
||||
agent.setUserContent(content)
|
||||
|
||||
// Reload system prompt to apply changes immediately
|
||||
console.log('[Profile IPC] Reloading system prompt...')
|
||||
agent.reloadSystemPrompt()
|
||||
|
||||
// Verify the change
|
||||
const newUserContent = agent.getUserContent()
|
||||
console.log('[Profile IPC] New user content:', newUserContent?.substring(0, 50) + '...')
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
readClaudeCliCredentials,
|
||||
readCodexCliCredentials,
|
||||
credentialManager,
|
||||
type ProviderInfo,
|
||||
} from '@multica/core'
|
||||
|
||||
/**
|
||||
* 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 conversationIds = hub.listConversations()
|
||||
if (conversationIds.length === 0) return null
|
||||
|
||||
return hub.getConversation(conversationIds[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}`,
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Test a provider connection by sending a minimal prompt.
|
||||
* Temporarily switches to the target provider, runs a test, then restores.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'provider:test',
|
||||
async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { ok: false, error: 'No agent available. Please wait for initialization.' }
|
||||
}
|
||||
|
||||
const meta = getProviderMeta(providerId)
|
||||
if (!meta) {
|
||||
return { ok: false, error: `Unknown provider: ${providerId}` }
|
||||
}
|
||||
|
||||
if (!isProviderAvailable(providerId)) {
|
||||
return { ok: false, error: `Provider "${meta.name}" is not configured.` }
|
||||
}
|
||||
|
||||
return agent.testProvider(providerId, modelId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
/**
|
||||
* Skills IPC handlers for Electron main process.
|
||||
*
|
||||
* These handlers get skill information from the real Agent instance
|
||||
* managed by the Hub.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCurrentHub } from './hub.js'
|
||||
|
||||
/**
|
||||
* Skill info returned to renderer.
|
||||
*/
|
||||
export interface SkillInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
source: 'bundled' | 'global' | 'profile'
|
||||
triggers: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default agent from Hub.
|
||||
*/
|
||||
function getDefaultAgent() {
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return null
|
||||
|
||||
const conversationIds = hub.listConversations()
|
||||
if (conversationIds.length === 0) return null
|
||||
|
||||
return hub.getConversation(conversationIds[0]) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default bundled skills (fallback when no agent).
|
||||
*/
|
||||
function getDefaultSkills(): SkillInfo[] {
|
||||
return [
|
||||
{
|
||||
id: 'commit',
|
||||
name: 'Git Commit Helper',
|
||||
description: 'Create well-formatted git commits following conventional commit standards',
|
||||
version: '1.0.0',
|
||||
enabled: true,
|
||||
source: 'bundled',
|
||||
triggers: ['/commit'],
|
||||
},
|
||||
{
|
||||
id: 'code-review',
|
||||
name: 'Code Review',
|
||||
description: 'Review code for bugs, security issues, and best practices',
|
||||
version: '1.0.0',
|
||||
enabled: true,
|
||||
source: 'bundled',
|
||||
triggers: ['/review'],
|
||||
},
|
||||
{
|
||||
id: 'skill-creator',
|
||||
name: 'Skill Creator',
|
||||
description: 'Create, edit, and manage custom skills',
|
||||
version: '1.0.0',
|
||||
enabled: true,
|
||||
source: 'bundled',
|
||||
triggers: ['/skill'],
|
||||
},
|
||||
{
|
||||
id: 'profile-setup',
|
||||
name: 'Profile Setup',
|
||||
description: 'Interactive setup wizard to personalize your agent profile',
|
||||
version: '1.0.0',
|
||||
enabled: true,
|
||||
source: 'bundled',
|
||||
triggers: ['/profile'],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Skills-related IPC handlers.
|
||||
*/
|
||||
export function registerSkillsIpcHandlers(): void {
|
||||
/**
|
||||
* Get list of all skills with their status.
|
||||
* Returns skills from the real Agent instance.
|
||||
*/
|
||||
ipcMain.handle('skills:list', async () => {
|
||||
const agent = getDefaultAgent()
|
||||
|
||||
if (!agent) {
|
||||
// Fallback: return default skills when no agent
|
||||
console.log('[IPC] skills:list - No agent available, returning defaults')
|
||||
return getDefaultSkills()
|
||||
}
|
||||
|
||||
try {
|
||||
const skillsWithStatus = agent.getSkillsWithStatus()
|
||||
|
||||
// Transform to SkillInfo format
|
||||
const skills: SkillInfo[] = skillsWithStatus.map((skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
version: '1.0.0',
|
||||
enabled: skill.eligible,
|
||||
source: skill.source as 'bundled' | 'global' | 'profile',
|
||||
triggers: [`/${skill.id}`],
|
||||
}))
|
||||
|
||||
console.log(`[IPC] skills:list - Returning ${skills.length} skills from agent`)
|
||||
return skills
|
||||
} catch (err) {
|
||||
console.error('[IPC] skills:list - Error getting skills from agent:', err)
|
||||
return getDefaultSkills()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggle a skill's enabled status.
|
||||
* NOTE: Skills eligibility is determined by requirements (env vars, binaries, etc.)
|
||||
* This handler reports the current eligibility status.
|
||||
*/
|
||||
ipcMain.handle('skills:toggle', async (_event, skillId: string) => {
|
||||
console.log(`[IPC] skills:toggle called for: ${skillId}`)
|
||||
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
const skillsWithStatus = agent.getSkillsWithStatus()
|
||||
const skill = skillsWithStatus.find((s) => s.id === skillId)
|
||||
|
||||
if (!skill) {
|
||||
return { error: `Skill not found: ${skillId}` }
|
||||
}
|
||||
|
||||
// Skills can't be manually toggled - eligibility is based on requirements
|
||||
// Return current status
|
||||
return {
|
||||
id: skillId,
|
||||
enabled: skill.eligible,
|
||||
reasons: skill.reasons,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Set a skill's enabled status explicitly.
|
||||
* NOTE: Skills eligibility is automatic based on requirements.
|
||||
* This handler is a no-op but returns current status.
|
||||
*/
|
||||
ipcMain.handle('skills:setStatus', async (_event, skillId: string, enabled: boolean) => {
|
||||
console.log(`[IPC] skills:setStatus called for: ${skillId}, enabled: ${enabled}`)
|
||||
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
const skillsWithStatus = agent.getSkillsWithStatus()
|
||||
const skill = skillsWithStatus.find((s) => s.id === skillId)
|
||||
|
||||
if (!skill) {
|
||||
return { error: `Skill not found: ${skillId}` }
|
||||
}
|
||||
|
||||
// TODO: Implement skill disable via config
|
||||
// For now, just return current eligibility status
|
||||
return {
|
||||
id: skillId,
|
||||
enabled: skill.eligible,
|
||||
reasons: skill.reasons,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get skill details by ID.
|
||||
*/
|
||||
ipcMain.handle('skills:get', async (_event, skillId: string) => {
|
||||
const agent = getDefaultAgent()
|
||||
|
||||
if (!agent) {
|
||||
// Fallback: check default skills
|
||||
const defaults = getDefaultSkills()
|
||||
const skill = defaults.find((s) => s.id === skillId)
|
||||
if (skill) return skill
|
||||
return { error: `Skill not found: ${skillId}` }
|
||||
}
|
||||
|
||||
const skillsWithStatus = agent.getSkillsWithStatus()
|
||||
const skill = skillsWithStatus.find((s) => s.id === skillId)
|
||||
|
||||
if (!skill) {
|
||||
return { error: `Skill not found: ${skillId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
version: '1.0.0',
|
||||
enabled: skill.eligible,
|
||||
source: skill.source as 'bundled' | 'global' | 'profile',
|
||||
triggers: [`/${skill.id}`],
|
||||
reasons: skill.reasons,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reload skills from disk.
|
||||
*/
|
||||
ipcMain.handle('skills:reload', async () => {
|
||||
const agent = getDefaultAgent()
|
||||
if (!agent) {
|
||||
return { error: 'No agent available' }
|
||||
}
|
||||
|
||||
agent.reloadSkills()
|
||||
console.log('[IPC] skills:reload - Skills reloaded')
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Add a skill from GitHub repository.
|
||||
* Source formats: owner/repo, owner/repo/skill-name, or full GitHub URL
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'skills:add',
|
||||
async (
|
||||
_event,
|
||||
source: string,
|
||||
options?: { name?: string; force?: boolean },
|
||||
) => {
|
||||
console.log(`[IPC] skills:add called: source=${source}, options=${JSON.stringify(options)}`)
|
||||
|
||||
const { addSkill } = await import('@multica/core')
|
||||
|
||||
const result = await addSkill({
|
||||
source,
|
||||
name: options?.name,
|
||||
force: options?.force,
|
||||
})
|
||||
|
||||
console.log(`[IPC] skills:add result: ${result.message}`)
|
||||
|
||||
// Reload skills in agent if available
|
||||
const agent = getDefaultAgent()
|
||||
if (agent && result.ok) {
|
||||
agent.reloadSkills()
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Remove an installed skill by name.
|
||||
*/
|
||||
ipcMain.handle('skills:remove', async (_event, name: string) => {
|
||||
console.log(`[IPC] skills:remove called: name=${name}`)
|
||||
|
||||
const { removeSkill } = await import('@multica/core')
|
||||
|
||||
const result = await removeSkill(name)
|
||||
|
||||
console.log(`[IPC] skills:remove result: ${result.message}`)
|
||||
|
||||
// Reload skills in agent if available
|
||||
const agent = getDefaultAgent()
|
||||
if (agent && result.ok) {
|
||||
agent.reloadSkills()
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
/**
|
||||
* System tray (menu bar) for the desktop app.
|
||||
*
|
||||
* Shows agent/hub status and allows window show/hide even
|
||||
* when the main window is closed.
|
||||
*/
|
||||
import { Tray, Menu, nativeImage, app, type BrowserWindow } from 'electron'
|
||||
import path from 'node:path'
|
||||
import { getCurrentHub, getDefaultAgent } from './ipc/hub.js'
|
||||
|
||||
let tray: Tray | null = null
|
||||
let mainWindowRef: BrowserWindow | null = null
|
||||
let statusInterval: ReturnType<typeof setInterval> | null = null
|
||||
let checkForUpdatesFn: (() => void) | null = null
|
||||
|
||||
export interface TrayOptions {
|
||||
onCheckForUpdates?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the system tray and start status polling.
|
||||
*/
|
||||
export function createTray(window: BrowserWindow, options?: TrayOptions): void {
|
||||
mainWindowRef = window
|
||||
checkForUpdatesFn = options?.onCheckForUpdates ?? null
|
||||
|
||||
// Use dedicated tray icon (asterisk shape matching MulticaIcon).
|
||||
// On macOS, Electron auto-picks trayTemplate.png / trayTemplate@2x.png
|
||||
// and treats "Template" suffix as a template image (adapts to dark/light menu bar).
|
||||
const iconPath = path.join(process.env.APP_ROOT!, 'build', 'trayTemplate.png')
|
||||
const icon = nativeImage.createFromPath(iconPath)
|
||||
|
||||
tray = new Tray(icon)
|
||||
tray.setToolTip('Multica')
|
||||
|
||||
// Initial menu
|
||||
updateTrayMenu()
|
||||
|
||||
// Poll status every 2 seconds
|
||||
statusInterval = setInterval(updateTrayMenu, 2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy tray and stop polling.
|
||||
*/
|
||||
export function destroyTray(): void {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval)
|
||||
statusInterval = null
|
||||
}
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
}
|
||||
mainWindowRef = null
|
||||
checkForUpdatesFn = null
|
||||
}
|
||||
|
||||
function showMainWindow(): void {
|
||||
if (!mainWindowRef || mainWindowRef.isDestroyed()) return
|
||||
mainWindowRef.show()
|
||||
mainWindowRef.focus()
|
||||
}
|
||||
|
||||
function updateTrayMenu(): void {
|
||||
if (!tray) return
|
||||
|
||||
const hub = getCurrentHub()
|
||||
const agent = getDefaultAgent()
|
||||
|
||||
let agentStatus = 'Initializing'
|
||||
let hubStatus = 'Disconnected'
|
||||
let gatewayUrl = ''
|
||||
|
||||
if (hub) {
|
||||
hubStatus = hub.connectionState === 'connected' ? 'Connected' : 'Disconnected'
|
||||
gatewayUrl = hub.url
|
||||
}
|
||||
|
||||
if (agent && !agent.closed) {
|
||||
if (agent.isStreaming) {
|
||||
agentStatus = 'Streaming'
|
||||
} else if (agent.isRunning) {
|
||||
agentStatus = 'Running'
|
||||
} else {
|
||||
agentStatus = 'Idle'
|
||||
}
|
||||
}
|
||||
|
||||
tray.setToolTip(`Multica - Agent: ${agentStatus}`)
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{ label: `Agent: ${agentStatus}`, enabled: false },
|
||||
{ label: `Hub: ${hubStatus}`, enabled: false },
|
||||
]
|
||||
|
||||
if (gatewayUrl) {
|
||||
template.push({ label: `Gateway: ${gatewayUrl}`, enabled: false })
|
||||
}
|
||||
|
||||
template.push(
|
||||
{ type: 'separator' },
|
||||
{ label: 'Show Main Window', click: showMainWindow },
|
||||
{ type: 'separator' },
|
||||
{ label: `Version ${app.getVersion()}`, enabled: false },
|
||||
)
|
||||
|
||||
if (checkForUpdatesFn) {
|
||||
const fn = checkForUpdatesFn
|
||||
template.push({ label: 'Check for Updates', click: () => fn() })
|
||||
}
|
||||
|
||||
template.push(
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit Multica', accelerator: 'CommandOrControl+Q', click: () => app.quit() },
|
||||
)
|
||||
|
||||
tray.setContextMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Auto-updater module using electron-updater
|
||||
* Checks for updates from GitHub releases and handles download/install
|
||||
*/
|
||||
import pkg from 'electron-updater'
|
||||
import type { UpdateInfo, ProgressInfo } from 'electron-updater'
|
||||
|
||||
const { autoUpdater } = pkg
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export interface UpdateStatus {
|
||||
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
|
||||
info?: UpdateInfo
|
||||
progress?: ProgressInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class AutoUpdater {
|
||||
private mainWindow: (() => BrowserWindow | null) | null = null
|
||||
|
||||
constructor(forceDevUpdateConfig = false) {
|
||||
// Configure auto-updater
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
// Enable update checking in dev mode for testing
|
||||
if (forceDevUpdateConfig) {
|
||||
autoUpdater.forceDevUpdateConfig = true
|
||||
console.log('[AutoUpdater] Force dev update config enabled')
|
||||
}
|
||||
|
||||
// Enable logging
|
||||
autoUpdater.logger = {
|
||||
info: (msg) => console.log('[AutoUpdater]', msg),
|
||||
warn: (msg) => console.warn('[AutoUpdater]', msg),
|
||||
error: (msg) => console.error('[AutoUpdater]', msg),
|
||||
debug: (msg) => console.log('[AutoUpdater:debug]', msg)
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
this.sendStatus({ status: 'checking' })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
this.sendStatus({ status: 'available', info })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
this.sendStatus({ status: 'not-available', info })
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
|
||||
this.sendStatus({ status: 'downloading', progress })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
|
||||
this.sendStatus({ status: 'downloaded', info })
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err: Error) => {
|
||||
this.sendStatus({ status: 'error', error: err.message })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the main window reference for sending IPC messages
|
||||
*/
|
||||
setMainWindow(getWindow: () => BrowserWindow | null): void {
|
||||
this.mainWindow = getWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates
|
||||
*/
|
||||
async checkForUpdates(): Promise<void> {
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
} catch (err) {
|
||||
console.error('[AutoUpdater] Check for updates failed:', err)
|
||||
this.sendStatus({
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the available update
|
||||
*/
|
||||
async downloadUpdate(): Promise<void> {
|
||||
try {
|
||||
await autoUpdater.downloadUpdate()
|
||||
} catch (err) {
|
||||
console.error('[AutoUpdater] Download update failed:', err)
|
||||
this.sendStatus({
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Download failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit and install the downloaded update
|
||||
*/
|
||||
quitAndInstall(): void {
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update status to renderer
|
||||
*/
|
||||
private sendStatus(status: UpdateStatus): void {
|
||||
const window = this.mainWindow?.()
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.webContents.send('update:status', status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create updater with options
|
||||
export function createUpdater(forceDevUpdateConfig = false): AutoUpdater {
|
||||
return new AutoUpdater(forceDevUpdateConfig)
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
|
||||
// ============================================================================
|
||||
// Type definitions for IPC API
|
||||
// ============================================================================
|
||||
|
||||
export interface HubStatus {
|
||||
hubId: string
|
||||
status: string
|
||||
agentCount: number
|
||||
gatewayConnected: boolean
|
||||
gatewayUrl?: string
|
||||
defaultAgent?: {
|
||||
agentId: string
|
||||
status: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
agentId: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string
|
||||
group: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
source: 'bundled' | 'global' | 'profile'
|
||||
triggers: string[]
|
||||
}
|
||||
|
||||
export interface ProfileData {
|
||||
profileId: string | undefined
|
||||
name: string | undefined
|
||||
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
|
||||
conversationId: string
|
||||
streamId?: string
|
||||
type?: 'error'
|
||||
content?: string
|
||||
event?: {
|
||||
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
|
||||
id?: string
|
||||
message?: {
|
||||
role: string
|
||||
content?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound message event (from any source: local, gateway, channel)
|
||||
export type MessageSource =
|
||||
| { type: 'local' }
|
||||
| { type: 'gateway'; deviceId: string }
|
||||
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
|
||||
|
||||
export interface InboundMessageEvent {
|
||||
agentId: string
|
||||
conversationId: string
|
||||
content: string
|
||||
source: MessageSource
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk)
|
||||
export interface LocalChatApproval {
|
||||
approvalId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
command: string
|
||||
cwd?: string
|
||||
riskLevel: 'safe' | 'needs-review' | 'dangerous'
|
||||
riskReasons: string[]
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Expose typed API to Renderer process
|
||||
// ============================================================================
|
||||
|
||||
const electronAPI = {
|
||||
// App-level
|
||||
app: {
|
||||
/** Get CLI flags passed to the app */
|
||||
getFlags: (): Promise<{ forceOnboarding: boolean }> => ipcRenderer.invoke('app:getFlags'),
|
||||
},
|
||||
|
||||
// App state (persisted to file system)
|
||||
appState: {
|
||||
/** Get onboarding completed status */
|
||||
getOnboardingCompleted: (): Promise<boolean> => ipcRenderer.invoke('appState:getOnboardingCompleted'),
|
||||
/** Set onboarding completed status */
|
||||
setOnboardingCompleted: (completed: boolean): Promise<void> => ipcRenderer.invoke('appState:setOnboardingCompleted', completed),
|
||||
},
|
||||
|
||||
// Auth management
|
||||
auth: {
|
||||
/** Load auth data from local file */
|
||||
load: (): Promise<{ sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number }; deviceId?: string } | null> =>
|
||||
ipcRenderer.invoke('auth:load'),
|
||||
/** Save auth data to local file (with optional deviceId from Web) */
|
||||
save: (sid: string, user: { uid: string; name: string; email?: string; icon?: string; vip?: number }, deviceId?: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('auth:save', sid, user, deviceId),
|
||||
/** Clear auth data (logout) */
|
||||
clear: (): Promise<boolean> => ipcRenderer.invoke('auth:clear'),
|
||||
/** Start login flow (opens browser) */
|
||||
startLogin: (): Promise<void> => ipcRenderer.invoke('auth:startLogin'),
|
||||
/** Get Device ID (raw UUID) */
|
||||
getDeviceId: (): Promise<string> => ipcRenderer.invoke('auth:getDeviceId'),
|
||||
/** Get encrypted Device-Id header value for API requests */
|
||||
getDeviceIdHeader: (): Promise<string> => ipcRenderer.invoke('auth:getDeviceIdHeader'),
|
||||
/** Listen for auth callback (includes deviceId from Web browser) */
|
||||
onAuthCallback: (callback: (data: { sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number }; deviceId?: string }) => void) => {
|
||||
ipcRenderer.on('auth:callback', (_event, data) => callback(data))
|
||||
},
|
||||
/** Remove auth callback listener */
|
||||
offAuthCallback: () => {
|
||||
ipcRenderer.removeAllListeners('auth:callback')
|
||||
},
|
||||
},
|
||||
|
||||
// Hub management
|
||||
hub: {
|
||||
init: () => ipcRenderer.invoke('hub:init'),
|
||||
getStatus: (): Promise<HubStatus> => ipcRenderer.invoke('hub:getStatus'),
|
||||
getAgentInfo: (): Promise<AgentInfo | null> => ipcRenderer.invoke('hub:getAgentInfo'),
|
||||
info: () => ipcRenderer.invoke('hub:info'),
|
||||
reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url),
|
||||
listConversations: () => ipcRenderer.invoke('hub:listConversations'),
|
||||
createConversation: (id?: string) => ipcRenderer.invoke('hub:createConversation', id),
|
||||
getConversation: (id: string) => ipcRenderer.invoke('hub:getConversation', id),
|
||||
closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id),
|
||||
sendMessage: (agentId: string, content: string, conversationId: string) =>
|
||||
ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId),
|
||||
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) =>
|
||||
ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt),
|
||||
onDeviceConfirmRequest: (
|
||||
callback: (
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
'hub:device-confirm-request',
|
||||
(
|
||||
_event,
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
|
||||
) => callback(deviceId, agentId, conversationId, meta),
|
||||
)
|
||||
},
|
||||
offDeviceConfirmRequest: () => {
|
||||
ipcRenderer.removeAllListeners('hub:device-confirm-request')
|
||||
},
|
||||
deviceConfirmResponse: (deviceId: string, allowed: boolean) => {
|
||||
ipcRenderer.send('hub:device-confirm-response', deviceId, allowed)
|
||||
},
|
||||
listDevices: () => ipcRenderer.invoke('hub:listDevices'),
|
||||
revokeDevice: (deviceId: string) => ipcRenderer.invoke('hub:revokeDevice', deviceId),
|
||||
onConnectionStateChanged: (callback: (state: string) => void) => {
|
||||
ipcRenderer.on('hub:connection-state-changed', (_event, state: string) => callback(state))
|
||||
},
|
||||
offConnectionStateChanged: () => {
|
||||
ipcRenderer.removeAllListeners('hub:connection-state-changed')
|
||||
},
|
||||
onDevicesChanged: (callback: () => void) => {
|
||||
ipcRenderer.on('hub:devices-changed', () => callback())
|
||||
},
|
||||
offDevicesChanged: () => {
|
||||
ipcRenderer.removeAllListeners('hub:devices-changed')
|
||||
},
|
||||
onInboundMessage: (callback: (event: InboundMessageEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: InboundMessageEvent): void => {
|
||||
callback(data)
|
||||
}
|
||||
ipcRenderer.on('hub:inbound-message', listener)
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener('hub:inbound-message', listener)
|
||||
}
|
||||
},
|
||||
onConversationsChanged: (callback: () => void) => {
|
||||
const listener = (): void => {
|
||||
callback()
|
||||
}
|
||||
ipcRenderer.on('hub:conversations-changed', listener)
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener('hub:conversations-changed', listener)
|
||||
}
|
||||
},
|
||||
offInboundMessage: () => {
|
||||
ipcRenderer.removeAllListeners('hub:inbound-message')
|
||||
},
|
||||
},
|
||||
|
||||
// Tools management
|
||||
tools: {
|
||||
list: (): Promise<ToolInfo[]> => ipcRenderer.invoke('tools:list'),
|
||||
toggle: (name: string) => ipcRenderer.invoke('tools:toggle', name),
|
||||
setStatus: (name: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke('tools:setStatus', name, enabled),
|
||||
active: () => ipcRenderer.invoke('tools:active'),
|
||||
reload: () => ipcRenderer.invoke('tools:reload'),
|
||||
},
|
||||
|
||||
// Skills management
|
||||
skills: {
|
||||
list: (): Promise<SkillInfo[]> => ipcRenderer.invoke('skills:list'),
|
||||
get: (id: string) => ipcRenderer.invoke('skills:get', id),
|
||||
toggle: (id: string) => ipcRenderer.invoke('skills:toggle', id),
|
||||
setStatus: (id: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke('skills:setStatus', id, enabled),
|
||||
reload: () => ipcRenderer.invoke('skills:reload'),
|
||||
add: (source: string, options?: { name?: string; force?: boolean }) =>
|
||||
ipcRenderer.invoke('skills:add', source, options),
|
||||
remove: (name: string) => ipcRenderer.invoke('skills:remove', name),
|
||||
},
|
||||
|
||||
// Agent management
|
||||
agent: {
|
||||
status: () => ipcRenderer.invoke('agent:status'),
|
||||
},
|
||||
|
||||
// Profile management
|
||||
profile: {
|
||||
get: (): Promise<ProfileData> => ipcRenderer.invoke('profile:get'),
|
||||
updateName: (name: string) => ipcRenderer.invoke('profile:updateName', name),
|
||||
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),
|
||||
/** Test a provider connection with a minimal prompt */
|
||||
test: (providerId: string, modelId?: string): Promise<{ ok: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('provider:test', providerId, modelId),
|
||||
},
|
||||
|
||||
// Channel management (Telegram, Discord, etc.)
|
||||
channels: {
|
||||
/** List all channel account states */
|
||||
listStates: () => ipcRenderer.invoke('channels:listStates'),
|
||||
/** Get channels config from credentials.json5 */
|
||||
getConfig: () => ipcRenderer.invoke('channels:getConfig'),
|
||||
/** Save a channel token and start the bot immediately */
|
||||
saveToken: (channelId: string, accountId: string, token: string) =>
|
||||
ipcRenderer.invoke('channels:saveToken', channelId, accountId, token),
|
||||
/** Remove a channel token and stop the bot */
|
||||
removeToken: (channelId: string, accountId: string) =>
|
||||
ipcRenderer.invoke('channels:removeToken', channelId, accountId),
|
||||
/** Stop a channel account */
|
||||
stop: (channelId: string, accountId: string) =>
|
||||
ipcRenderer.invoke('channels:stop', channelId, accountId),
|
||||
/** Start a channel account from saved config */
|
||||
start: (channelId: string, accountId: string) =>
|
||||
ipcRenderer.invoke('channels:start', channelId, accountId),
|
||||
},
|
||||
|
||||
// Cron jobs management
|
||||
cron: {
|
||||
list: () => ipcRenderer.invoke('cron:list'),
|
||||
toggle: (jobId: string) => ipcRenderer.invoke('cron:toggle', jobId),
|
||||
remove: (jobId: string) => ipcRenderer.invoke('cron:remove', jobId),
|
||||
},
|
||||
|
||||
heartbeat: {
|
||||
last: () => ipcRenderer.invoke('heartbeat:last'),
|
||||
setEnabled: (enabled: boolean) => ipcRenderer.invoke('heartbeat:setEnabled', enabled),
|
||||
wake: (reason?: string) => ipcRenderer.invoke('heartbeat:wake', reason),
|
||||
},
|
||||
|
||||
// Auto-update
|
||||
update: {
|
||||
/** Check for updates */
|
||||
check: () => ipcRenderer.invoke('update:check'),
|
||||
/** Download the available update */
|
||||
download: () => ipcRenderer.invoke('update:download'),
|
||||
/** Quit and install the downloaded update */
|
||||
install: () => ipcRenderer.invoke('update:install'),
|
||||
/** Listen for update status changes (returns unsubscribe function) */
|
||||
onStatus: (callback: (status: { status: string; info?: unknown; progress?: unknown; error?: string }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, status: Parameters<typeof callback>[0]): void => {
|
||||
callback(status)
|
||||
}
|
||||
ipcRenderer.on('update:status', listener)
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener('update:status', listener)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Local chat (direct IPC, no Gateway required)
|
||||
localChat: {
|
||||
/** Subscribe to conversation events for local direct chat */
|
||||
subscribe: (conversationId: string) => ipcRenderer.invoke('localChat:subscribe', conversationId),
|
||||
/** Unsubscribe from conversation events */
|
||||
unsubscribe: (conversationId: string, token?: number) => ipcRenderer.invoke('localChat:unsubscribe', conversationId, token),
|
||||
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
|
||||
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) =>
|
||||
ipcRenderer.invoke('localChat:getHistory', conversationId, options),
|
||||
/** Send message to conversation via direct IPC (no Gateway) */
|
||||
send: (conversationId: string, content: string) =>
|
||||
ipcRenderer.invoke('localChat:send', conversationId, content),
|
||||
/** Abort the current agent run */
|
||||
abort: (conversationId: string) =>
|
||||
ipcRenderer.invoke('localChat:abort', conversationId),
|
||||
/** Resolve an exec approval request */
|
||||
resolveExecApproval: (approvalId: string, decision: string) =>
|
||||
ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision),
|
||||
/** Listen for agent events */
|
||||
onEvent: (callback: (event: LocalChatEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: LocalChatEvent): void => {
|
||||
callback(data)
|
||||
}
|
||||
ipcRenderer.on('localChat:event', listener)
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener('localChat:event', listener)
|
||||
}
|
||||
},
|
||||
/** Remove event listener */
|
||||
offEvent: () => {
|
||||
ipcRenderer.removeAllListeners('localChat:event')
|
||||
},
|
||||
/** Listen for exec approval requests */
|
||||
onApproval: (callback: (approval: LocalChatApproval) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: LocalChatApproval): void => {
|
||||
callback(data)
|
||||
}
|
||||
ipcRenderer.on('localChat:approval', listener)
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener('localChat:approval', listener)
|
||||
}
|
||||
},
|
||||
/** Remove approval listener */
|
||||
offApproval: () => {
|
||||
ipcRenderer.removeAllListeners('localChat:approval')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Expose to renderer
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
||||
|
||||
// Also expose ipcRenderer for backward compatibility
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
on(...args: Parameters<typeof ipcRenderer.on>) {
|
||||
const [channel, listener] = args
|
||||
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args))
|
||||
},
|
||||
off(...args: Parameters<typeof ipcRenderer.off>) {
|
||||
const [channel, ...omit] = args
|
||||
return ipcRenderer.off(channel, ...omit)
|
||||
},
|
||||
send(...args: Parameters<typeof ipcRenderer.send>) {
|
||||
const [channel, ...omit] = args
|
||||
return ipcRenderer.send(channel, ...omit)
|
||||
},
|
||||
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
||||
const [channel, ...omit] = args
|
||||
return ipcRenderer.invoke(channel, ...omit)
|
||||
},
|
||||
})
|
||||
|
||||
// Type declaration for window object
|
||||
export type ElectronAPI = typeof electronAPI
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multica</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
/* Use system fonts for proper CJK punctuation support */
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-geist-mono: "Geist Mono", ui-monospace, monospace;
|
||||
--font-brand: "Playfair Display", serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom'
|
||||
import { ThemeProvider } from './components/theme-provider'
|
||||
import { TooltipProvider } from '@multica/ui/components/ui/tooltip'
|
||||
import { Toaster } from './components/toaster'
|
||||
import Layout from './pages/layout'
|
||||
import HomePage from './pages/home'
|
||||
import ProfilePage from './pages/agent/profile'
|
||||
import SkillsPage from './pages/agent/skills'
|
||||
import ToolsPage from './pages/agent/tools'
|
||||
import ClientsPage from './pages/clients'
|
||||
import CronsPage from './pages/crons'
|
||||
import OnboardingPage from './pages/onboarding'
|
||||
import LoginPage from './pages/login'
|
||||
import { useOnboardingStore } from './stores/onboarding'
|
||||
import { useHubStore } from './stores/hub'
|
||||
import { useProviderStore } from './stores/provider'
|
||||
import { useChannelsStore } from './stores/channels'
|
||||
import { useSkillsStore } from './stores/skills'
|
||||
import { useToolsStore } from './stores/tools'
|
||||
import { useCronJobsStore } from './stores/cron-jobs'
|
||||
import { useAuthStore, setupAuthCallbackListener } from './stores/auth'
|
||||
|
||||
// Auth guard - redirects to login if not authenticated
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex h-screen items-center justify-center bg-background" />
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||
const completed = useOnboardingStore((s) => s.completed)
|
||||
if (!completed) return <Navigate to="/onboarding" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/onboarding',
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<OnboardingPage />
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<Layout />
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<OnboardingGuard>
|
||||
<HomePage />
|
||||
</OnboardingGuard>
|
||||
),
|
||||
},
|
||||
{ path: 'chat', element: null },
|
||||
{ path: 'agent/profile', element: <ProfilePage /> },
|
||||
{ path: 'agent/skills', element: <SkillsPage /> },
|
||||
{ path: 'agent/tools', element: <ToolsPage /> },
|
||||
{ path: 'clients', element: <ClientsPage /> },
|
||||
{ path: 'crons', element: <CronsPage /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
export default function App() {
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
const setCompleted = useOnboardingStore((s) => s.setCompleted)
|
||||
|
||||
useEffect(() => {
|
||||
// Setup auth callback listener BEFORE async operations
|
||||
// This ensures cleanup works correctly in React Strict Mode
|
||||
const cleanupAuth = setupAuthCallbackListener()
|
||||
|
||||
async function hydrateState() {
|
||||
try {
|
||||
// Load auth state first
|
||||
await useAuthStore.getState().loadAuth()
|
||||
|
||||
// Load onboarding state
|
||||
const completed = await window.electronAPI.appState.getOnboardingCompleted()
|
||||
setCompleted(completed)
|
||||
} catch (err) {
|
||||
console.error('[App] Failed to hydrate state:', err)
|
||||
setCompleted(false)
|
||||
} finally {
|
||||
setIsHydrated(true)
|
||||
}
|
||||
}
|
||||
|
||||
hydrateState()
|
||||
|
||||
useHubStore.getState().init()
|
||||
useProviderStore.getState().fetch()
|
||||
useChannelsStore.getState().fetch()
|
||||
useSkillsStore.getState().fetch()
|
||||
useToolsStore.getState().fetch()
|
||||
useCronJobsStore.getState().fetch()
|
||||
|
||||
return () => {
|
||||
cleanupAuth()
|
||||
}
|
||||
}, [setCompleted])
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
|
||||
<div className="h-dvh bg-background" />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
|
||||
<TooltipProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="bottom-right" />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { useState, useEffect } 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 { Textarea } from '@multica/ui/components/ui/textarea'
|
||||
import { Label } from '@multica/ui/components/ui/label'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface AgentSettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [userContent, setUserContent] = useState('')
|
||||
|
||||
// Load profile data when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadProfile()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const loadProfile = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await window.electronAPI.profile.get()
|
||||
setName(data.name ?? '')
|
||||
setUserContent(data.userContent ?? '')
|
||||
} catch (err) {
|
||||
console.error('Failed to load profile:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Update name if changed
|
||||
await window.electronAPI.profile.updateName(name)
|
||||
// Update user content
|
||||
await window.electronAPI.profile.updateUser(userContent)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to save profile:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Customize your agent's name and personal settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">Name</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Assistant"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Content */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-content">About You</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Help the agent understand you better. Share your preferences, role, or any context.
|
||||
</p>
|
||||
<Textarea
|
||||
id="user-content"
|
||||
value={userContent}
|
||||
onChange={(e) => setUserContent(e.target.value)}
|
||||
placeholder="- I'm a frontend developer - I prefer TypeScript - Please respond in Chinese"
|
||||
className="min-h-[120px] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || saving}>
|
||||
{saving && <Loader2 className="size-4 animate-spin mr-2" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentSettingsDialog
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
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 { Loader2, Key, Check } from 'lucide-react'
|
||||
|
||||
type Phase = 'input' | 'saving' | 'testing' | 'success' | 'error'
|
||||
|
||||
interface ApiKeyDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
providerId: string
|
||||
providerName: string
|
||||
showModelInput?: boolean
|
||||
onSuccess?: (modelId?: string) => void
|
||||
}
|
||||
|
||||
export function ApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
providerId,
|
||||
providerName,
|
||||
showModelInput,
|
||||
onSuccess,
|
||||
}: ApiKeyDialogProps) {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [modelId, setModelId] = useState('')
|
||||
const [phase, setPhase] = useState<Phase>('input')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const busy = phase === 'saving' || phase === 'testing'
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setError('API key is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (showModelInput && !modelId.trim()) {
|
||||
setError('Please enter a model name')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setPhase('saving')
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.provider.saveApiKey(providerId, apiKey.trim())
|
||||
if (!result.ok) {
|
||||
setError(result.error ?? 'Failed to save API key')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
setPhase('testing')
|
||||
const effectiveModel = showModelInput && modelId.trim() ? modelId.trim() : undefined
|
||||
const testResult = await window.electronAPI.provider.test(providerId, effectiveModel)
|
||||
|
||||
if (!testResult.ok) {
|
||||
setError(testResult.error ?? 'Connection test failed')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('success')
|
||||
// Auto-close after brief success display
|
||||
setTimeout(() => {
|
||||
setApiKey('')
|
||||
setModelId('')
|
||||
setPhase('input')
|
||||
setError(null)
|
||||
onOpenChange(false)
|
||||
onSuccess?.(effectiveModel)
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(message)
|
||||
setPhase('error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(null)
|
||||
setPhase('input')
|
||||
}
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen && !busy) {
|
||||
setApiKey('')
|
||||
setModelId('')
|
||||
setPhase('input')
|
||||
setError(null)
|
||||
}
|
||||
if (!busy) {
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="size-5" />
|
||||
Configure {providerName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your API key to enable {providerName}. The key will be saved and tested automatically.
|
||||
</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-..."
|
||||
disabled={busy || phase === 'success'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !busy) {
|
||||
handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showModelInput && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-id">Model</Label>
|
||||
<Input
|
||||
id="model-id"
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
placeholder="e.g. anthropic/claude-sonnet-4-5"
|
||||
disabled={busy || phase === 'success'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status messages */}
|
||||
{phase === 'saving' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Saving API key...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'testing' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Testing connection...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'success' && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="size-4" />
|
||||
Connected successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{phase === 'error' ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => handleClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={busy || phase === 'success'}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={busy || phase === 'success' || !apiKey.trim() || (showModelInput && !modelId.trim())}
|
||||
>
|
||||
Save & Test
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiKeyDialog
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { Switch } from '@multica/ui/components/ui/switch'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import {
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import type { CronJobInfo } from '../stores/cron-jobs'
|
||||
|
||||
interface CronJobListProps {
|
||||
jobs: CronJobInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onToggleJob: (jobId: string) => Promise<void>
|
||||
onRemoveJob: (jobId: string) => Promise<void>
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
no runs
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const config = {
|
||||
ok: { Icon: CheckCircle, className: 'text-emerald-600', label: 'ok' },
|
||||
error: { Icon: XCircle, className: 'text-destructive', label: 'error' },
|
||||
skipped: { Icon: AlertCircle, className: 'text-yellow-600', label: 'skipped' },
|
||||
}[status]
|
||||
|
||||
return (
|
||||
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
|
||||
<config.Icon className="size-3.5" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
const now = Date.now()
|
||||
const diffMs = date.getTime() - now
|
||||
|
||||
if (Math.abs(diffMs) < 60_000) return 'just now'
|
||||
|
||||
const absMs = Math.abs(diffMs)
|
||||
const minutes = Math.floor(absMs / 60_000)
|
||||
const hours = Math.floor(absMs / 3_600_000)
|
||||
const days = Math.floor(absMs / 86_400_000)
|
||||
|
||||
const unit = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
|
||||
return diffMs > 0 ? `in ${unit}` : `${unit} ago`
|
||||
}
|
||||
|
||||
export function CronJobList({
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
onToggleJob,
|
||||
onRemoveJob,
|
||||
onRefresh,
|
||||
}: CronJobListProps) {
|
||||
const [togglingJobs, setTogglingJobs] = useState<Set<string>>(new Set())
|
||||
const [removingJobs, setRemovingJobs] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleToggle = async (jobId: string) => {
|
||||
setTogglingJobs((prev) => new Set(prev).add(jobId))
|
||||
try {
|
||||
await onToggleJob(jobId)
|
||||
} finally {
|
||||
setTogglingJobs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(jobId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (jobId: string) => {
|
||||
setRemovingJobs((prev) => new Set(prev).add(jobId))
|
||||
try {
|
||||
await onRemoveJob(jobId)
|
||||
} finally {
|
||||
setRemovingJobs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(jobId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && jobs.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="gap-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-4" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{jobs.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Clock className="size-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Use the cron tool in Chat to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job list */}
|
||||
{jobs.length > 0 && (
|
||||
<div className="border rounded-lg divide-y">
|
||||
{jobs.map((job) => {
|
||||
const isToggling = togglingJobs.has(job.id)
|
||||
const isRemoving = removingJobs.has(job.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{job.name}</span>
|
||||
<StatusBadge status={job.lastStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule}</span>
|
||||
{job.nextRunAt && job.enabled && (
|
||||
<span>next: {formatRelativeTime(job.nextRunAt)}</span>
|
||||
)}
|
||||
{job.lastRunAt && (
|
||||
<span>last: {formatRelativeTime(job.lastRunAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{job.lastError && (
|
||||
<p className="text-xs text-destructive mt-0.5 truncate">{job.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(job.id)}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
{isRemoving ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isToggling && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={() => handleToggle(job.id)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CronJobList
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@multica/ui/components/ui/alert-dialog'
|
||||
import { parseUserAgent } from '../lib/parse-user-agent'
|
||||
|
||||
interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
clientName?: string
|
||||
}
|
||||
|
||||
interface PendingConfirm {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* Device confirmation dialog — shown when a new device tries to connect via QR code.
|
||||
* Listens for 'hub:device-confirm-request' IPC events from the main process,
|
||||
* shows an AlertDialog, and sends the user's response back.
|
||||
*/
|
||||
export function DeviceConfirmDialog() {
|
||||
const [pending, setPending] = useState<PendingConfirm | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest(
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, agentId, conversationId, meta })
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDeviceConfirmRequest()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAllow = useCallback(() => {
|
||||
if (!pending) return
|
||||
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, true)
|
||||
setPending(null)
|
||||
}, [pending])
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (!pending) return
|
||||
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, false)
|
||||
setPending(null)
|
||||
}, [pending])
|
||||
|
||||
const parsed = pending?.meta?.userAgent
|
||||
? parseUserAgent(pending.meta.userAgent)
|
||||
: null
|
||||
|
||||
const deviceLabel = pending?.meta?.clientName
|
||||
? pending.meta.clientName
|
||||
: parsed
|
||||
? `${parsed.browser} on ${parsed.os}`
|
||||
: pending?.deviceId
|
||||
|
||||
return (
|
||||
<AlertDialog open={pending !== null}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>New Device Connection</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium">{deviceLabel}</span> wants to connect.
|
||||
{parsed && (
|
||||
<span className="block text-xs font-mono text-muted-foreground truncate mt-1">
|
||||
{pending?.deviceId}
|
||||
</span>
|
||||
)}
|
||||
<span className="block mt-1">Allow this device?</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleReject}>
|
||||
Reject
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleAllow}>
|
||||
Allow
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import {
|
||||
Smartphone,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
} from 'lucide-react'
|
||||
import { useDevices, type DeviceEntry } from '../hooks/use-devices'
|
||||
import { parseUserAgent } from '../lib/parse-user-agent'
|
||||
|
||||
// ============ Relative Time ============
|
||||
|
||||
function relativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
|
||||
const months = Math.floor(days / 30)
|
||||
return `${months}mo ago`
|
||||
}
|
||||
|
||||
// ============ Component ============
|
||||
|
||||
function DeviceItem({
|
||||
device,
|
||||
onRevoke,
|
||||
}: {
|
||||
device: DeviceEntry
|
||||
onRevoke: (deviceId: string) => Promise<boolean>
|
||||
}) {
|
||||
const [revoking, setRevoking] = useState(false)
|
||||
|
||||
const parsed = device.meta?.userAgent
|
||||
? parseUserAgent(device.meta.userAgent)
|
||||
: null
|
||||
|
||||
const displayName = device.meta?.clientName
|
||||
? device.meta.clientName
|
||||
: parsed
|
||||
? `${parsed.browser} on ${parsed.os}`
|
||||
: device.deviceId
|
||||
|
||||
const handleRevoke = async () => {
|
||||
setRevoking(true)
|
||||
try {
|
||||
await onRevoke(device.deviceId)
|
||||
} finally {
|
||||
setRevoking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/20 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<Smartphone className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono truncate max-w-[180px]">{device.deviceId}</span>
|
||||
<span>·</span>
|
||||
<span>{relativeTime(device.addedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={handleRevoke}
|
||||
disabled={revoking}
|
||||
>
|
||||
{revoking ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeviceList() {
|
||||
const { devices, loading, refreshing, refresh, revokeDevice } = useDevices()
|
||||
|
||||
if (loading) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-8">
|
||||
<Smartphone className="size-8 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No devices connected yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Verified Devices ({devices.length})
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-3" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg divide-y overflow-hidden">
|
||||
{devices.map((device) => (
|
||||
<DeviceItem
|
||||
key={device.deviceId}
|
||||
device={device}
|
||||
onRevoke={revokeDevice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loading } from '@multica/ui/components/ui/loading'
|
||||
import { ChatView } from '@multica/ui/components/chat-view'
|
||||
import { useLocalChat } from '../hooks/use-local-chat'
|
||||
import { useProviderStore } from '../stores/provider'
|
||||
import { ApiKeyDialog } from './api-key-dialog'
|
||||
import { OAuthDialog } from './oauth-dialog'
|
||||
import { QueuedMessageBar } from './queued-message-bar'
|
||||
|
||||
interface LocalChatProps {
|
||||
initialPrompt?: string
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
export function LocalChat({ initialPrompt, conversationId }: LocalChatProps) {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
conversationId: activeConversationId,
|
||||
initError,
|
||||
messages,
|
||||
streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
contextWindowTokens,
|
||||
error,
|
||||
pendingApprovals,
|
||||
queuedMessages,
|
||||
sendMessage,
|
||||
abortGeneration,
|
||||
removeQueuedMessage,
|
||||
clearQueuedMessages,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
clearError,
|
||||
} = useLocalChat({ conversationId })
|
||||
|
||||
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore()
|
||||
|
||||
// Provider config dialog state
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
|
||||
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
|
||||
|
||||
const handleConfigureProvider = useCallback(() => {
|
||||
const providerId = current?.provider
|
||||
if (!providerId) return
|
||||
|
||||
const meta = providers.find((p) => p.id === providerId)
|
||||
if (!meta) return
|
||||
|
||||
if (meta.authMethod === 'oauth') {
|
||||
setOauthDialogOpen(true)
|
||||
} else {
|
||||
setApiKeyDialogOpen(true)
|
||||
}
|
||||
}, [current, providers])
|
||||
|
||||
const handleProviderConfigSuccess = useCallback(async () => {
|
||||
const providerId = current?.provider
|
||||
if (!providerId) return
|
||||
|
||||
await refreshProviders()
|
||||
await switchProvider(providerId)
|
||||
clearError()
|
||||
}, [current, refreshProviders, switchProvider, clearError])
|
||||
|
||||
// Derive provider info for dialogs
|
||||
const currentMeta = current ? providers.find((p) => p.id === current.provider) : null
|
||||
|
||||
// Auto-send initial prompt after a short delay
|
||||
const lastPromptRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (!activeConversationId || !initialPrompt) return
|
||||
if (initialPrompt === lastPromptRef.current) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
lastPromptRef.current = initialPrompt
|
||||
sendMessage(initialPrompt)
|
||||
// Remove prompt from URL to prevent re-sending on back navigation
|
||||
const nextPath = activeConversationId
|
||||
? `/chat?conversation=${encodeURIComponent(activeConversationId)}`
|
||||
: '/chat'
|
||||
navigate(nextPath, { replace: true })
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeConversationId, initialPrompt, sendMessage, navigate])
|
||||
|
||||
if (initError) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-destructive">
|
||||
{initError}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activeConversationId) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Initializing...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show "Configure" button when error is about provider/API key
|
||||
const errorAction = error?.code === 'AGENT_ERROR' && currentMeta
|
||||
? { label: 'Configure', onClick: handleConfigureProvider }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatView
|
||||
messages={messages}
|
||||
streamingIds={streamingIds}
|
||||
isLoading={isLoading}
|
||||
isLoadingHistory={isLoadingHistory}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
contextWindowTokens={contextWindowTokens}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
onAbort={abortGeneration}
|
||||
loadMore={loadMore}
|
||||
resolveApproval={resolveApproval}
|
||||
errorAction={errorAction}
|
||||
bottomSlot={
|
||||
<QueuedMessageBar
|
||||
messages={queuedMessages}
|
||||
isRunning={isLoading}
|
||||
onRemove={removeQueuedMessage}
|
||||
onClear={clearQueuedMessages}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{currentMeta && currentMeta.authMethod === 'api-key' && (
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
providerId={currentMeta.id}
|
||||
providerName={currentMeta.name}
|
||||
onSuccess={handleProviderConfigSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentMeta && currentMeta.authMethod === 'oauth' && (
|
||||
<OAuthDialog
|
||||
open={oauthDialogOpen}
|
||||
onOpenChange={setOauthDialogOpen}
|
||||
providerId={currentMeta.id}
|
||||
providerName={currentMeta.name}
|
||||
loginCommand={currentMeta.loginCommand}
|
||||
onSuccess={handleProviderConfigSuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Sun, Moon, Monitor } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu"
|
||||
import { useTheme } from "./theme-provider"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex items-center justify-center size-8 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Icon className="size-4" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="size-4" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="size-4" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="size-4" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
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 { Loader2, Terminal, RefreshCw, Check } from 'lucide-react'
|
||||
|
||||
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">
|
||||
<Terminal 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">
|
||||
<Check 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 ? (
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default OAuthDialog
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Switch } from '@multica/ui/components/ui/switch'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface AcknowledgementItemProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export function AcknowledgementItem({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: AcknowledgementItemProps) {
|
||||
return (
|
||||
<label className="flex items-start gap-4 p-4 rounded-xl border border-border bg-card cursor-pointer hover:bg-accent/30 transition-colors">
|
||||
<div className="mt-0.5 flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<p className="font-medium text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { Key, Database, Terminal } from 'lucide-react'
|
||||
|
||||
const privacyItems = [
|
||||
{
|
||||
icon: Database,
|
||||
title: 'Everything stays local',
|
||||
description:
|
||||
'All sessions, history, and profiles are stored on your device. Nothing leaves your computer.',
|
||||
},
|
||||
{
|
||||
icon: Key,
|
||||
title: 'Your data, your control',
|
||||
description:
|
||||
'API keys and credentials are saved locally in ~/.super-multica/. We never access them.',
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: 'Transparent execution',
|
||||
description:
|
||||
'Every shell command the agent wants to run requires your explicit approval first.',
|
||||
},
|
||||
]
|
||||
|
||||
export function PrivacyPanel() {
|
||||
return (
|
||||
<div className="rounded-2xl bg-muted/50 border border-border/50 p-6 space-y-5">
|
||||
{privacyItems.map((item) => (
|
||||
<div key={item.title} className="flex gap-3">
|
||||
<div className="mt-0.5 flex items-center justify-center size-7 rounded-lg bg-primary/10 shrink-0">
|
||||
<item.icon className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm text-primary">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
|
||||
interface ProviderSetupProps {
|
||||
providers: ProviderStatus[]
|
||||
loading: boolean
|
||||
activeProviderId?: string
|
||||
onConfigure: (provider: ProviderStatus) => void
|
||||
onSelect: (provider: ProviderStatus) => void
|
||||
onFocus?: (provider: ProviderStatus) => void
|
||||
}
|
||||
|
||||
const SUPPORTED_PROVIDERS = ['kimi-coding', 'claude-code', 'openai-codex', 'openrouter']
|
||||
|
||||
function ProviderCard({
|
||||
provider,
|
||||
isActive,
|
||||
onConfigure,
|
||||
onSelect,
|
||||
onFocus,
|
||||
}: {
|
||||
provider: ProviderStatus
|
||||
isActive: boolean
|
||||
onConfigure: (p: ProviderStatus) => void
|
||||
onSelect: (p: ProviderStatus) => void
|
||||
onFocus?: (p: ProviderStatus) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => provider.available && onSelect(provider)}
|
||||
onMouseEnter={() => onFocus?.(provider)}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl border bg-card transition-colors',
|
||||
provider.available && 'cursor-pointer hover:bg-accent/30',
|
||||
isActive
|
||||
? 'border-primary ring-1 ring-primary/20'
|
||||
: provider.available
|
||||
? 'border-primary/30'
|
||||
: 'border-border'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{provider.available ? (
|
||||
<div
|
||||
className={cn(
|
||||
'size-4 rounded-full border-2 shrink-0 flex items-center justify-center',
|
||||
isActive ? 'border-primary' : 'border-muted-foreground/30'
|
||||
)}
|
||||
>
|
||||
{isActive && <div className="size-2 rounded-full bg-primary" />}
|
||||
</div>
|
||||
) : (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{provider.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider.defaultModel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{provider.available ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="size-4 text-primary" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs text-muted-foreground h-auto py-1 px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigure(provider)
|
||||
}}
|
||||
>
|
||||
Reconfigure
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigure(provider)
|
||||
}}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderSetup({
|
||||
providers,
|
||||
loading,
|
||||
activeProviderId,
|
||||
onConfigure,
|
||||
onSelect,
|
||||
onFocus,
|
||||
}: ProviderSetupProps) {
|
||||
const filtered = SUPPORTED_PROVIDERS
|
||||
.map((id) => providers.find((p) => p.id === id))
|
||||
.filter((p): p is ProviderStatus => p != null)
|
||||
|
||||
if (loading && filtered.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 rounded-xl border border-border bg-card animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isActive={activeProviderId === provider.id}
|
||||
onConfigure={onConfigure}
|
||||
onSelect={onSelect}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
interface SamplePromptProps {
|
||||
title: string
|
||||
prompt: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function SamplePrompt({ title, prompt, onClick }: SamplePromptProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors group flex items-center justify-between"
|
||||
>
|
||||
<div className="space-y-0.5 pr-4 min-w-0">
|
||||
<p className="font-medium text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{prompt}</p>
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
const steps = [
|
||||
{ label: 'Privacy' },
|
||||
{ label: 'Provider' },
|
||||
{ label: 'Connect' },
|
||||
{ label: 'Try it' },
|
||||
]
|
||||
|
||||
interface StepperProps {
|
||||
currentStep: number // 1-based index
|
||||
}
|
||||
|
||||
export function Stepper({ currentStep }: StepperProps) {
|
||||
const currentIndex = currentStep - 1 // Convert to 0-based
|
||||
// Progress: 0% at step 0, 50% at step 1, 100% at step 2
|
||||
const progress = (currentIndex / (steps.length - 1)) * 100
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Step labels */}
|
||||
<nav className="flex items-center justify-center gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentIndex
|
||||
const isCurrent = index === currentIndex
|
||||
|
||||
return (
|
||||
<div key={step.label} className="flex items-center gap-3">
|
||||
{index > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isCompleted || isCurrent
|
||||
? 'text-muted-foreground'
|
||||
: 'text-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
›
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-sm transition-colors',
|
||||
isCurrent && 'text-foreground font-medium',
|
||||
isCompleted && 'text-foreground',
|
||||
!isCurrent && !isCompleted && 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{isCompleted && (
|
||||
<Check className="size-3.5 text-foreground" />
|
||||
)}
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 w-full bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
interface TutorialStepProps {
|
||||
number: number
|
||||
text: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
export function TutorialStep({ number, text, link }: TutorialStepProps) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center justify-center size-6 rounded-full bg-primary/10 text-primary text-xs font-medium shrink-0">
|
||||
{number}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{link ? (
|
||||
<>
|
||||
Go to{' '}
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
>
|
||||
{new URL(link).hostname}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { useQRToken, useCountdown } from './qr-hooks'
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export interface QRCodeData {
|
||||
type: 'multica-connect'
|
||||
gateway: string
|
||||
hubId: string
|
||||
agentId: string
|
||||
conversationId?: string
|
||||
token: string
|
||||
expires: number
|
||||
}
|
||||
|
||||
export interface ConnectionQRCodeProps {
|
||||
gateway: string
|
||||
hubId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
expirySeconds?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
// Hooks are in ./qr-hooks.ts (separate file for react-refresh compatibility)
|
||||
|
||||
/**
|
||||
* Hook for clipboard copy with feedback
|
||||
*/
|
||||
function useCopyToClipboard(timeout = 2000) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), timeout)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [timeout])
|
||||
|
||||
return { copied, copy }
|
||||
}
|
||||
|
||||
// ============ Components ============
|
||||
|
||||
/** Corner accent decoration */
|
||||
function CornerAccent({ position }: { position: 'tl' | 'tr' | 'bl' | 'br' }) {
|
||||
const positionClasses = {
|
||||
tl: '-top-2 -left-2 border-t-2 border-l-2 rounded-tl-lg',
|
||||
tr: '-top-2 -right-2 border-t-2 border-r-2 rounded-tr-lg',
|
||||
bl: '-bottom-2 -left-2 border-b-2 border-l-2 rounded-bl-lg',
|
||||
br: '-bottom-2 -right-2 border-b-2 border-r-2 rounded-br-lg',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute w-5 h-5 border-muted-foreground/30 ${positionClasses[position]}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** QR code frame with corner accents */
|
||||
export function QRCodeFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<CornerAccent position="tl" />
|
||||
<CornerAccent position="tr" />
|
||||
<CornerAccent position="bl" />
|
||||
<CornerAccent position="br" />
|
||||
<div className="bg-white p-3 rounded-lg">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Format seconds as M:SS */
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Expiry timer display */
|
||||
export function ExpiryTimer({ remaining }: { remaining: number }) {
|
||||
// Derive display state from remaining seconds (no extra state needed)
|
||||
const isWarning = remaining > 0 && remaining < 10
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
isWarning ? 'text-orange-500' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Expires in {formatTime(remaining)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Copy link button */
|
||||
function CopyLinkButton({ url }: { url: string }) {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5" onClick={() => copy(url)}>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Main Component ============
|
||||
|
||||
/**
|
||||
* ConnectionQRCode - QR code for mobile app connection
|
||||
*
|
||||
* Architecture:
|
||||
* - useQRToken: manages token generation and Hub registration
|
||||
* - useCountdown: handles timer with auto-refresh on expiry
|
||||
* - Pure child components for display (no state)
|
||||
*/
|
||||
export function ConnectionQRCode({
|
||||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId,
|
||||
expirySeconds = 30,
|
||||
size = 200,
|
||||
}: ConnectionQRCodeProps) {
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
|
||||
const remaining = useCountdown(expiresAt, refresh)
|
||||
|
||||
// Derive QR data and URL from current token (computed during render)
|
||||
const qrData: QRCodeData = useMemo(
|
||||
() => ({
|
||||
type: 'multica-connect',
|
||||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId,
|
||||
token,
|
||||
expires: expiresAt,
|
||||
}),
|
||||
[gateway, hubId, agentId, conversationId, token, expiresAt]
|
||||
)
|
||||
|
||||
const connectionUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
gateway,
|
||||
hub: hubId,
|
||||
agent: agentId,
|
||||
conversation: conversationId,
|
||||
token,
|
||||
exp: expiresAt.toString(),
|
||||
})
|
||||
return `multica://connect?${params.toString()}`
|
||||
}, [gateway, hubId, agentId, conversationId, token, expiresAt])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<QRCodeFrame>
|
||||
<QRCodeSVG
|
||||
value={JSON.stringify(qrData)}
|
||||
size={size}
|
||||
level="M"
|
||||
marginSize={0}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#0a0a0a"
|
||||
/>
|
||||
</QRCodeFrame>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ExpiryTimer remaining={remaining} />
|
||||
<CopyLinkButton url={connectionUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectionQRCode
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
/** Generate a secure random token */
|
||||
function generateToken(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage QR token lifecycle
|
||||
* - Generates token on mount
|
||||
* - Auto-refreshes when expired
|
||||
* - Registers token with Hub
|
||||
*/
|
||||
export function useQRToken(agentId: string, conversationId: string, expirySeconds: number) {
|
||||
const [token, setToken] = useState(generateToken)
|
||||
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
const newToken = generateToken()
|
||||
const newExpiry = Date.now() + expirySeconds * 1000
|
||||
setToken(newToken)
|
||||
setExpiresAt(newExpiry)
|
||||
window.electronAPI?.hub.registerToken(newToken, agentId, conversationId, newExpiry)
|
||||
}, [agentId, conversationId, expirySeconds])
|
||||
|
||||
// Register initial token
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.registerToken(token, agentId, conversationId, expiresAt)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { token, expiresAt, refresh }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for countdown timer
|
||||
* Returns remaining seconds, auto-updates every second
|
||||
*/
|
||||
export function useCountdown(expiresAt: number, onExpire: () => void) {
|
||||
const [remaining, setRemaining] = useState(() =>
|
||||
Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
|
||||
)
|
||||
const onExpireRef = useRef(onExpire)
|
||||
onExpireRef.current = onExpire
|
||||
|
||||
useEffect(() => {
|
||||
// Reset when expiresAt changes
|
||||
setRemaining(Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)))
|
||||
|
||||
const id = setInterval(() => {
|
||||
const next = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
|
||||
setRemaining(next)
|
||||
if (next === 0) onExpireRef.current()
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [expiresAt])
|
||||
|
||||
return remaining
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import type { QueuedLocalMessage } from '../hooks/use-local-chat'
|
||||
|
||||
interface QueuedMessageBarProps {
|
||||
messages: QueuedLocalMessage[]
|
||||
isRunning: boolean
|
||||
onRemove: (id: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function QueuedMessageBar({ messages, isRunning, onRemove, onClear }: QueuedMessageBarProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const statusText = isRunning
|
||||
? 'Agent is running. Queued messages will send automatically.'
|
||||
: 'Queued messages are being sent.'
|
||||
|
||||
if (messages.length === 0) return null
|
||||
const firstMessage = messages[0]
|
||||
const firstMessagePreview =
|
||||
firstMessage.text.length <= 120
|
||||
? firstMessage.text
|
||||
: `${firstMessage.text.slice(0, 120)}...`
|
||||
|
||||
return (
|
||||
<div className="container px-4 pb-2">
|
||||
<div className="rounded-lg border bg-muted/40">
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2 pb-1">
|
||||
<div className="text-xs font-medium text-foreground/80">
|
||||
{messages.length} queued message{messages.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 1 && (
|
||||
<button
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground">{statusText}</div>
|
||||
{expanded ? (
|
||||
<div className="px-2 pb-2">
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto pr-1">
|
||||
{messages.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-2 rounded-md bg-background/70 px-2 py-1.5">
|
||||
<div className="text-xs text-foreground/85 break-all">{item.text}</div>
|
||||
<button
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 pb-2 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2 rounded-md bg-background/70 px-2 py-1.5">
|
||||
<div className="text-xs text-foreground/85 break-all">{firstMessagePreview}</div>
|
||||
<button
|
||||
onClick={() => onRemove(firstMessage.id)}
|
||||
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{messages.length > 1 && (
|
||||
<div className="px-1 text-xs text-muted-foreground">
|
||||
+{messages.length - 1} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@multica/ui/components/ui/collapsible'
|
||||
import { RotateCw, Loader2, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import type { SkillInfo, SkillSource } from '../stores/skills'
|
||||
|
||||
// Source section titles
|
||||
const SOURCE_TITLES: Record<SkillSource, string> = {
|
||||
bundled: 'Built-in Skills',
|
||||
global: 'Global Skills',
|
||||
profile: 'Profile Skills',
|
||||
}
|
||||
|
||||
interface SkillListProps {
|
||||
skills: SkillInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onToggleSkill: (skillId: string) => Promise<void>
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function SkillList({
|
||||
skills,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
}: SkillListProps) {
|
||||
// Track which skills are expanded
|
||||
const [expandedSkills, setExpandedSkills] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleSkill = (skillId: string) => {
|
||||
setExpandedSkills((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(skillId)) {
|
||||
next.delete(skillId)
|
||||
} else {
|
||||
next.add(skillId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Group skills by source
|
||||
const skillsBySource: Record<SkillSource, SkillInfo[]> = {
|
||||
bundled: skills.filter((s) => s.source === 'bundled'),
|
||||
global: skills.filter((s) => s.source === 'global'),
|
||||
profile: skills.filter((s) => s.source === 'profile'),
|
||||
}
|
||||
|
||||
// Order of sources to display
|
||||
const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile']
|
||||
|
||||
if (loading && skills.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skills.length} skill{skills.length !== 1 && 's'} available
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="gap-1.5 h-8"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills grouped by source */}
|
||||
{sourceOrder.map((source) => {
|
||||
const sourceSkills = skillsBySource[source]
|
||||
if (sourceSkills.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={source}>
|
||||
{/* Section header */}
|
||||
<h3 className="text-sm font-medium mb-3">{SOURCE_TITLES[source]}</h3>
|
||||
|
||||
{/* Skills card */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
{sourceSkills.map((skill, index) => {
|
||||
const isExpanded = expandedSkills.has(skill.id)
|
||||
const isLast = index === sourceSkills.length - 1
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={skill.id}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleSkill(skill.id)}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left',
|
||||
!isLast && !isExpanded && 'border-b'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium flex-1">{skill.name}</span>
|
||||
<code className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono">
|
||||
{skill.triggers[0] || `/${skill.id}`}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{skill.version}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform flex-shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground p-4',
|
||||
!isLast && 'border-b'
|
||||
)}
|
||||
>
|
||||
{skill.description || 'No description'}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{skills.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
No skills found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SkillList
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useQRToken, useCountdown } from './qr-hooks'
|
||||
import { QRCodeFrame, ExpiryTimer } from './qr-code'
|
||||
|
||||
export interface TelegramConnectQRProps {
|
||||
gateway: string
|
||||
hubId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
expirySeconds?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram QR code for deep link connection flow.
|
||||
*
|
||||
* Generates a token, sends it to Gateway to create a short code,
|
||||
* then renders a QR encoding https://t.me/{botUsername}?start={code}.
|
||||
* Auto-refreshes when the token expires.
|
||||
*/
|
||||
export function TelegramConnectQR({
|
||||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId,
|
||||
expirySeconds = 30,
|
||||
size = 200,
|
||||
}: TelegramConnectQRProps) {
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
|
||||
const remaining = useCountdown(expiresAt, refresh)
|
||||
|
||||
const [deepLink, setDeepLink] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function fetchCode() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${gateway}/telegram/connect-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId,
|
||||
token,
|
||||
expires: expiresAt,
|
||||
}),
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { message?: string } | null
|
||||
const detail = body?.message || `HTTP ${res.status}`
|
||||
setError(`Gateway error (${res.status}): ${detail}`)
|
||||
setDeepLink(null)
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { code: string; botUsername: string }
|
||||
if (cancelled) return
|
||||
|
||||
setDeepLink(`https://t.me/${data.botUsername}?start=${data.code}`)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect to Gateway')
|
||||
setDeepLink(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchCode()
|
||||
return () => { cancelled = true }
|
||||
}, [token, expiresAt, gateway, hubId, agentId, conversationId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!deepLink) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<QRCodeFrame>
|
||||
<QRCodeSVG
|
||||
value={deepLink}
|
||||
size={size}
|
||||
level="M"
|
||||
marginSize={0}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#0a0a0a"
|
||||
/>
|
||||
</QRCodeFrame>
|
||||
|
||||
<ExpiryTimer remaining={remaining} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
resolvedTheme: "light" | "dark"
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "multica-theme",
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(
|
||||
() => (theme === "system" ? getSystemTheme() : theme)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme
|
||||
root.classList.add(resolved)
|
||||
setResolvedTheme(resolved)
|
||||
}, [theme])
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== "system") return
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handleChange = () => {
|
||||
const resolved = getSystemTheme()
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(resolved)
|
||||
setResolvedTheme(resolved)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange)
|
||||
return () => mediaQuery.removeEventListener("change", handleChange)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
return context
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||
import { CheckCircle, Info, AlertCircle, XCircle, Loader2 } from 'lucide-react'
|
||||
import { useTheme } from './theme-provider'
|
||||
|
||||
export function Toaster(props: ToasterProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={resolvedTheme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CheckCircle className="size-4 text-emerald-500" />,
|
||||
info: <Info className="size-4 text-blue-500" />,
|
||||
warning: <AlertCircle className="size-4 text-amber-500" />,
|
||||
error: <XCircle className="size-4 text-red-500" />,
|
||||
loading: <Loader2 className="size-4 text-muted-foreground animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import { Switch } from '@multica/ui/components/ui/switch'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@multica/ui/components/ui/collapsible'
|
||||
import {
|
||||
RotateCw,
|
||||
FolderOpen,
|
||||
Code,
|
||||
Globe,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Clock,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import type { ToolInfo } from '../stores/tools'
|
||||
|
||||
// Group display names
|
||||
const GROUP_NAMES: Record<string, string> = {
|
||||
fs: 'File System',
|
||||
runtime: 'Runtime',
|
||||
web: 'Web',
|
||||
subagent: 'Subagent',
|
||||
cron: 'Cron',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
// Group descriptions
|
||||
const GROUP_DESCRIPTIONS: Record<string, string> = {
|
||||
fs: 'Read, write, and manage files',
|
||||
runtime: 'Execute code and commands',
|
||||
web: 'Fetch and interact with web content',
|
||||
subagent: 'Delegate tasks to sub-agents',
|
||||
cron: 'Schedule recurring tasks',
|
||||
other: 'Miscellaneous tools',
|
||||
}
|
||||
|
||||
// Group icons
|
||||
const GROUP_ICONS: Record<string, typeof FolderOpen> = {
|
||||
fs: FolderOpen,
|
||||
runtime: Code,
|
||||
web: Globe,
|
||||
subagent: Users,
|
||||
cron: Clock,
|
||||
other: Code,
|
||||
}
|
||||
|
||||
interface ToolListProps {
|
||||
tools: ToolInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onToggleTool: (toolName: string) => Promise<void>
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function ToolList({
|
||||
tools,
|
||||
loading,
|
||||
error,
|
||||
onToggleTool,
|
||||
onRefresh,
|
||||
}: ToolListProps) {
|
||||
// Compute groups from tools
|
||||
const groups = useMemo(() => {
|
||||
const groupIds = [...new Set(tools.map((t) => t.group))]
|
||||
return groupIds.map((id) => ({
|
||||
id,
|
||||
name: GROUP_NAMES[id] || id,
|
||||
description: GROUP_DESCRIPTIONS[id] || '',
|
||||
tools: tools.filter((t) => t.group === id),
|
||||
enabledCount: tools.filter((t) => t.group === id && t.enabled).length,
|
||||
totalCount: tools.filter((t) => t.group === id).length,
|
||||
}))
|
||||
}, [tools])
|
||||
|
||||
// Track which groups are expanded
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
() => new Set(groups.map((g) => g.id))
|
||||
)
|
||||
|
||||
// Track toggling state for individual tools
|
||||
const [togglingTools, setTogglingTools] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId)
|
||||
} else {
|
||||
next.add(groupId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleTool = async (toolName: string) => {
|
||||
setTogglingTools((prev) => new Set(prev).add(toolName))
|
||||
try {
|
||||
await onToggleTool(toolName)
|
||||
} finally {
|
||||
setTogglingTools((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(toolName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && tools.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="gap-1.5 h-8"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool groups */}
|
||||
{groups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.id)
|
||||
const GroupIcon = GROUP_ICONS[group.id] || Code
|
||||
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{/* Section header */}
|
||||
<div className="mb-3 flex items-start gap-2">
|
||||
<GroupIcon className="size-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{group.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group.enabledCount}/{group.totalCount} enabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools card */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => toggleGroup(group.id)}>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors text-left">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{group.description}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform flex-shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{group.tools.map((tool, index) => {
|
||||
const isToggling = togglingTools.has(tool.name)
|
||||
const isLast = index === group.tools.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors',
|
||||
!isLast && 'border-b'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="text-sm font-mono">{tool.name}</code>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{isToggling && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => handleToggleTool(tool.name)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Note */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Changes apply immediately to the running Agent.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolList
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
/**
|
||||
* Update notification component
|
||||
* Shows when a new version is available and allows user to download/install
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Download,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string
|
||||
releaseDate?: string
|
||||
releaseNotes?: string | null
|
||||
}
|
||||
|
||||
interface UpdateProgress {
|
||||
percent: number
|
||||
bytesPerSecond: number
|
||||
total: number
|
||||
transferred: number
|
||||
}
|
||||
|
||||
interface UpdateStatus {
|
||||
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
|
||||
info?: UpdateInfo
|
||||
progress?: UpdateProgress
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function UpdateNotification(): React.JSX.Element | null {
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI.update.onStatus((status: UpdateStatus) => {
|
||||
setUpdateStatus(status)
|
||||
// Reset dismissed state when a new update becomes available
|
||||
if (status.status === 'available') {
|
||||
setDismissed(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe()
|
||||
}, [])
|
||||
|
||||
const handleDownload = async (): Promise<void> => {
|
||||
await window.electronAPI.update.download()
|
||||
}
|
||||
|
||||
const handleInstall = (): void => {
|
||||
window.electronAPI.update.install()
|
||||
}
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
// Don't show if dismissed or no relevant status
|
||||
if (dismissed) return null
|
||||
if (!updateStatus) return null
|
||||
if (updateStatus.status === 'checking' || updateStatus.status === 'not-available') return null
|
||||
|
||||
const version = updateStatus.info?.version
|
||||
const isError = updateStatus.status === 'error'
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-lg">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${isError ? 'bg-destructive/10' : 'bg-primary/10'}`}
|
||||
>
|
||||
{isError ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : updateStatus.status === 'downloaded' ? (
|
||||
<CheckCircle className="h-4 w-4 text-primary" />
|
||||
) : updateStatus.status === 'downloading' ? (
|
||||
<Loader2 className="h-4 w-4 text-primary animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{isError
|
||||
? 'Update failed'
|
||||
: updateStatus.status === 'downloaded'
|
||||
? 'Update ready'
|
||||
: updateStatus.status === 'downloading'
|
||||
? 'Downloading update...'
|
||||
: 'Update available'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isError
|
||||
? 'Please download manually from GitHub'
|
||||
: updateStatus.status === 'downloading' && updateStatus.progress
|
||||
? `${Math.round(updateStatus.progress.percent)}%`
|
||||
: version
|
||||
? `Version ${version}`
|
||||
: 'New version available'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{updateStatus.status === 'available' && (
|
||||
<Button size="sm" variant="default" onClick={handleDownload}>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus.status === 'downloaded' && (
|
||||
<Button size="sm" variant="default" onClick={handleInstall}>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{isError && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open('https://github.com/multica-ai/super-multica/releases', '_blank')
|
||||
}
|
||||
>
|
||||
View Releases
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus.status !== 'downloading' && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleDismiss}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { toast } from '@multica/ui/components/ui/sonner'
|
||||
|
||||
// Minimum loading time for user perception (ms)
|
||||
const MIN_LOADING_TIME = 600
|
||||
|
||||
export interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
clientName?: string
|
||||
}
|
||||
|
||||
export interface DeviceEntry {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationIds: string[]
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
export interface UseDevicesReturn {
|
||||
devices: DeviceEntry[]
|
||||
loading: boolean
|
||||
refreshing: boolean
|
||||
refresh: () => Promise<void>
|
||||
revokeDevice: (deviceId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useDevices(): UseDevicesReturn {
|
||||
const [devices, setDevices] = useState<DeviceEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Initial fetch (silent, no toast)
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
const list = await window.electronAPI?.hub.listDevices()
|
||||
setDevices((list as DeviceEntry[]) ?? [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load devices:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Manual refresh (with feedback)
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const list = await window.electronAPI?.hub.listDevices()
|
||||
|
||||
// Ensure minimum loading time for user perception
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_LOADING_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
|
||||
}
|
||||
|
||||
setDevices((list as DeviceEntry[]) ?? [])
|
||||
toast.success('Device list refreshed')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error('Failed to refresh devices', { description: message })
|
||||
console.error('Failed to refresh devices:', err)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const revokeDevice = useCallback(async (deviceId: string): Promise<boolean> => {
|
||||
try {
|
||||
const result = await window.electronAPI?.hub.revokeDevice(deviceId)
|
||||
if (result?.ok) {
|
||||
setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId))
|
||||
toast.success('Device removed')
|
||||
return true
|
||||
}
|
||||
toast.error('Failed to remove device')
|
||||
return false
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error('Failed to remove device', { description: message })
|
||||
console.error('Failed to revoke device:', err)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices()
|
||||
}, [fetchDevices])
|
||||
|
||||
// Subscribe to device list changes pushed from main process (silent refresh)
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDevicesChanged(() => {
|
||||
fetchDevices()
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDevicesChanged()
|
||||
}
|
||||
}, [fetchDevices])
|
||||
|
||||
return { devices, loading, refreshing, refresh, revokeDevice }
|
||||
}
|
||||
|
|
@ -1,371 +0,0 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useChat, type MessageSource } from '@multica/hooks/use-chat'
|
||||
import type {
|
||||
StreamPayload,
|
||||
ExecApprovalRequestPayload,
|
||||
ApprovalDecision,
|
||||
AgentMessageItem,
|
||||
AgentErrorEvent,
|
||||
} from '@multica/sdk'
|
||||
import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk'
|
||||
|
||||
export interface QueuedLocalMessage {
|
||||
id: string
|
||||
text: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface QueuedInboundMessage {
|
||||
content: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
source: MessageSource
|
||||
}
|
||||
|
||||
interface UseLocalChatOptions {
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
interface LocalChatSubscribeResult {
|
||||
ok?: boolean
|
||||
error?: string
|
||||
alreadySubscribed?: boolean
|
||||
token?: number
|
||||
isRunning?: boolean
|
||||
}
|
||||
|
||||
interface LocalChatHistoryResult {
|
||||
messages?: AgentMessageItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
contextWindowTokens?: number
|
||||
isRunning?: boolean
|
||||
}
|
||||
|
||||
function makeQueueId(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function useLocalChat(options: UseLocalChatOptions = {}) {
|
||||
const requestedConversationId = options.conversationId
|
||||
const chat = useChat()
|
||||
const chatRef = useRef(chat)
|
||||
chatRef.current = chat
|
||||
const [agentId, setAgentId] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const isLoadingRef = useRef(false)
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const isLoadingMoreRef = useRef(false)
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedLocalMessage[]>([])
|
||||
const [queuedInboundMessages, setQueuedInboundMessages] = useState<QueuedInboundMessage[]>([])
|
||||
const [initError, setInitError] = useState<string | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const offsetRef = useRef<number | null>(null)
|
||||
const activeConversationId = requestedConversationId ?? agentId
|
||||
|
||||
// Initialize hub and get default agent ID
|
||||
useEffect(() => {
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
|
||||
window.electronAPI.hub.init()
|
||||
.then((result) => {
|
||||
const r = result as { defaultConversationId?: string }
|
||||
const defaultConversationId = r.defaultConversationId
|
||||
console.log('[LocalChat] hub.init → defaultConversationId:', defaultConversationId)
|
||||
if (defaultConversationId) {
|
||||
setAgentId(defaultConversationId)
|
||||
} else if (requestedConversationId) {
|
||||
setAgentId(requestedConversationId)
|
||||
} else {
|
||||
setInitError('No default agent available')
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setInitError(err.message)
|
||||
setIsLoadingHistory(false)
|
||||
})
|
||||
}, [requestedConversationId])
|
||||
|
||||
// Subscribe to events + fetch history once conversation is available
|
||||
useEffect(() => {
|
||||
if (!activeConversationId) return
|
||||
let disposed = false
|
||||
setQueuedMessages([])
|
||||
setQueuedInboundMessages([])
|
||||
offsetRef.current = null
|
||||
setIsLoading(false)
|
||||
setIsLoadingHistory(true)
|
||||
chatRef.current.reset()
|
||||
|
||||
// Subscribe to agent events
|
||||
const subscribePromise = window.electronAPI.localChat.subscribe(activeConversationId)
|
||||
.then((result) => {
|
||||
const typed = result as LocalChatSubscribeResult
|
||||
if (!disposed && typed.isRunning) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
return typed
|
||||
})
|
||||
.catch(() => null)
|
||||
|
||||
// Listen for stream events
|
||||
const unsubscribeEvent = window.electronAPI.localChat.onEvent((data) => {
|
||||
// Cast IPC event to StreamPayload (same shape: { agentId, streamId, event })
|
||||
const payload = data as unknown as StreamPayload
|
||||
if (!payload.event) return
|
||||
if (payload.conversationId !== activeConversationId) return
|
||||
|
||||
// Handle agent error events
|
||||
if (payload.event.type === 'agent_error') {
|
||||
const errorEvent = payload.event as AgentErrorEvent
|
||||
chatRef.current.setError({ code: 'AGENT_ERROR', message: errorEvent.message })
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
chatRef.current.handleStream(payload)
|
||||
if (payload.event.type === 'message_start') setIsLoading(true)
|
||||
if (payload.event.type === 'tool_execution_start') setIsLoading(true)
|
||||
if (payload.event.type === 'message_end') {
|
||||
const stopReason =
|
||||
'message' in payload.event
|
||||
? (payload.event.message as { stopReason?: string } | undefined)?.stopReason
|
||||
: undefined
|
||||
|
||||
// message_end with stopReason=toolUse is an intermediate step in the same run.
|
||||
// Keep loading=true so queued user messages are not dispatched mid-run.
|
||||
if (stopReason === 'toolUse') {
|
||||
setIsLoading(true)
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for exec approval requests
|
||||
const unsubscribeApproval = window.electronAPI.localChat.onApproval((approval) => {
|
||||
if (approval.conversationId !== activeConversationId) return
|
||||
chatRef.current.addApproval(approval as ExecApprovalRequestPayload)
|
||||
})
|
||||
|
||||
// Listen for inbound messages from all sources (gateway, channel)
|
||||
// This allows the local UI to display messages from other sources
|
||||
const unsubscribeInbound = window.electronAPI.hub.onInboundMessage((event: InboundMessageEvent) => {
|
||||
const eventConversationId = event.conversationId
|
||||
// Only add non-local messages (local messages are added by sendMessage)
|
||||
if (event.source.type !== 'local' && eventConversationId === activeConversationId) {
|
||||
const queuedInbound: QueuedInboundMessage = {
|
||||
content: event.content,
|
||||
agentId: event.agentId,
|
||||
conversationId: eventConversationId,
|
||||
source: event.source as MessageSource,
|
||||
}
|
||||
if (isLoadingRef.current) {
|
||||
setQueuedInboundMessages((prev) => [...prev, queuedInbound])
|
||||
return
|
||||
}
|
||||
|
||||
chatRef.current.addUserMessage(
|
||||
queuedInbound.content,
|
||||
queuedInbound.agentId,
|
||||
queuedInbound.conversationId,
|
||||
queuedInbound.source,
|
||||
)
|
||||
setIsLoading(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch history with pagination
|
||||
window.electronAPI.localChat.getHistory(activeConversationId, {
|
||||
limit: DEFAULT_MESSAGES_LIMIT,
|
||||
})
|
||||
.then((result) => {
|
||||
if (disposed) return
|
||||
const typed = result as LocalChatHistoryResult
|
||||
if (typed.isRunning) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total)
|
||||
if (result.messages?.length) {
|
||||
chatRef.current.setHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
contextWindowTokens: result.contextWindowTokens,
|
||||
})
|
||||
offsetRef.current = result.offset
|
||||
} else {
|
||||
chatRef.current.setHistory([], activeConversationId, activeConversationId, {
|
||||
total: 0,
|
||||
offset: 0,
|
||||
contextWindowTokens: result.contextWindowTokens,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!disposed) {
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribeEvent?.()
|
||||
unsubscribeApproval?.()
|
||||
unsubscribeInbound?.()
|
||||
void subscribePromise
|
||||
.then((result) => {
|
||||
if (typeof result?.token !== 'number') return
|
||||
return window.electronAPI.localChat.unsubscribe(activeConversationId, result.token)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [activeConversationId])
|
||||
|
||||
useEffect(() => {
|
||||
isLoadingRef.current = isLoading
|
||||
}, [isLoading])
|
||||
|
||||
const dispatchMessageNow = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || !activeConversationId) return
|
||||
chatRef.current.addUserMessage(trimmed, activeConversationId, activeConversationId, { type: 'local' })
|
||||
chatRef.current.setError(null)
|
||||
setIsLoading(true)
|
||||
window.electronAPI.localChat.send(activeConversationId, trimmed)
|
||||
.then((result) => {
|
||||
const response = result as { ok?: boolean; error?: string } | undefined
|
||||
if (response?.error) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [activeConversationId])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || !activeConversationId) return
|
||||
|
||||
if (isLoadingRef.current) {
|
||||
setQueuedMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: makeQueueId(),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
dispatchMessageNow(trimmed)
|
||||
}, [activeConversationId, dispatchMessageNow])
|
||||
|
||||
const removeQueuedMessage = useCallback((id: string) => {
|
||||
setQueuedMessages((prev) => prev.filter((item) => item.id !== id))
|
||||
}, [])
|
||||
|
||||
const clearQueuedMessages = useCallback(() => {
|
||||
setQueuedMessages([])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeConversationId || isLoading) return
|
||||
|
||||
// Inbound channel/gateway messages are already queued in backend.
|
||||
// Render them first to keep frontend ordering aligned with agent run order.
|
||||
const nextInbound = queuedInboundMessages[0]
|
||||
if (nextInbound) {
|
||||
setQueuedInboundMessages((prev) => prev.slice(1))
|
||||
chatRef.current.addUserMessage(
|
||||
nextInbound.content,
|
||||
nextInbound.agentId,
|
||||
nextInbound.conversationId,
|
||||
nextInbound.source,
|
||||
)
|
||||
setIsLoading(true)
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocal = queuedMessages[0]
|
||||
if (!nextLocal) return
|
||||
setQueuedMessages((prev) => prev.slice(1))
|
||||
dispatchMessageNow(nextLocal.text)
|
||||
}, [activeConversationId, isLoading, queuedInboundMessages, queuedMessages, dispatchMessageNow])
|
||||
|
||||
const abortGeneration = useCallback(() => {
|
||||
if (!activeConversationId) return
|
||||
window.electronAPI.localChat.abort(activeConversationId).catch(() => {})
|
||||
setIsLoading(false)
|
||||
}, [activeConversationId])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const currentOffset = offsetRef.current
|
||||
if (!activeConversationId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return
|
||||
|
||||
isLoadingMoreRef.current = true
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT)
|
||||
const limit = currentOffset - newOffset
|
||||
const result = await window.electronAPI.localChat.getHistory(activeConversationId, {
|
||||
offset: newOffset,
|
||||
limit,
|
||||
})
|
||||
if (result.messages?.length) {
|
||||
chatRef.current.prependHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
contextWindowTokens: result.contextWindowTokens,
|
||||
})
|
||||
offsetRef.current = result.offset
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — pagination failure does not block chat
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}, [activeConversationId])
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(approvalId: string, decision: ApprovalDecision) => {
|
||||
chatRef.current.removeApproval(approvalId)
|
||||
window.electronAPI.localChat.resolveExecApproval(approvalId, decision).catch(() => {})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
chatRef.current.setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
agentId,
|
||||
conversationId: activeConversationId,
|
||||
initError,
|
||||
messages: chat.messages,
|
||||
streamingIds: chat.streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
hasMore: chat.hasMore,
|
||||
contextWindowTokens: chat.contextWindowTokens,
|
||||
error: chat.error,
|
||||
pendingApprovals: chat.pendingApprovals,
|
||||
queuedMessages,
|
||||
sendMessage,
|
||||
abortGeneration,
|
||||
removeQueuedMessage,
|
||||
clearQueuedMessages,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
export interface ParsedUA {
|
||||
browser: string
|
||||
os: string
|
||||
}
|
||||
|
||||
export function parseUserAgent(ua: string): ParsedUA {
|
||||
let os = 'Unknown'
|
||||
if (/Mac OS X/.test(ua)) os = 'macOS'
|
||||
else if (/Windows/.test(ua)) os = 'Windows'
|
||||
else if (/Android/.test(ua)) os = 'Android'
|
||||
else if (/iPhone|iPad/.test(ua)) os = 'iOS'
|
||||
else if (/CrOS/.test(ua)) os = 'ChromeOS'
|
||||
else if (/Linux/.test(ua)) os = 'Linux'
|
||||
|
||||
let browser = 'Unknown'
|
||||
const edgeMatch = ua.match(/Edg\/(\d+)/)
|
||||
const chromeMatch = ua.match(/Chrome\/(\d+)/)
|
||||
const safariMatch = ua.match(/Version\/(\d+).*Safari/)
|
||||
const firefoxMatch = ua.match(/Firefox\/(\d+)/)
|
||||
|
||||
if (edgeMatch) browser = `Edge ${edgeMatch[1]}`
|
||||
else if (firefoxMatch) browser = `Firefox ${firefoxMatch[1]}`
|
||||
else if (chromeMatch) browser = `Chrome ${chromeMatch[1]}`
|
||||
else if (safariMatch) browser = `Safari ${safariMatch[1]}`
|
||||
|
||||
return { browser, os }
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import "@multica/ui/fonts"
|
||||
import "@multica/ui/globals.css"
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import {
|
||||
MessageSquare,
|
||||
Link2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Pencil,
|
||||
ChevronDown,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
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 { useHubStore } from '../stores/hub'
|
||||
import { useProviderStore } from '../stores/provider'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const { hubInfo, agents, loading, error } = useHubStore()
|
||||
const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
|
||||
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(() => {
|
||||
loadAgentInfo()
|
||||
}, [])
|
||||
|
||||
// Reload agent info when settings dialog closes
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) {
|
||||
loadAgentInfo()
|
||||
}
|
||||
}, [settingsOpen])
|
||||
|
||||
const loadAgentInfo = async () => {
|
||||
try {
|
||||
const data = await window.electronAPI.profile.get()
|
||||
setAgentName(data.name)
|
||||
} catch (err) {
|
||||
console.error('Failed to load agent info:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the first agent (or create one if none exists)
|
||||
const primaryAgent = agents[0]
|
||||
|
||||
// Connection state indicator
|
||||
// Note: 'registered' means fully connected and registered with Gateway
|
||||
const connectionState = hubInfo?.connectionState ?? 'disconnected'
|
||||
const isConnected = connectionState === 'connected' || connectionState === 'registered'
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span>Connecting to Hub...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-destructive">
|
||||
<AlertCircle className="size-8" />
|
||||
<span className="font-medium">Connection Error</span>
|
||||
<span className="text-sm text-muted-foreground">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Main content - QR + Status */}
|
||||
<div className="flex-1 flex gap-8 p-2">
|
||||
{/* Left: QR Code */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<ConnectionQRCode
|
||||
gateway={hubInfo?.url ?? 'http://localhost:3000'}
|
||||
hubId={hubInfo?.hubId ?? 'unknown'}
|
||||
agentId={primaryAgent?.id}
|
||||
conversationId={primaryAgent?.id}
|
||||
expirySeconds={30}
|
||||
size={180}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Hub Status */}
|
||||
<div className="flex-1 flex flex-col justify-center">
|
||||
<div className="space-y-6">
|
||||
{/* Hub Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="relative flex size-2.5">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2.5 bg-green-500" />
|
||||
</>
|
||||
) : connectionState === 'connecting' || connectionState === 'reconnecting' ? (
|
||||
<>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2.5 bg-yellow-500" />
|
||||
</>
|
||||
) : (
|
||||
<span className="relative inline-flex rounded-full size-2.5 bg-red-500" />
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${
|
||||
isConnected
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: connectionState === 'connecting' || connectionState === 'reconnecting'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{isConnected
|
||||
? 'Hub Connected'
|
||||
: connectionState === 'connecting'
|
||||
? 'Connecting...'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'Reconnecting...'
|
||||
: 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Local Hub
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{hubInfo?.hubId ?? 'Initializing...'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Settings */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Agent Settings
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<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 ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle 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>
|
||||
<ChevronDown
|
||||
className={`size-4 text-muted-foreground transition-transform ${providerDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Provider Dropdown - Compact Grid + Model List */}
|
||||
{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 max-h-[60vh] overflow-y-auto">
|
||||
<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>
|
||||
|
||||
{/* Model List for current provider */}
|
||||
{(() => {
|
||||
const currentProvider = providers.find(p => p.id === current?.provider)
|
||||
if (!currentProvider || currentProvider.models.length <= 1) return null
|
||||
return (
|
||||
<div className="border-t border-border mt-2 pt-2">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider px-1 mb-1">
|
||||
Models — {currentProvider.name}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{currentProvider.models.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1 rounded text-left text-xs transition-colors ${
|
||||
model === current?.model
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'hover:bg-accent/50 text-muted-foreground'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
if (model === current?.model) return
|
||||
setSwitching(true)
|
||||
setProviderDropdownOpen(false)
|
||||
const result = await setProvider(currentProvider.id, model)
|
||||
setSwitching(false)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch model:', result.error)
|
||||
}
|
||||
}}
|
||||
disabled={switching}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full flex-shrink-0 ${
|
||||
model === current?.model ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
}`} />
|
||||
<span className="font-mono truncate">{model}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Gateway
|
||||
</p>
|
||||
<p className="font-medium text-sm truncate" title={hubInfo?.url}>
|
||||
{hubInfo?.url ?? '-'}
|
||||
</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">
|
||||
Connection
|
||||
</p>
|
||||
<p className="font-medium capitalize">{connectionState}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verified Devices */}
|
||||
<div className="px-4 pb-2">
|
||||
<DeviceList />
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Primary Action: Chat */}
|
||||
<Button
|
||||
size="lg"
|
||||
className="gap-2 px-6"
|
||||
onClick={() => navigate('/chat')}
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<MessageSquare className="size-5" />
|
||||
Open Chat
|
||||
</Button>
|
||||
|
||||
{/* Secondary: Connect to Remote */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground gap-1.5"
|
||||
>
|
||||
<Link2 className="size-4" />
|
||||
Connect to Remote Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Input } from '@multica/ui/components/ui/input'
|
||||
import { Textarea } from '@multica/ui/components/ui/textarea'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from '@multica/ui/components/ui/combobox'
|
||||
import {
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { ApiKeyDialog } from '../../components/api-key-dialog'
|
||||
import { OAuthDialog } from '../../components/oauth-dialog'
|
||||
import { useProviderStore } from '../../stores/provider'
|
||||
import { toast } from '@multica/ui/components/ui/sonner'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
|
||||
|
||||
const [profileLoading, setProfileLoading] = useState(true)
|
||||
const [name, setName] = useState('')
|
||||
const [userContent, setUserContent] = useState('')
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [originalName, setOriginalName] = useState('')
|
||||
const [originalUserContent, setOriginalUserContent] = useState('')
|
||||
|
||||
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)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(name !== originalName || userContent !== originalUserContent)
|
||||
}, [name, userContent, originalName, originalUserContent])
|
||||
|
||||
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])
|
||||
|
||||
const loadProfile = async () => {
|
||||
setProfileLoading(true)
|
||||
try {
|
||||
const data = await window.electronAPI.profile.get()
|
||||
const loadedName = data.name ?? ''
|
||||
const loadedUserContent = data.userContent ?? ''
|
||||
setName(loadedName)
|
||||
setUserContent(loadedUserContent)
|
||||
setOriginalName(loadedName)
|
||||
setOriginalUserContent(loadedUserContent)
|
||||
} catch (err) {
|
||||
console.error('Failed to load profile:', err)
|
||||
toast.error('Failed to load profile')
|
||||
} finally {
|
||||
setProfileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProfile = useCallback(async () => {
|
||||
try {
|
||||
await window.electronAPI.profile.updateName(name)
|
||||
await window.electronAPI.profile.updateUser(userContent)
|
||||
setOriginalName(name)
|
||||
setOriginalUserContent(userContent)
|
||||
setHasChanges(false)
|
||||
toast.success('Profile saved')
|
||||
} catch (err) {
|
||||
console.error('Failed to save profile:', err)
|
||||
toast.error('Failed to save profile')
|
||||
}
|
||||
}, [name, userContent])
|
||||
|
||||
// Keep ref to latest save function
|
||||
const saveRef = useRef(handleSaveProfile)
|
||||
saveRef.current = handleSaveProfile
|
||||
|
||||
// Show/hide persistent toast for unsaved changes
|
||||
useEffect(() => {
|
||||
const toastId = 'unsaved-changes'
|
||||
if (hasChanges) {
|
||||
toast('Unsaved changes', {
|
||||
id: toastId,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Save',
|
||||
onClick: () => saveRef.current()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
toast.dismiss(toastId)
|
||||
}
|
||||
}, [hasChanges])
|
||||
|
||||
const handleProviderClick = async (p: typeof providers[0]) => {
|
||||
if (!p.available) {
|
||||
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) {
|
||||
toast.error('Failed to switch provider')
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelSelect = async (model: string) => {
|
||||
if (!model || model === current?.model || !current?.provider) return
|
||||
setSwitching(true)
|
||||
const result = await setProvider(current.provider, model)
|
||||
setSwitching(false)
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to switch model')
|
||||
}
|
||||
}
|
||||
|
||||
if (profileLoading || providerLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentProvider = providers.find(p => p.id === current?.provider)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-lg font-medium">Profile</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your agent's identity and the model that powers it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Section */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-sm font-medium">Model</h2>
|
||||
<p className="text-xs text-muted-foreground">AI model for your agent</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* Provider Row */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" ref={dropdownRef}>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Provider</div>
|
||||
<div className="text-xs text-muted-foreground">LLM API connection</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
|
||||
disabled={switching}
|
||||
>
|
||||
{current?.available ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="size-3 text-yellow-500" />
|
||||
)}
|
||||
<span>{current?.providerName ?? 'Select'}</span>
|
||||
<ChevronDown className={cn(
|
||||
'size-3.5 text-muted-foreground transition-transform',
|
||||
providerDropdownOpen && 'rotate-180'
|
||||
)} />
|
||||
</Button>
|
||||
|
||||
{providerDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 z-10 bg-popover border border-border rounded-lg shadow-lg p-1.5 min-w-[200px]">
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded text-left text-sm transition-colors',
|
||||
p.id === current?.provider
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50',
|
||||
!p.available && 'opacity-50'
|
||||
)}
|
||||
onClick={() => handleProviderClick(p)}
|
||||
disabled={switching}
|
||||
>
|
||||
<span className={cn(
|
||||
'size-2 rounded-full flex-shrink-0',
|
||||
p.available ? 'bg-green-500' : 'bg-muted-foreground/40'
|
||||
)} />
|
||||
<span>{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Row - with Combobox */}
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Model</div>
|
||||
<div className="text-xs text-muted-foreground">Select or enter model ID</div>
|
||||
</div>
|
||||
<Combobox
|
||||
items={currentProvider?.models ?? []}
|
||||
value={current?.model ?? ''}
|
||||
onValueChange={(value) => {
|
||||
if (value) handleModelSelect(value)
|
||||
}}
|
||||
disabled={switching}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Select model"
|
||||
className="w-48 h-8 text-sm font-mono"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!current?.available && (
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
|
||||
Select a provider and add your API key to enable your agent.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Identity Section */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-sm font-medium">Identity</h2>
|
||||
<p className="text-xs text-muted-foreground">How your agent presents itself</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-sm font-medium mb-1">Agent Name</div>
|
||||
<div className="text-xs text-muted-foreground">Personalize interactions</div>
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Assistant"
|
||||
className="h-8 w-48 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Personalization Section */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-sm font-medium">Personalization</h2>
|
||||
<p className="text-xs text-muted-foreground">Help the agent understand you better</p>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={userContent}
|
||||
onChange={(e) => setUserContent(e.target.value)}
|
||||
placeholder="- I'm a frontend developer - I prefer TypeScript - Please respond in Chinese"
|
||||
className="min-h-[120px] font-mono text-sm"
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
providerId={selectedProvider.id}
|
||||
providerName={selectedProvider.name}
|
||||
onSuccess={async () => {
|
||||
await refresh()
|
||||
const result = await setProvider(selectedProvider.id)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
|
||||
<OAuthDialog
|
||||
open={oauthDialogOpen}
|
||||
onOpenChange={setOauthDialogOpen}
|
||||
providerId={selectedProvider.id}
|
||||
providerName={selectedProvider.name}
|
||||
loginCommand={selectedProvider.loginCommand}
|
||||
onSuccess={async () => {
|
||||
await refresh()
|
||||
const result = await setProvider(selectedProvider.id)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { useSkillsStore } from '../../stores/skills'
|
||||
import { SkillList } from '../../components/skill-list'
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-lg font-medium">Skills</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skills are modular capabilities that expand what your agent can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<SkillList
|
||||
skills={skills}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleSkill={toggleSkill}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { useToolsStore } from '../../stores/tools'
|
||||
import { ToolList } from '../../components/tool-list'
|
||||
|
||||
export default function ToolsPage() {
|
||||
const { tools, loading, error, toggleTool, refresh } = useToolsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-lg font-medium">Tools</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Toggle tools to control what your agent can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ToolList
|
||||
tools={tools}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleTool={toggleTool}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
import { LocalChat } from '../components/local-chat'
|
||||
|
||||
export default function ChatPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialPrompt = searchParams.get('prompt') ?? undefined
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<LocalChat initialPrompt={initialPrompt} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@multica/ui/components/ui/card'
|
||||
import { WifiOff, Loader2 } from 'lucide-react'
|
||||
import { useHubStore, selectPrimaryAgent } from '../stores/hub'
|
||||
import { TelegramConnectQR } from '../components/telegram-qr'
|
||||
import { DeviceList } from '../components/device-list'
|
||||
|
||||
function ChannelsContent() {
|
||||
const { hubInfo, agents } = useHubStore()
|
||||
const primaryAgent = selectPrimaryAgent(agents)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect messaging platforms to chat with your agent.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Telegram</CardTitle>
|
||||
<CardDescription>Scan with your phone camera to connect on Telegram.</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<TelegramConnectQR
|
||||
gateway={hubInfo?.url ?? 'http://localhost:3000'}
|
||||
hubId={hubInfo?.hubId ?? 'unknown'}
|
||||
agentId={primaryAgent?.id ?? 'unknown'}
|
||||
conversationId={primaryAgent?.id ?? 'unknown'}
|
||||
expirySeconds={30}
|
||||
size={200}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Discord and Slack coming soon.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Authorized Devices</CardTitle>
|
||||
<CardDescription>Devices you've approved to access your agent.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DeviceList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Gateway status indicator - only shows when disconnected/error */
|
||||
function GatewayStatus() {
|
||||
const { hubInfo } = useHubStore()
|
||||
const state = hubInfo?.connectionState ?? 'disconnected'
|
||||
const url = hubInfo?.url ?? 'Unknown'
|
||||
|
||||
// Only show when not connected
|
||||
const isConnected = state === 'connected' || state === 'registered'
|
||||
if (isConnected) return null
|
||||
|
||||
const isConnecting = state === 'connecting' || state === 'reconnecting'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm rounded-md bg-destructive/10 text-destructive px-3 py-2">
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<WifiOff className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{state === 'connecting' && 'Connecting to gateway...'}
|
||||
{state === 'reconnecting' && 'Reconnecting to gateway...'}
|
||||
{state === 'disconnected' && 'Gateway disconnected'}
|
||||
</span>
|
||||
<span className="text-destructive/60 font-mono text-xs truncate max-w-[200px]" title={url}>
|
||||
{url}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ClientsPage() {
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container flex flex-col p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Clients</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access your agent from anywhere. Connect via third-party platforms.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<GatewayStatus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChannelsContent />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { useCronJobsStore } from '../stores/cron-jobs'
|
||||
import { CronJobList } from '../components/cron-job-list'
|
||||
|
||||
export default function CronsPage() {
|
||||
const { jobs, loading, error, toggleJob, removeJob, refresh } = useCronJobsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container flex flex-col p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Scheduled Tasks</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scheduled tasks run automatically at set times. Ask your agent to create one, like "remind me every morning" or "check my inbox daily."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<CronJobList
|
||||
jobs={jobs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleJob={toggleJob}
|
||||
onRemoveJob={removeJob}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import {
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { useHubStore } from '../stores/hub'
|
||||
import { useProviderStore } from '../stores/provider'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const { loading } = useHubStore()
|
||||
const { current, loading: providerLoading } = useProviderStore()
|
||||
|
||||
const isProviderAvailable = current?.available ?? false
|
||||
const agentReady = !providerLoading && isProviderAvailable
|
||||
|
||||
if (loading || providerLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span>Starting agent...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="container shrink-0 px-6 pt-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Overview of your agent's status.</p>
|
||||
</div>
|
||||
|
||||
{/* Status Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-1 mt-4">
|
||||
<span className="relative flex size-2">
|
||||
{agentReady ? (
|
||||
<>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2 bg-green-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2 bg-yellow-500" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'font-medium',
|
||||
agentReady
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
)}>
|
||||
{agentReady ? 'Your agent is running' : 'Configure LLM provider to start'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{agentReady
|
||||
? 'Ready to assist you. Start a conversation to get things done.'
|
||||
: 'Go to Agent settings to configure your LLM provider.'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={() => navigate('/chat')}
|
||||
disabled={!agentReady}
|
||||
>
|
||||
Start Chat
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
|
||||
{!agentReady && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={() => navigate('/agent/profile')}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
Configure Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,417 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { MulticaIcon } from '@multica/ui/components/multica-icon'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@multica/ui/components/ui/collapsible'
|
||||
import {
|
||||
Home,
|
||||
MessageSquare,
|
||||
Users,
|
||||
Clock,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Bot,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@multica/ui/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@multica/ui/components/ui/sidebar'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { ModeToggle } from '../components/mode-toggle'
|
||||
import { LocalChat } from '../components/local-chat'
|
||||
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
|
||||
import { UpdateNotification } from '../components/update-notification'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useHubStore } from '../stores/hub'
|
||||
|
||||
const mainNavItems = [
|
||||
{ path: '/', label: 'Home', icon: Home, exact: true },
|
||||
{ path: '/chat', label: 'Chat', icon: MessageSquare },
|
||||
]
|
||||
|
||||
const agentSubItems = [
|
||||
{ path: '/agent/profile', label: 'Profile' },
|
||||
{ path: '/agent/skills', label: 'Skills' },
|
||||
{ path: '/agent/tools', label: 'Tools' },
|
||||
]
|
||||
|
||||
const bottomNavItems = [
|
||||
{ path: '/clients', label: 'Clients', icon: Users },
|
||||
{ path: '/crons', label: 'Scheduled Tasks', icon: Clock },
|
||||
]
|
||||
|
||||
// All nav items for header lookup
|
||||
const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact?: boolean }> = [
|
||||
...mainNavItems,
|
||||
{ path: '/agent', label: 'Agent', icon: Bot },
|
||||
...bottomNavItems,
|
||||
]
|
||||
|
||||
function shortConversationId(id: string): string {
|
||||
if (id.length <= 18) return id
|
||||
return `${id.slice(0, 8)}...${id.slice(-8)}`
|
||||
}
|
||||
|
||||
function NavigationButtons() {
|
||||
const navigate = useNavigate()
|
||||
useLocation()
|
||||
|
||||
const historyIdx = window.history.state?.idx ?? 0
|
||||
const canGoBack = historyIdx > 0
|
||||
const canGoForward = historyIdx < window.history.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 ml-auto mr-2"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => navigate(1)}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MainHeader() {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const location = useLocation()
|
||||
const needsTrafficLightSpace = state === 'collapsed' || isMobile
|
||||
|
||||
const currentPage = allNavItems.find((item) => {
|
||||
if (item.exact) return location.pathname === item.path
|
||||
return location.pathname.startsWith(item.path)
|
||||
})
|
||||
|
||||
return (
|
||||
<header
|
||||
className="h-12 shrink-0 flex items-center px-4"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full shrink-0 transition-[width] duration-200 ease-linear',
|
||||
needsTrafficLightSpace ? 'w-16' : 'w-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<SidebarTrigger
|
||||
className={cn(needsTrafficLightSpace && 'text-muted-foreground')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex justify-center">
|
||||
{currentPage && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<currentPage.icon className="size-4" />
|
||||
<span>{currentPage.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isAgentActive = location.pathname.startsWith('/agent')
|
||||
const isOnChat = location.pathname === '/chat'
|
||||
const { user, clearAuth } = useAuthStore()
|
||||
const { agents, refresh: refreshAgents, createConversation } = useHubStore()
|
||||
const [isCreatingConversation, setIsCreatingConversation] = useState(false)
|
||||
const [conversationError, setConversationError] = useState<string | null>(null)
|
||||
const selectedConversationId = isOnChat
|
||||
? new URLSearchParams(location.search).get('conversation') ?? undefined
|
||||
: undefined
|
||||
const activeConversationId = selectedConversationId ?? agents[0]?.id
|
||||
|
||||
// Lazy mount: only mount Chat on first visit, then keep it mounted forever
|
||||
const [chatMounted, setChatMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
if (isOnChat && !chatMounted) setChatMounted(true)
|
||||
}, [isOnChat, chatMounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnChat) return
|
||||
void refreshAgents()
|
||||
}, [isOnChat, refreshAgents])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnChat || !selectedConversationId || agents.length === 0) return
|
||||
const exists = agents.some((item) => item.id === selectedConversationId)
|
||||
if (!exists) {
|
||||
navigate('/chat', { replace: true })
|
||||
}
|
||||
}, [isOnChat, selectedConversationId, agents, navigate])
|
||||
|
||||
// Extract initialPrompt from URL search params when navigating to /chat?prompt=...
|
||||
const initialPrompt = isOnChat
|
||||
? new URLSearchParams(location.search).get('prompt') ?? undefined
|
||||
: undefined
|
||||
|
||||
const handleLogout = async () => {
|
||||
await clearAuth()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const openConversation = (id: string) => {
|
||||
if (!id) return
|
||||
navigate(`/chat?conversation=${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
const handleCreateConversation = async () => {
|
||||
setConversationError(null)
|
||||
setIsCreatingConversation(true)
|
||||
try {
|
||||
const created = await createConversation()
|
||||
if (!created?.id) {
|
||||
setConversationError('Failed to create session')
|
||||
return
|
||||
}
|
||||
openConversation(created.id)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setConversationError(message)
|
||||
} finally {
|
||||
setIsCreatingConversation(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<SidebarProvider className="flex-1 overflow-hidden">
|
||||
<Sidebar>
|
||||
<SidebarHeader
|
||||
className="h-12 shrink-0 flex items-center"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<NavigationButtons />
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<MulticaIcon bordered noSpin />
|
||||
<span className="text-sm font-brand">Multica</span>
|
||||
</div>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarMenu className="space-y-0.5">
|
||||
{/* Main nav items */}
|
||||
{mainNavItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.path
|
||||
: location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Agent collapsible */}
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger
|
||||
render={<SidebarMenuButton isActive={isAgentActive} />}
|
||||
>
|
||||
<Bot
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isAgentActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>Agent</span>
|
||||
<ChevronDown className="ml-auto size-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{agentSubItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.path}>
|
||||
<SidebarMenuSubButton
|
||||
render={<NavLink to={item.path} />}
|
||||
isActive={location.pathname === item.path}
|
||||
>
|
||||
{item.label}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
{/* Bottom nav items */}
|
||||
{bottomNavItems.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<SidebarMenuButton size="lg" />}
|
||||
>
|
||||
<div className="size-8 rounded-lg bg-muted flex items-center justify-center text-sm font-medium">
|
||||
{user?.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user?.name || 'User'}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user?.email || ''}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="end">
|
||||
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
||||
<LogOut className="size-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset className="overflow-hidden">
|
||||
<MainHeader />
|
||||
<main className="flex-1 overflow-hidden min-h-1">
|
||||
<div className={cn('h-full', isOnChat && 'hidden')}>
|
||||
<Outlet />
|
||||
</div>
|
||||
{chatMounted && (
|
||||
<div className={cn('h-full flex flex-col overflow-hidden', !isOnChat && 'hidden')}>
|
||||
<div className="border-b px-4 py-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground">Session</span>
|
||||
{activeConversationId ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="h-7 max-w-64 justify-start font-mono text-xs" />
|
||||
}
|
||||
>
|
||||
{shortConversationId(activeConversationId)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-80">
|
||||
{agents.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
No sessions available
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
agents.map((item) => (
|
||||
<DropdownMenuItem key={item.id} onClick={() => openConversation(item.id)}>
|
||||
<span className="font-mono text-xs truncate">{item.id}</span>
|
||||
{item.id === activeConversationId && <span className="ml-auto text-xs">Current</span>}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Initializing...</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 shrink-0"
|
||||
onClick={handleCreateConversation}
|
||||
disabled={isCreatingConversation}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{isCreatingConversation ? 'Creating...' : 'New Session'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conversationError && (
|
||||
<div className="px-4 py-2 text-xs text-destructive border-b">
|
||||
{conversationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalChat initialPrompt={initialPrompt} conversationId={activeConversationId} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
|
||||
<DeviceConfirmDialog />
|
||||
<UpdateNotification />
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* Login Page - Shown when user is not authenticated
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Loading } from '@multica/ui/components/ui/loading'
|
||||
import { MulticaIcon } from '@multica/ui/components/multica-icon'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { startLogin, isLoading, isAuthenticated } = useAuthStore()
|
||||
|
||||
// Redirect to home when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
console.log('[LoginPage] Authenticated, redirecting to home...')
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [isAuthenticated, navigate])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<header
|
||||
className="shrink-0 h-12"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loading className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<header
|
||||
className="shrink-0 h-12"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-sm flex flex-col items-center text-center space-y-6">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<MulticaIcon bordered animate size="md" />
|
||||
<h1 className="text-lg tracking-wide font-brand">Multica</h1>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
An AI assistant that gets things done.
|
||||
</p>
|
||||
|
||||
{/* Sign In */}
|
||||
<Button
|
||||
onClick={startLogin}
|
||||
size="lg"
|
||||
className="px-8"
|
||||
>
|
||||
Sign In to Continue
|
||||
</Button>
|
||||
|
||||
{/* Helper */}
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Opens browser for authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Separator } from '@multica/ui/components/ui/separator'
|
||||
import { ChevronLeft, Info, Check, Smartphone } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@multica/ui/components/ui/alert-dialog'
|
||||
import { useHubStore, selectPrimaryAgent } from '../../../stores/hub'
|
||||
import { TelegramConnectQR } from '../../../components/telegram-qr'
|
||||
import { StepDots } from './step-dots'
|
||||
|
||||
interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
clientName?: string
|
||||
}
|
||||
|
||||
interface PendingConfirm {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
interface ConnectStepProps {
|
||||
onNext: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
|
||||
const { hubInfo, agents } = useHubStore()
|
||||
const primaryAgent = selectPrimaryAgent(agents)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connectedDevice, setConnectedDevice] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<PendingConfirm | null>(null)
|
||||
|
||||
// Listen for device confirm requests during onboarding
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest(
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, agentId, conversationId, meta })
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDeviceConfirmRequest()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAllow = useCallback(() => {
|
||||
if (!pending) return
|
||||
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, true)
|
||||
setConnectedDevice(pending.meta?.clientName ?? pending.deviceId)
|
||||
setPending(null)
|
||||
setConnected(true)
|
||||
}, [pending])
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (!pending) return
|
||||
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, false)
|
||||
setPending(null)
|
||||
}, [pending])
|
||||
|
||||
const deviceLabel = pending?.meta?.clientName ?? pending?.deviceId
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Connect Telegram
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the QR code with your phone camera to connect on Telegram.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="rounded-lg bg-muted/50 px-4 py-3 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chat with your agent from your phone via Telegram.
|
||||
Your messages are routed through the Gateway to this machine.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 flex items-center gap-1.5">
|
||||
<Info className="size-3.5 shrink-0" />
|
||||
Discord, Slack, and more coming soon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR code or connected state */}
|
||||
<div className="flex justify-center py-2">
|
||||
{connected ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="size-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<Check className="size-6 text-green-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Telegram connected</p>
|
||||
{connectedDevice && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded-lg px-3 py-2">
|
||||
<Smartphone className="size-3.5 shrink-0" />
|
||||
<span>{connectedDevice}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TelegramConnectQR
|
||||
gateway={hubInfo?.url ?? 'http://localhost:3000'}
|
||||
hubId={hubInfo?.hubId ?? 'unknown'}
|
||||
agentId={primaryAgent?.id ?? 'unknown'}
|
||||
conversationId={primaryAgent?.id ?? 'unknown'}
|
||||
expirySeconds={30}
|
||||
size={180}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<StepDots />
|
||||
<div className="flex gap-2">
|
||||
{!connected && (
|
||||
<Button size="sm" variant="outline" onClick={onNext}>
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={onNext}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device confirm dialog */}
|
||||
<AlertDialog open={pending !== null}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>New Device Connection</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium">{deviceLabel}</span> wants to connect.
|
||||
<span className="block mt-1">Allow this device?</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleReject}>
|
||||
Reject
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleAllow}>
|
||||
Allow
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue