mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 19:50:09 +00:00
Compare commits
3 Commits
main
...
feat/claude-code
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ed61d87da | |||
| 45919e6db8 | |||
| 01b411cbc3 |
@@ -2,3 +2,4 @@ lockfile=false
|
||||
shamefully-hoist=true
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
ignore-workspace-root-check=true
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Claude Code Integration
|
||||
|
||||
This document describes the Claude Code SDK integration in LobeChat Desktop application.
|
||||
|
||||
## Overview
|
||||
|
||||
Claude Code SDK enables running Claude Code as a subprocess, providing AI-powered coding assistance capabilities. The integration supports:
|
||||
|
||||
- **Multi-turn conversations** with context retention
|
||||
- **File operations** (read/write)
|
||||
- **Code execution** through bash commands
|
||||
- **Session management** and continuation
|
||||
- **Real-time streaming** responses
|
||||
- **Cost tracking** per session
|
||||
|
||||
## Accessing Claude Code
|
||||
|
||||
In the LobeChat Desktop application, you can access Claude Code through:
|
||||
|
||||
1. **Sidebar Navigation**: Click the code icon (`</>`) in the sidebar (desktop only)
|
||||
2. **Direct URL**: Navigate to `/claude-code` in the application
|
||||
|
||||
The Claude Code interface provides:
|
||||
- A code editor for writing prompts
|
||||
- Real-time streaming message display
|
||||
- Session management with history
|
||||
- Cost tracking and usage statistics
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **IPC Layer** (`packages/electron-client-ipc`)
|
||||
- Type definitions for Claude Code events
|
||||
- IPC event interfaces for main/render communication
|
||||
|
||||
2. **Main Process Controller** (`apps/desktop/src/main/controllers/ClaudeCodeCtr.ts`)
|
||||
- Handles Claude Code SDK integration
|
||||
- Manages streaming sessions and abort controllers
|
||||
- Tracks session history
|
||||
|
||||
3. **React Hook** (`src/hooks/useClaudeCode.ts`)
|
||||
- Provides easy-to-use interface for React components
|
||||
- Handles IPC communication with main process
|
||||
- Manages streaming state and events
|
||||
|
||||
4. **UI Page** (`src/app/[variants]/(main)/claude-code/`)
|
||||
- User interface for interacting with Claude Code
|
||||
- Query editor with syntax highlighting
|
||||
- Session management interface
|
||||
- Real-time message streaming display
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Install Claude Code SDK dependency:
|
||||
```bash
|
||||
npm install @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
2. Set up authentication:
|
||||
```bash
|
||||
# Option 1: Anthropic API Key
|
||||
export ANTHROPIC_API_KEY="your-api-key"
|
||||
|
||||
# Option 2: Amazon Bedrock
|
||||
export CLAUDE_CODE_USE_BEDROCK=1
|
||||
# Configure AWS credentials
|
||||
|
||||
# Option 3: Google Vertex AI
|
||||
export CLAUDE_CODE_USE_VERTEX=1
|
||||
# Configure Google Cloud credentials
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Query
|
||||
|
||||
```typescript
|
||||
import { useClaudeCode } from '@/hooks/useClaudeCode';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { query, isLoading } = useClaudeCode();
|
||||
|
||||
const handleQuery = async () => {
|
||||
const result = await query('Write a function to calculate Fibonacci numbers', {
|
||||
maxTurns: 3,
|
||||
outputFormat: 'json',
|
||||
});
|
||||
|
||||
console.log(result.messages);
|
||||
console.log(result.sessionId);
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Streaming Query
|
||||
|
||||
```typescript
|
||||
const { startStreamingQuery, isLoading } = useClaudeCode({
|
||||
onStreamMessage: (message) => {
|
||||
console.log('New message:', message);
|
||||
},
|
||||
onStreamComplete: (sessionId) => {
|
||||
console.log('Stream completed:', sessionId);
|
||||
},
|
||||
onStreamError: (error) => {
|
||||
console.error('Stream error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleStream = async () => {
|
||||
await startStreamingQuery('Build a React component', {
|
||||
maxTurns: 5,
|
||||
outputFormat: 'stream-json',
|
||||
allowedTools: ['Read', 'Write', 'Bash'],
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
```typescript
|
||||
const { recentSessions, fetchRecentSessions, clearSession } = useClaudeCode();
|
||||
|
||||
// Get recent sessions
|
||||
await fetchRecentSessions();
|
||||
|
||||
// Continue a previous session
|
||||
await startStreamingQuery('Continue', {
|
||||
resumeSessionId: session.sessionId,
|
||||
});
|
||||
|
||||
// Clear a session
|
||||
await clearSession(sessionId);
|
||||
```
|
||||
|
||||
## IPC Events
|
||||
|
||||
### Client Dispatch Events (Renderer → Main)
|
||||
|
||||
- `claudeCodeQuery` - Execute a Claude Code query
|
||||
- `claudeCodeStreamStart` - Start a streaming query
|
||||
- `claudeCodeStreamStop` - Stop an active stream
|
||||
- `claudeCodeCreateAbortController` - Create abort controller
|
||||
- `claudeCodeAbort` - Trigger abort
|
||||
- `claudeCodeGetRecentSessions` - Get session history
|
||||
- `claudeCodeClearSession` - Clear a specific session
|
||||
- `claudeCodeCheckAvailability` - Check if Claude Code is available
|
||||
|
||||
### Broadcast Events (Main → Renderer)
|
||||
|
||||
- `claudeCodeStreamMessage` - Stream message event
|
||||
- `claudeCodeStreamComplete` - Stream completion event
|
||||
- `claudeCodeStreamError` - Stream error event
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```typescript
|
||||
interface ClaudeCodeOptions {
|
||||
maxTurns?: number; // Maximum conversation turns
|
||||
systemPrompt?: string; // Override system prompt
|
||||
appendSystemPrompt?: string; // Append to system prompt
|
||||
cwd?: string; // Working directory
|
||||
allowedTools?: string[] | string; // Allowed tools
|
||||
disallowedTools?: string[] | string; // Disallowed tools
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
||||
outputFormat?: 'text' | 'json' | 'stream-json';
|
||||
inputFormat?: 'text' | 'stream-json';
|
||||
mcpConfig?: string; // MCP configuration file path
|
||||
permissionPromptTool?: string; // MCP tool for permissions
|
||||
verbose?: boolean; // Enable verbose logging
|
||||
continueLastSession?: boolean; // Continue last session
|
||||
resumeSessionId?: string; // Resume specific session
|
||||
}
|
||||
```
|
||||
|
||||
## Message Types
|
||||
|
||||
```typescript
|
||||
interface ClaudeCodeMessage {
|
||||
type: 'assistant' | 'user' | 'system' | 'result';
|
||||
message?: any;
|
||||
session_id?: string;
|
||||
subtype?: string;
|
||||
duration_ms?: number;
|
||||
duration_api_ms?: number;
|
||||
is_error?: boolean;
|
||||
num_turns?: number;
|
||||
result?: string;
|
||||
total_cost_usd?: number;
|
||||
apiKeySource?: string;
|
||||
cwd?: string;
|
||||
tools?: string[];
|
||||
mcp_servers?: Array<{ name: string; status: string }>;
|
||||
model?: string;
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always check availability** before using Claude Code
|
||||
2. **Handle errors gracefully** - both sync and async errors
|
||||
3. **Use abort controllers** for long-running operations
|
||||
4. **Monitor costs** through session tracking
|
||||
5. **Clean up sessions** when no longer needed
|
||||
6. **Set appropriate tool permissions** based on use case
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Claude Code not available
|
||||
|
||||
1. Check if running in Electron desktop app
|
||||
2. Verify API key is set correctly
|
||||
3. Check environment variables
|
||||
|
||||
### Streaming not working
|
||||
|
||||
1. Ensure proper event listeners are set up
|
||||
2. Check for abort controller conflicts
|
||||
3. Verify stream ID is unique
|
||||
|
||||
### Session continuation fails
|
||||
|
||||
1. Check if session ID is valid
|
||||
2. Ensure session hasn't been cleared
|
||||
3. Verify prompt is appropriate for continuation
|
||||
@@ -13,7 +13,7 @@ export const appBrowsers = {
|
||||
identifier: 'chat',
|
||||
keepAlive: true,
|
||||
minWidth: 400,
|
||||
path: '/chat',
|
||||
path: '/claude-code',
|
||||
showOnInit: true,
|
||||
titleBarStyle: 'hidden',
|
||||
vibrancy: 'under-window',
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
ClaudeCodeMessage,
|
||||
ClaudeCodeOptions,
|
||||
ClaudeCodeQueryParams,
|
||||
ClaudeCodeQueryResult,
|
||||
ClaudeCodeSessionInfo,
|
||||
ClaudeCodeStreamingParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { app } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createClaudeCodeModule } from '@/modules/claudeCode';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ClaudeCodeCtr');
|
||||
|
||||
interface StreamingSession {
|
||||
abortController: AbortController;
|
||||
sessionId?: string;
|
||||
streamId: string;
|
||||
}
|
||||
|
||||
export default class ClaudeCodeCtr extends ControllerModule {
|
||||
private claudeCodeModule = createClaudeCodeModule({
|
||||
debugMode: Boolean(process.env.DEBUG),
|
||||
});
|
||||
private streamingSessions = new Map<string, StreamingSession>();
|
||||
private abortControllers = new Map<string, AbortController>();
|
||||
private sessionHistory = new Map<string, ClaudeCodeSessionInfo>();
|
||||
|
||||
/**
|
||||
* 检查 Claude Code 是否可用
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeCheckAvailability')
|
||||
async checkAvailability(): Promise<{
|
||||
apiKeySource?: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
version?: string;
|
||||
}> {
|
||||
try {
|
||||
return await this.claudeCodeModule.checkAvailability();
|
||||
} catch (error) {
|
||||
logger.error('Error checking Claude Code availability:', error);
|
||||
return {
|
||||
available: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Claude Code 查询
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeQuery')
|
||||
async executeQuery(params: ClaudeCodeQueryParams): Promise<ClaudeCodeQueryResult> {
|
||||
try {
|
||||
logger.info('Executing Claude Code query:', params.prompt);
|
||||
|
||||
const abortController = params.abortSignal
|
||||
? this.abortControllers.get(params.abortSignal)
|
||||
: new AbortController();
|
||||
|
||||
const messages: ClaudeCodeMessage[] = [];
|
||||
let sessionId: string | undefined;
|
||||
|
||||
const queryParams = {
|
||||
abortController,
|
||||
options: this.buildOptions(params.options),
|
||||
prompt: params.prompt,
|
||||
};
|
||||
|
||||
for await (const message of this.claudeCodeModule.query(queryParams)) {
|
||||
messages.push(message);
|
||||
|
||||
if (message.session_id) {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会话历史
|
||||
if (sessionId) {
|
||||
this.updateSessionHistory(sessionId, messages);
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
sessionId: sessionId || '',
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error executing Claude Code query:', error);
|
||||
return {
|
||||
error: error.message,
|
||||
messages: [],
|
||||
sessionId: '',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始流式查询
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeStreamStart')
|
||||
async startStreamingQuery(
|
||||
params: ClaudeCodeStreamingParams,
|
||||
): Promise<{ error?: string; success: boolean }> {
|
||||
try {
|
||||
logger.info('Starting streaming Claude Code query:', params.streamId);
|
||||
|
||||
const abortController = params.abortSignal
|
||||
? this.abortControllers.get(params.abortSignal)
|
||||
: new AbortController();
|
||||
|
||||
const session: StreamingSession = {
|
||||
abortController,
|
||||
streamId: params.streamId,
|
||||
};
|
||||
|
||||
this.streamingSessions.set(params.streamId, session);
|
||||
|
||||
// 在后台执行流式查询
|
||||
this.executeStreamingQuery(params, abortController);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error starting streaming query:', error);
|
||||
return { error: error.message, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止流式查询
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeStreamStop')
|
||||
async stopStreamingQuery(streamId: string): Promise<{ success: boolean }> {
|
||||
try {
|
||||
logger.info('Stopping streaming query:', streamId);
|
||||
|
||||
const session = this.streamingSessions.get(streamId);
|
||||
if (session) {
|
||||
session.abortController.abort();
|
||||
this.streamingSessions.delete(streamId);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error stopping streaming query:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 AbortController
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeCreateAbortController')
|
||||
createAbortController(): { signalId: string } {
|
||||
const signalId = `abort-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(signalId, abortController);
|
||||
|
||||
logger.debug('Created AbortController:', signalId);
|
||||
|
||||
// 清理过期的 AbortController(30分钟后)
|
||||
setTimeout(
|
||||
() => {
|
||||
this.abortControllers.delete(signalId);
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
return { signalId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发 abort
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeAbort')
|
||||
abort(signalId: string): { success: boolean } {
|
||||
try {
|
||||
const abortController = this.abortControllers.get(signalId);
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
this.abortControllers.delete(signalId);
|
||||
logger.debug('Aborted signal:', signalId);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
logger.error('Error aborting:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的会话列表
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeGetRecentSessions')
|
||||
getRecentSessions(): ClaudeCodeSessionInfo[] {
|
||||
const sessions = Array.from(this.sessionHistory.values());
|
||||
// 按最后活跃时间排序
|
||||
sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
// 返回最近 20 个会话
|
||||
return sessions.slice(0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定会话
|
||||
*/
|
||||
@ipcClientEvent('claudeCodeClearSession')
|
||||
clearSession(sessionId: string): { success: boolean } {
|
||||
try {
|
||||
this.sessionHistory.delete(sessionId);
|
||||
logger.debug('Cleared session:', sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error clearing session:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行流式查询(后台)
|
||||
*/
|
||||
private async executeStreamingQuery(
|
||||
params: ClaudeCodeStreamingParams,
|
||||
abortController: AbortController,
|
||||
) {
|
||||
try {
|
||||
const { streamId } = params;
|
||||
let sessionId: string | undefined;
|
||||
let messageCount = 0;
|
||||
|
||||
logger.debug('Starting streaming query execution for stream:', streamId);
|
||||
|
||||
const queryParams = {
|
||||
abortController,
|
||||
options: this.buildOptions(params.options),
|
||||
prompt: params.prompt,
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const message of this.claudeCodeModule.query(queryParams)) {
|
||||
messageCount++;
|
||||
|
||||
logger.debug(`Stream ${streamId} - Message ${messageCount}:`, message.type);
|
||||
logger.debug('output message:', message);
|
||||
|
||||
// 广播消息到渲染进程
|
||||
this.app.browserManager.broadcastToAllWindows('claudeCodeStreamMessage', {
|
||||
message,
|
||||
streamId,
|
||||
});
|
||||
|
||||
if (message.session_id) {
|
||||
sessionId = message.session_id;
|
||||
const session = this.streamingSessions.get(streamId);
|
||||
if (session) {
|
||||
session.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Stream ${streamId} completed with ${messageCount} messages`);
|
||||
} catch (queryError) {
|
||||
logger.error('Error in Claude Code query:', queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
// 更新会话历史
|
||||
if (sessionId) {
|
||||
// 这里我们不存储所有消息,只更新会话信息
|
||||
const existingSession = this.sessionHistory.get(sessionId);
|
||||
if (existingSession) {
|
||||
existingSession.lastActiveAt = Date.now();
|
||||
existingSession.turnCount++;
|
||||
} else {
|
||||
this.sessionHistory.set(sessionId, {
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
sessionId,
|
||||
turnCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 广播完成事件
|
||||
this.app.browserManager.broadcastToAllWindows('claudeCodeStreamComplete', {
|
||||
sessionId: sessionId || '',
|
||||
streamId,
|
||||
});
|
||||
|
||||
logger.debug('Stream completed successfully:', streamId);
|
||||
|
||||
// 清理
|
||||
this.streamingSessions.delete(streamId);
|
||||
} catch (error) {
|
||||
logger.error('Error in streaming query:', error);
|
||||
|
||||
// 广播错误事件
|
||||
this.app.browserManager.broadcastToAllWindows('claudeCodeStreamError', {
|
||||
error: error.message,
|
||||
streamId: params.streamId,
|
||||
});
|
||||
|
||||
// 清理
|
||||
this.streamingSessions.delete(params.streamId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建选项对象
|
||||
*/
|
||||
private buildOptions(options?: ClaudeCodeOptions): ClaudeCodeOptions {
|
||||
const defaultOptions: ClaudeCodeOptions = {
|
||||
maxTurns: 5,
|
||||
outputFormat: 'stream-json',
|
||||
};
|
||||
|
||||
if (!options) {
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
// 处理选项
|
||||
const processedOptions: ClaudeCodeOptions = { ...defaultOptions, ...options };
|
||||
|
||||
// 如果提供了 mcpConfig 路径,确保它是绝对路径
|
||||
if (options.mcpConfig && !join(options.mcpConfig).startsWith('/')) {
|
||||
processedOptions.mcpConfig = join(app.getPath('userData'), options.mcpConfig);
|
||||
}
|
||||
|
||||
return processedOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话历史
|
||||
*/
|
||||
private updateSessionHistory(sessionId: string, messages: ClaudeCodeMessage[]) {
|
||||
const resultMessage = messages.find((m) => m.type === 'result');
|
||||
|
||||
const existingSession = this.sessionHistory.get(sessionId);
|
||||
if (existingSession) {
|
||||
existingSession.lastActiveAt = Date.now();
|
||||
existingSession.turnCount++;
|
||||
if (resultMessage?.total_cost_usd) {
|
||||
existingSession.totalCost = (existingSession.totalCost || 0) + resultMessage.total_cost_usd;
|
||||
}
|
||||
} else {
|
||||
this.sessionHistory.set(sessionId, {
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
sessionId,
|
||||
totalCost: resultMessage?.total_cost_usd,
|
||||
turnCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { ClaudeCodeMessage } from '@lobechat/electron-client-ipc';
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import { type Interface, createInterface } from 'node:readline';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import {
|
||||
ClaudeCodeImpl,
|
||||
ClaudeCodeProcessOptions,
|
||||
ClaudeCodeQueryParams,
|
||||
ClaudeCodeRuntimeConfig,
|
||||
} from './type';
|
||||
|
||||
const logger = createLogger('modules:claude-code');
|
||||
|
||||
/**
|
||||
* Claude Code Service Implementation
|
||||
*/
|
||||
export class ClaudeCodeServiceImpl extends ClaudeCodeImpl {
|
||||
private activeProcesses = new Map<string, ChildProcess>();
|
||||
private config: ClaudeCodeRuntimeConfig;
|
||||
|
||||
constructor(config: ClaudeCodeRuntimeConfig = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
debugMode: config.debugMode ?? Boolean(process.env.DEBUG),
|
||||
maxMemoryUsage: config.maxMemoryUsage ?? 1024 * 1024 * 1024, // 1GB
|
||||
timeoutMs: config.timeoutMs ?? 30 * 60 * 1000, // 30 minutes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude Code query
|
||||
*/
|
||||
async *query(params: ClaudeCodeQueryParams): AsyncGenerator<ClaudeCodeMessage> {
|
||||
const processId = this.generateProcessId();
|
||||
let childProcess: ChildProcess | null = null;
|
||||
let readline: Interface | null = null;
|
||||
|
||||
// Set entrypoint environment variable
|
||||
if (!process.env.CLAUDE_CODE_ENTRYPOINT) {
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts';
|
||||
}
|
||||
|
||||
// Build process options
|
||||
const processOptions = this.buildProcessOptions(params);
|
||||
|
||||
// Spawn child process using claude command directly
|
||||
childProcess = spawn('claude', processOptions.args, {
|
||||
cwd: processOptions.cwd,
|
||||
env: { ...process.env, ...processOptions.env },
|
||||
signal: params.abortController?.signal,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Register process
|
||||
this.activeProcesses.set(processId, childProcess);
|
||||
|
||||
// Handle process cleanup
|
||||
const cleanup = () => {
|
||||
if (childProcess && !childProcess.killed) {
|
||||
childProcess.kill('SIGTERM');
|
||||
}
|
||||
this.activeProcesses.delete(processId);
|
||||
};
|
||||
|
||||
// Setup abort handling
|
||||
params.abortController?.signal.addEventListener('abort', cleanup);
|
||||
process.on('exit', cleanup);
|
||||
|
||||
// Handle stdin
|
||||
if (typeof params.prompt === 'string') {
|
||||
childProcess.stdin?.end();
|
||||
} else {
|
||||
// Handle stream input if needed
|
||||
this.streamToStdin(params.prompt, childProcess.stdin, params.abortController);
|
||||
}
|
||||
|
||||
// Handle stderr in debug mode
|
||||
if (this.config.debugMode && childProcess.stderr) {
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
logger.debug('Claude Code stderr:', data.toString());
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle process errors
|
||||
let processError: Error | null = null;
|
||||
childProcess.on('error', (error) => {
|
||||
processError = new Error(`Failed to spawn Claude Code process: ${error.message}`);
|
||||
});
|
||||
|
||||
// Create a promise to wait for process completion
|
||||
const processExitPromise = new Promise<void>((resolve, reject) => {
|
||||
childProcess!.on('close', (code) => {
|
||||
if (params.abortController?.signal.aborted) {
|
||||
reject(new Error('Claude Code process aborted by user'));
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`Claude Code process exited with code ${code}`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create readline interface for stdout and yield messages
|
||||
if (childProcess.stdout) {
|
||||
readline = createInterface({ input: childProcess.stdout });
|
||||
|
||||
try {
|
||||
for await (const line of readline) {
|
||||
if (processError) {
|
||||
throw processError;
|
||||
}
|
||||
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
yield message;
|
||||
} catch (parseError) {
|
||||
logger.error('Failed to parse JSON line:', line, parseError);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
readline.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to complete
|
||||
await processExitPromise;
|
||||
} finally {
|
||||
// Cleanup
|
||||
if (readline) {
|
||||
readline.close();
|
||||
}
|
||||
cleanup();
|
||||
params.abortController?.signal.removeEventListener('abort', cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Claude Code availability
|
||||
*/
|
||||
async checkAvailability(): Promise<{
|
||||
apiKeySource?: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
version?: string;
|
||||
}> {
|
||||
try {
|
||||
// Check environment variables
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === '1';
|
||||
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === '1';
|
||||
|
||||
if (!apiKey && !useBedrock && !useVertex) {
|
||||
return {
|
||||
available: false,
|
||||
error:
|
||||
'No API credentials found. Please set ANTHROPIC_API_KEY or configure third-party provider.',
|
||||
};
|
||||
}
|
||||
|
||||
let apiKeySource = 'unknown';
|
||||
if (apiKey) apiKeySource = 'anthropic';
|
||||
else if (useBedrock) apiKeySource = 'bedrock';
|
||||
else if (useVertex) apiKeySource = 'vertex';
|
||||
|
||||
// Check if claude command exists
|
||||
const claudeExists = await this.checkClaudeCommandExists();
|
||||
if (!claudeExists) {
|
||||
return {
|
||||
available: false,
|
||||
error: 'Claude CLI command not found. Please install Claude CLI first.',
|
||||
};
|
||||
}
|
||||
|
||||
// Get version
|
||||
const version = await this.getVersion();
|
||||
|
||||
return {
|
||||
apiKeySource,
|
||||
available: true,
|
||||
version,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error checking Claude Code availability:', error);
|
||||
return {
|
||||
available: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude Code executable path
|
||||
*/
|
||||
async getExecutablePath(): Promise<string> {
|
||||
// Return claude command directly
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Kill all active processes
|
||||
for (const [processId, childProcess] of this.activeProcesses) {
|
||||
if (!childProcess.killed) {
|
||||
childProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
this.activeProcesses.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if claude command exists
|
||||
*/
|
||||
private async checkClaudeCommandExists(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const testProcess = spawn('which', ['claude'], { stdio: 'pipe' });
|
||||
|
||||
testProcess.on('close', (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
testProcess.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build process options from query parameters
|
||||
*/
|
||||
private buildProcessOptions(params: ClaudeCodeQueryParams): ClaudeCodeProcessOptions {
|
||||
const args = ['--output-format', 'stream-json'];
|
||||
|
||||
if (this.config.debugMode) {
|
||||
args.push('--verbose');
|
||||
}
|
||||
|
||||
const options = params.options || {};
|
||||
|
||||
// Add options to args
|
||||
if (options.systemPrompt) {
|
||||
args.push('--system-prompt', options.systemPrompt);
|
||||
}
|
||||
if (options.appendSystemPrompt) {
|
||||
args.push('--append-system-prompt', options.appendSystemPrompt);
|
||||
}
|
||||
if (options.maxTurns) {
|
||||
args.push('--max-turns', options.maxTurns.toString());
|
||||
}
|
||||
if (options.permissionPromptTool) {
|
||||
args.push('--permission-prompt-tool', options.permissionPromptTool);
|
||||
}
|
||||
if (options.continueLastSession) {
|
||||
args.push('--continue');
|
||||
}
|
||||
if (options.resumeSessionId) {
|
||||
args.push('--resume', options.resumeSessionId);
|
||||
}
|
||||
if (options.allowedTools) {
|
||||
const tools = Array.isArray(options.allowedTools)
|
||||
? options.allowedTools.join(',')
|
||||
: options.allowedTools;
|
||||
args.push('--allowedTools', tools);
|
||||
}
|
||||
if (options.disallowedTools) {
|
||||
const tools = Array.isArray(options.disallowedTools)
|
||||
? options.disallowedTools.join(',')
|
||||
: options.disallowedTools;
|
||||
args.push('--disallowedTools', tools);
|
||||
}
|
||||
if (options.mcpConfig) {
|
||||
args.push('--mcp-config', options.mcpConfig);
|
||||
}
|
||||
if (options.permissionMode && options.permissionMode !== 'default') {
|
||||
args.push('--permission-mode', options.permissionMode);
|
||||
}
|
||||
|
||||
// Add prompt
|
||||
if (typeof params.prompt === 'string') {
|
||||
args.push('--print', params.prompt.trim());
|
||||
} else {
|
||||
args.push('--input-format', 'stream-json');
|
||||
}
|
||||
|
||||
return {
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique process ID
|
||||
*/
|
||||
private generateProcessId(): string {
|
||||
return `claude-code-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream input to stdin
|
||||
*/
|
||||
private async streamToStdin(
|
||||
stream: any,
|
||||
stdin: any,
|
||||
abortController?: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
for await (const message of stream) {
|
||||
if (abortController?.signal.aborted) break;
|
||||
stdin.write(JSON.stringify(message) + '\n');
|
||||
}
|
||||
stdin.end();
|
||||
} catch (error) {
|
||||
logger.error('Error streaming to stdin:', error);
|
||||
stdin.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude Code version
|
||||
*/
|
||||
private async getVersion(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const versionProcess = spawn('claude', ['--version'], { stdio: 'pipe' });
|
||||
|
||||
let output = '';
|
||||
versionProcess.stdout?.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
versionProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// Extract version from output
|
||||
const versionMatch = output.match(/(\d+\.\d+\.\d+)/);
|
||||
resolve(versionMatch ? versionMatch[1] : 'unknown');
|
||||
} else {
|
||||
resolve('unknown');
|
||||
}
|
||||
});
|
||||
|
||||
versionProcess.on('error', () => {
|
||||
resolve('unknown');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ClaudeCodeServiceImpl } from './impl';
|
||||
import { ClaudeCodeImpl, ClaudeCodeRuntimeConfig } from './type';
|
||||
|
||||
/**
|
||||
* Create Claude Code module instance
|
||||
*/
|
||||
export const createClaudeCodeModule = (config?: ClaudeCodeRuntimeConfig): ClaudeCodeImpl => {
|
||||
return new ClaudeCodeServiceImpl(config);
|
||||
};
|
||||
|
||||
// Export types and implementation
|
||||
export type {
|
||||
ClaudeCodeProcessOptions,
|
||||
ClaudeCodeProcessResult,
|
||||
ClaudeCodeQueryParams,
|
||||
ClaudeCodeRuntimeConfig,
|
||||
ClaudeCodeStreamingParams,
|
||||
} from './type';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ClaudeCodeMessage, ClaudeCodeOptions } from '@lobechat/electron-client-ipc';
|
||||
|
||||
/**
|
||||
* Claude Code Query Parameters
|
||||
*/
|
||||
export interface ClaudeCodeQueryParams {
|
||||
abortController?: AbortController;
|
||||
options?: ClaudeCodeOptions;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code Streaming Parameters
|
||||
*/
|
||||
export interface ClaudeCodeStreamingParams extends ClaudeCodeQueryParams {
|
||||
streamId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code Service Implementation Abstract Class
|
||||
*/
|
||||
export abstract class ClaudeCodeImpl {
|
||||
/**
|
||||
* Execute Claude Code query
|
||||
* @param params Query parameters
|
||||
* @returns AsyncGenerator of ClaudeCodeMessage
|
||||
*/
|
||||
abstract query(params: ClaudeCodeQueryParams): AsyncGenerator<ClaudeCodeMessage>;
|
||||
|
||||
/**
|
||||
* Check Claude Code availability
|
||||
* @returns Promise with availability status
|
||||
*/
|
||||
abstract checkAvailability(): Promise<{
|
||||
apiKeySource?: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
version?: string;
|
||||
}>;
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
abstract cleanup(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code Process Options
|
||||
*/
|
||||
export interface ClaudeCodeProcessOptions {
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
executable?: string;
|
||||
executableArgs?: string[];
|
||||
pathToClaudeCodeExecutable?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code Process Result
|
||||
*/
|
||||
export interface ClaudeCodeProcessResult {
|
||||
exitCode: number;
|
||||
killed: boolean;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code Runtime Configuration
|
||||
*/
|
||||
export interface ClaudeCodeRuntimeConfig {
|
||||
debugMode?: boolean;
|
||||
maxMemoryUsage?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ declare global {
|
||||
}
|
||||
|
||||
/**
|
||||
* client 端请求 sketch 端 event 数据的方法
|
||||
* client 端请求 main 端 event 数据的方法
|
||||
*/
|
||||
export const dispatch: DispatchInvoke = async (event, ...data) => {
|
||||
if (!window.electronAPI || !window.electronAPI.invoke)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
ClaudeCodeMessage,
|
||||
ClaudeCodeQueryParams,
|
||||
ClaudeCodeQueryResult,
|
||||
ClaudeCodeSessionInfo,
|
||||
ClaudeCodeStreamingParams,
|
||||
} from '../types/claudeCode';
|
||||
|
||||
export interface ClaudeCodeDispatchEvents {
|
||||
/**
|
||||
* 触发 abort controller
|
||||
*/
|
||||
claudeCodeAbort: (signalId: string) => { success: boolean };
|
||||
|
||||
/**
|
||||
* 检查 Claude Code 是否可用
|
||||
*/
|
||||
claudeCodeCheckAvailability: () => {
|
||||
apiKeySource?: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除指定会话
|
||||
*/
|
||||
claudeCodeClearSession: (sessionId: string) => { success: boolean };
|
||||
|
||||
/**
|
||||
* 创建 abort controller 并返回其 ID
|
||||
*/
|
||||
claudeCodeCreateAbortController: () => { signalId: string };
|
||||
|
||||
/**
|
||||
* 获取最近的会话列表
|
||||
*/
|
||||
claudeCodeGetRecentSessions: () => ClaudeCodeSessionInfo[];
|
||||
|
||||
/**
|
||||
* 执行 Claude Code 查询
|
||||
*/
|
||||
claudeCodeQuery: (params: ClaudeCodeQueryParams) => ClaudeCodeQueryResult;
|
||||
|
||||
/**
|
||||
* 开始流式 Claude Code 查询
|
||||
*/
|
||||
claudeCodeStreamStart: (params: ClaudeCodeStreamingParams) => {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止流式 Claude Code 查询
|
||||
*/
|
||||
claudeCodeStreamStop: (streamId: string) => { success: boolean };
|
||||
}
|
||||
|
||||
export interface ClaudeCodeBroadcastEvents {
|
||||
/**
|
||||
* 流式完成事件
|
||||
*/
|
||||
claudeCodeStreamComplete: (data: { sessionId: string, streamId: string; }) => void;
|
||||
|
||||
/**
|
||||
* 流式错误事件
|
||||
*/
|
||||
claudeCodeStreamError: (data: { error: string, streamId: string; }) => void;
|
||||
|
||||
/**
|
||||
* 流式消息事件
|
||||
*/
|
||||
claudeCodeStreamMessage: (data: { message: ClaudeCodeMessage, streamId: string; }) => void;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ClaudeCodeBroadcastEvents, ClaudeCodeDispatchEvents } from './claudeCode';
|
||||
import { LocalFilesDispatchEvents } from './localFile';
|
||||
import { MenuDispatchEvents } from './menu';
|
||||
import { RemoteServerBroadcastEvents, RemoteServerDispatchEvents } from './remoteServer';
|
||||
@@ -21,7 +22,8 @@ export interface ClientDispatchEvents
|
||||
ShortcutDispatchEvents,
|
||||
RemoteServerDispatchEvents,
|
||||
UploadFilesDispatchEvents,
|
||||
TrayDispatchEvents {}
|
||||
TrayDispatchEvents,
|
||||
ClaudeCodeDispatchEvents {}
|
||||
|
||||
export type ClientDispatchEventKey = keyof ClientDispatchEvents;
|
||||
|
||||
@@ -36,7 +38,8 @@ export type ClientEventReturnType<T extends ClientDispatchEventKey> = ReturnType
|
||||
export interface MainBroadcastEvents
|
||||
extends AutoUpdateBroadcastEvents,
|
||||
RemoteServerBroadcastEvents,
|
||||
SystemBroadcastEvents {}
|
||||
SystemBroadcastEvents,
|
||||
ClaudeCodeBroadcastEvents {}
|
||||
|
||||
export type MainBroadcastEventKey = keyof MainBroadcastEvents;
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface ClaudeCodeMessage {
|
||||
apiKeySource?: string;
|
||||
cwd?: string;
|
||||
duration_api_ms?: number;
|
||||
duration_ms?: number;
|
||||
is_error?: boolean;
|
||||
mcp_servers?: Array<{ name: string; status: string }>;
|
||||
message?: any;
|
||||
model?: string;
|
||||
num_turns?: number;
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
||||
result?: string;
|
||||
session_id?: string;
|
||||
subtype?: string;
|
||||
tools?: string[];
|
||||
total_cost_usd?: number;
|
||||
type: 'assistant' | 'user' | 'system' | 'result';
|
||||
}
|
||||
|
||||
export interface ClaudeCodeOptions {
|
||||
allowedTools?: string[] | string;
|
||||
appendSystemPrompt?: string;
|
||||
continueLastSession?: boolean;
|
||||
cwd?: string;
|
||||
disallowedTools?: string[] | string;
|
||||
inputFormat?: 'text' | 'stream-json';
|
||||
maxTurns?: number;
|
||||
mcpConfig?: string;
|
||||
outputFormat?: 'text' | 'json' | 'stream-json';
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
||||
permissionPromptTool?: string;
|
||||
resumeSessionId?: string;
|
||||
systemPrompt?: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeQueryParams {
|
||||
abortSignal?: string;
|
||||
options?: ClaudeCodeOptions;
|
||||
prompt: string; // Signal ID for abort functionality
|
||||
}
|
||||
|
||||
export interface ClaudeCodeQueryResult {
|
||||
error?: string;
|
||||
messages: ClaudeCodeMessage[];
|
||||
sessionId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeStreamingParams extends ClaudeCodeQueryParams {
|
||||
streamId: string; // Unique ID for this streaming session
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSessionInfo {
|
||||
createdAt: number;
|
||||
lastActiveAt: number;
|
||||
sessionId: string;
|
||||
totalCost?: number;
|
||||
turnCount: number;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './claudeCode';
|
||||
export * from './dataSync';
|
||||
export * from './dispatch';
|
||||
export * from './localFile';
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { ActionIcon, ActionIconProps } from '@lobehub/ui';
|
||||
import { Compass, FolderClosed, MessageSquare } from 'lucide-react';
|
||||
'use client';
|
||||
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { Code2, Compass, FolderClosed, MessageSquare } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { SidebarTabKey } from '@/store/global/initialState';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
const ICON_SIZE: ActionIconProps['size'] = {
|
||||
blockSize: 40,
|
||||
size: 24,
|
||||
strokeWidth: 2,
|
||||
};
|
||||
|
||||
export interface TopActionProps {
|
||||
isPinned?: boolean | null;
|
||||
interface TopActionProps {
|
||||
isPinned?: boolean;
|
||||
tab?: SidebarTabKey;
|
||||
}
|
||||
|
||||
const ICON_SIZE = { fontSize: 20 };
|
||||
|
||||
const TopActions = memo<TopActionProps>(({ tab, isPinned }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const switchBackToChat = useGlobalStore((s) => s.switchBackToChat);
|
||||
@@ -29,6 +28,7 @@ const TopActions = memo<TopActionProps>(({ tab, isPinned }) => {
|
||||
const isChatActive = tab === SidebarTabKey.Chat && !isPinned;
|
||||
const isFilesActive = tab === SidebarTabKey.Files;
|
||||
const isDiscoverActive = tab === SidebarTabKey.Discover;
|
||||
const isClaudeCodeActive = tab === SidebarTabKey.ClaudeCode;
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
@@ -54,6 +54,17 @@ const TopActions = memo<TopActionProps>(({ tab, isPinned }) => {
|
||||
tooltipProps={{ placement: 'right' }}
|
||||
/>
|
||||
</Link>
|
||||
{isDesktop && (
|
||||
<Link aria-label={'Claude Code'} href={'/claude-code'}>
|
||||
<ActionIcon
|
||||
active={isClaudeCodeActive}
|
||||
icon={Code2}
|
||||
size={ICON_SIZE}
|
||||
title={'Claude Code'}
|
||||
tooltipProps={{ placement: 'right' }}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
{enableKnowledgeBase && (
|
||||
<Link aria-label={t('tab.files')} href={'/files'}>
|
||||
<ActionIcon
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import { ClaudeCodeMessage } from '@lobechat/electron-client-ipc';
|
||||
import { CodeEditor, Markdown, Text } from '@lobehub/ui';
|
||||
import { Button, Card, Empty, List, Space, Tabs, Tag, message } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Center, Flexbox } from 'react-layout-kit';
|
||||
|
||||
import PageTitle from '@/components/PageTitle';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import { claudeCodeSelectors } from '@/store/electron/selectors';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
padding: ${token.paddingLG}px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
`,
|
||||
|
||||
editorCard: css`
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
`,
|
||||
|
||||
messageCard: css`
|
||||
background: ${token.colorBgContainer};
|
||||
border-radius: ${token.borderRadius}px;
|
||||
padding: ${token.padding}px;
|
||||
margin-bottom: ${token.marginSM}px;
|
||||
`,
|
||||
|
||||
sessionItem: css`
|
||||
cursor: pointer;
|
||||
transition: all ${token.motionDurationMid} ${token.motionEaseInOut};
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorBgTextHover};
|
||||
}
|
||||
`,
|
||||
|
||||
statusCard: css`
|
||||
background: ${token.colorBgElevated};
|
||||
`,
|
||||
}));
|
||||
|
||||
const Client = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [messages, setMessages] = useState<ClaudeCodeMessage[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState('query');
|
||||
|
||||
// 获取操作方法
|
||||
const [
|
||||
startStreamingQuery,
|
||||
// stopStreaming,
|
||||
abort,
|
||||
fetchRecentSessions,
|
||||
clearSession,
|
||||
checkAvailability,
|
||||
] = useElectronStore((s) => [
|
||||
s.startStreamingQuery,
|
||||
// s.stopStreaming,
|
||||
s.abort,
|
||||
s.fetchRecentSessions,
|
||||
s.clearSession,
|
||||
s.checkAvailability,
|
||||
]);
|
||||
|
||||
// 获取状态
|
||||
const [
|
||||
isAvailable,
|
||||
isLoading,
|
||||
error,
|
||||
apiKeySource,
|
||||
version,
|
||||
recentSessions,
|
||||
useClaudeCodeListeners,
|
||||
] = useElectronStore((s) => [
|
||||
claudeCodeSelectors.isAvailable(s),
|
||||
claudeCodeSelectors.isLoading(s),
|
||||
claudeCodeSelectors.error(s),
|
||||
claudeCodeSelectors.apiKeySource(s),
|
||||
claudeCodeSelectors.version(s),
|
||||
claudeCodeSelectors.recentSessions(s),
|
||||
s.useClaudeCodeListeners,
|
||||
]);
|
||||
|
||||
// 设置监听器
|
||||
useClaudeCodeListeners({
|
||||
onStreamComplete: (sessionId) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
message.success('Query completed');
|
||||
fetchRecentSessions();
|
||||
},
|
||||
onStreamError: (err) => {
|
||||
message.error(`Error: ${err}`);
|
||||
},
|
||||
onStreamMessage: (msg) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
},
|
||||
});
|
||||
|
||||
// 初始化时检查可用性
|
||||
useEffect(() => {
|
||||
checkAvailability();
|
||||
}, []);
|
||||
|
||||
// 执行查询
|
||||
const handleQuery = async () => {
|
||||
if (!prompt?.trim?.()) {
|
||||
message.warning('Please enter a prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMessages([]);
|
||||
await startStreamingQuery(prompt, {
|
||||
allowedTools: ['Read', 'Write', 'Bash'],
|
||||
maxTurns: 5,
|
||||
outputFormat: 'stream-json',
|
||||
});
|
||||
} catch (err) {
|
||||
message.error(`Query failed: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 继续会话
|
||||
const handleContinueSession = async (sessionId: string) => {
|
||||
try {
|
||||
setMessages([]);
|
||||
setCurrentSessionId(sessionId);
|
||||
await startStreamingQuery(prompt || 'Continue working on this', {
|
||||
outputFormat: 'stream-json',
|
||||
resumeSessionId: sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
message.error(`Failed to continue session: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息
|
||||
const renderMessage = (msg: ClaudeCodeMessage) => {
|
||||
if (msg.type === 'result') {
|
||||
return (
|
||||
<div className={styles.messageCard}>
|
||||
<Space direction="vertical" size="small">
|
||||
<Text strong>Result</Text>
|
||||
{msg.subtype && <Tag color={msg.is_error ? 'error' : 'success'}>{msg.subtype}</Tag>}
|
||||
<Text type="secondary">
|
||||
Duration: {msg.duration_ms}ms | API: {msg.duration_api_ms}ms | Turns: {msg.num_turns}
|
||||
</Text>
|
||||
{msg.total_cost_usd && (
|
||||
<Text type="secondary">Cost: ${msg.total_cost_usd.toFixed(4)}</Text>
|
||||
)}
|
||||
{msg.result && <Text>{msg.result}</Text>}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === 'system' && msg.subtype === 'init') {
|
||||
return (
|
||||
<div className={styles.messageCard}>
|
||||
<Space direction="vertical" size="small">
|
||||
<Text strong>System Initialization</Text>
|
||||
<Text type="secondary">Model: {msg.model}</Text>
|
||||
<Text type="secondary">Working Directory: {msg.cwd}</Text>
|
||||
<Text type="secondary">Available Tools: {msg.tools?.join(', ')}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.message) {
|
||||
const content = msg.message.content?.[0]?.text || JSON.stringify(msg.message, null, 2);
|
||||
return (
|
||||
<div className={styles.messageCard}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Text strong>{msg.type === 'assistant' ? '🤖 Assistant' : '👤 User'}</Text>
|
||||
<Markdown>{content}</Markdown>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<Center height="100%" width="100%">
|
||||
<Empty
|
||||
description="Claude Code is only available in the desktop app"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAvailable) {
|
||||
return (
|
||||
<Center height="100%" width="100%">
|
||||
<Card className={styles.statusCard}>
|
||||
<Space align="center" direction="vertical">
|
||||
<Text as={'h4'}>Claude Code is not available</Text>
|
||||
{error && <Text type="danger">{error}</Text>}
|
||||
<Text type="secondary">
|
||||
Please ensure you have set ANTHROPIC_API_KEY environment variable
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
children: (
|
||||
<Flexbox gap={16} height="100%">
|
||||
<Card className={styles.editorCard}>
|
||||
<CodeEditor
|
||||
height="200px"
|
||||
language="markdown"
|
||||
onValueChange={(v) => {
|
||||
setPrompt(v);
|
||||
}}
|
||||
placeholder="Enter your prompt here..."
|
||||
style={{ fontSize: 14 }}
|
||||
value={prompt}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
disabled={!prompt?.trim?.()}
|
||||
loading={isLoading}
|
||||
onClick={handleQuery}
|
||||
type="primary"
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
{isLoading && (
|
||||
<Button danger onClick={abort}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Flexbox flex={1} style={{ overflow: 'auto' }}>
|
||||
{messages.length > 0 ? (
|
||||
<List dataSource={messages} renderItem={renderMessage} />
|
||||
) : (
|
||||
<Center height="100%">
|
||||
<Empty description="No messages yet" />
|
||||
</Center>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
),
|
||||
key: 'query',
|
||||
label: 'Query',
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Flexbox gap={16} height="100%">
|
||||
<Flexbox align="center" horizontal justify="space-between">
|
||||
<Text as={'h5'}>Recent Sessions</Text>
|
||||
<Button onClick={fetchRecentSessions} size="small">
|
||||
Refresh
|
||||
</Button>
|
||||
</Flexbox>
|
||||
|
||||
<List
|
||||
dataSource={recentSessions}
|
||||
renderItem={(session) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
danger
|
||||
key="delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearSession(session.sessionId);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
Delete
|
||||
</Button>,
|
||||
]}
|
||||
className={styles.sessionItem}
|
||||
onClick={() => handleContinueSession(session.sessionId)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
description={
|
||||
<Space size="large">
|
||||
<Text type="secondary">
|
||||
Created: {new Date(session.createdAt).toLocaleString()}
|
||||
</Text>
|
||||
<Text type="secondary">Turns: {session.turnCount}</Text>
|
||||
{session.totalCost && (
|
||||
<Text type="secondary">Cost: ${session.totalCost.toFixed(4)}</Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
title={
|
||||
<Space>
|
||||
<Text>{session.sessionId}</Text>
|
||||
{session.sessionId === currentSessionId && <Tag color="blue">Current</Tag>}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Flexbox>
|
||||
),
|
||||
key: 'sessions',
|
||||
label: `Sessions (${recentSessions.length})`,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Flexbox gap={16}>
|
||||
<Card className={styles.statusCard} title="Claude Code Status">
|
||||
<Space direction="vertical">
|
||||
<Text>Version: {version}</Text>
|
||||
<Text>API Source: {apiKeySource}</Text>
|
||||
{currentSessionId && <Text>Current Session: {currentSessionId}</Text>}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="Features">
|
||||
<List
|
||||
dataSource={[
|
||||
'Multi-turn conversations with context',
|
||||
'File reading and writing capabilities',
|
||||
'Code execution through bash commands',
|
||||
'Session management and continuation',
|
||||
'Real-time streaming responses',
|
||||
'Cost tracking per session',
|
||||
]}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Text>✅ {item}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Flexbox>
|
||||
),
|
||||
key: 'info',
|
||||
label: 'Info',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={16}>
|
||||
<PageTitle title="Claude Code" />
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
items={tabItems}
|
||||
onChange={setActiveTab}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default Client;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { metadataModule } from '@/server/metadata';
|
||||
import { translation } from '@/server/translation';
|
||||
import { DynamicLayoutProps } from '@/types/next';
|
||||
import { RouteVariants } from '@/utils/server/routeVariants';
|
||||
|
||||
import Client from './Client';
|
||||
|
||||
export const generateMetadata = async (props: DynamicLayoutProps) => {
|
||||
const locale = await RouteVariants.getLocale(props);
|
||||
const { t } = await translation('common', locale);
|
||||
|
||||
return metadataModule.generate({
|
||||
description: 'Use Claude Code SDK to build AI-powered coding assistants',
|
||||
title: 'Claude Code',
|
||||
url: '/claude-code',
|
||||
});
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
return <Client />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -33,6 +33,7 @@ export const config = {
|
||||
'/',
|
||||
'/discover',
|
||||
'/discover(.*)',
|
||||
'/claude-code',
|
||||
'/chat',
|
||||
'/chat(.*)',
|
||||
'/changelog(.*)',
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
ClaudeCodeMessage,
|
||||
ClaudeCodeOptions,
|
||||
ClaudeCodeQueryResult,
|
||||
dispatch,
|
||||
useWatchBroadcast,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { useCallback } from 'react';
|
||||
import type { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
|
||||
import { ElectronStore } from '../store';
|
||||
import { ClaudeCodeState } from './initialState';
|
||||
|
||||
// Global variables to store stream and abort signal references
|
||||
let streamIdRef: string | null = null;
|
||||
let abortSignalIdRef: string | null = null;
|
||||
|
||||
// ======== Action Interface ======== //
|
||||
|
||||
export interface ClaudeCodeAction {
|
||||
abort: () => Promise<void>;
|
||||
checkAvailability: () => Promise<void>;
|
||||
clearSession: (sessionId: string) => Promise<void>;
|
||||
fetchRecentSessions: () => Promise<void>;
|
||||
query: (prompt: string, queryOptions?: ClaudeCodeOptions) => Promise<ClaudeCodeQueryResult>;
|
||||
setClaudeCodeError: (error: string | null) => void;
|
||||
setClaudeCodeLoading: (isLoading: boolean) => void;
|
||||
startStreamingQuery: (prompt: string, queryOptions?: ClaudeCodeOptions) => Promise<void>;
|
||||
stopStreaming: () => Promise<void>;
|
||||
updateClaudeCodeState: (state: Partial<ClaudeCodeState>) => void;
|
||||
useClaudeCodeListeners: (options: {
|
||||
onStreamComplete?: (sessionId: string) => void;
|
||||
onStreamError?: (error: string) => void;
|
||||
onStreamMessage?: (message: ClaudeCodeMessage) => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// ======== Action Implementation ======== //
|
||||
|
||||
export const createClaudeCodeSlice: StateCreator<
|
||||
ElectronStore,
|
||||
[['zustand/devtools', never]],
|
||||
[],
|
||||
ClaudeCodeAction
|
||||
> = (set, get) => ({
|
||||
abort: async () => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
const { stopStreaming } = get();
|
||||
|
||||
if (streamIdRef) {
|
||||
await stopStreaming();
|
||||
} else if (abortSignalIdRef) {
|
||||
try {
|
||||
await dispatch('claudeCodeAbort', abortSignalIdRef);
|
||||
} catch (err) {
|
||||
console.error('Error aborting:', err);
|
||||
} finally {
|
||||
abortSignalIdRef = null;
|
||||
set({ claudeCode: { ...get().claudeCode, isLoading: false } });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
checkAvailability: async () => {
|
||||
if (!isDesktop) {
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
error: 'Not running in Electron environment',
|
||||
isAvailable: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dispatch('claudeCodeCheckAvailability');
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
apiKeySource: result.apiKeySource || '',
|
||||
error: result.available ? null : result.error || 'Claude Code is not available',
|
||||
isAvailable: result.available,
|
||||
version: result.version || '',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
get().updateClaudeCodeState({
|
||||
error: (err as Error).message,
|
||||
isAvailable: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearSession: async (sessionId: string) => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
try {
|
||||
await dispatch('claudeCodeClearSession', sessionId);
|
||||
// 重新获取会话列表
|
||||
const { fetchRecentSessions } = get();
|
||||
await fetchRecentSessions();
|
||||
} catch (err) {
|
||||
console.error('Error clearing session:', err);
|
||||
}
|
||||
},
|
||||
|
||||
fetchRecentSessions: async () => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
try {
|
||||
const sessions = await dispatch('claudeCodeGetRecentSessions');
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
recentSessions: sessions,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching recent sessions:', err);
|
||||
}
|
||||
},
|
||||
|
||||
query: async (prompt: string, queryOptions?: ClaudeCodeOptions) => {
|
||||
if (!isDesktop) {
|
||||
throw new Error('Not running in Electron environment');
|
||||
}
|
||||
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// 创建 abort controller
|
||||
const { signalId } = await dispatch('claudeCodeCreateAbortController');
|
||||
abortSignalIdRef = signalId;
|
||||
|
||||
const result = await dispatch('claudeCodeQuery', {
|
||||
abortSignal: signalId,
|
||||
options: queryOptions,
|
||||
prompt,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Query failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
get().updateClaudeCodeState({
|
||||
error: (err as Error).message,
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
abortSignalIdRef = null;
|
||||
}
|
||||
},
|
||||
|
||||
setClaudeCodeError: (error: string | null) => {
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
error,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setClaudeCodeLoading: (isLoading: boolean) => {
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
isLoading,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
startStreamingQuery: async (prompt: string, queryOptions?: ClaudeCodeOptions) => {
|
||||
if (!isDesktop) {
|
||||
throw new Error('Not running in Electron environment');
|
||||
}
|
||||
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// 生成唯一的 stream ID
|
||||
const streamId = `stream-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
streamIdRef = streamId;
|
||||
|
||||
// 创建 abort controller
|
||||
const { signalId } = await dispatch('claudeCodeCreateAbortController');
|
||||
abortSignalIdRef = signalId;
|
||||
|
||||
const result = await dispatch('claudeCodeStreamStart', {
|
||||
abortSignal: signalId,
|
||||
options: queryOptions,
|
||||
prompt,
|
||||
streamId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to start streaming');
|
||||
}
|
||||
} catch (err) {
|
||||
get().updateClaudeCodeState({
|
||||
error: (err as Error).message,
|
||||
isLoading: false,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
stopStreaming: async () => {
|
||||
if (!isDesktop || !streamIdRef) return;
|
||||
|
||||
try {
|
||||
await dispatch('claudeCodeStreamStop', streamIdRef);
|
||||
|
||||
if (abortSignalIdRef) {
|
||||
await dispatch('claudeCodeAbort', abortSignalIdRef);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error stopping stream:', err);
|
||||
} finally {
|
||||
streamIdRef = null;
|
||||
abortSignalIdRef = null;
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateClaudeCodeState: (state) => {
|
||||
set({
|
||||
claudeCode: {
|
||||
...get().claudeCode,
|
||||
...state,
|
||||
},
|
||||
});
|
||||
},
|
||||
useClaudeCodeListeners: (options = {}) => {
|
||||
const { updateClaudeCodeState } = get();
|
||||
|
||||
const handleStreamMessage = useCallback(
|
||||
(data: { message: ClaudeCodeMessage; streamId: string }) => {
|
||||
if (data.streamId === streamIdRef && options.onStreamMessage) {
|
||||
options.onStreamMessage(data.message);
|
||||
}
|
||||
},
|
||||
[options.onStreamMessage],
|
||||
);
|
||||
|
||||
const handleStreamComplete = useCallback(
|
||||
(data: { sessionId: string; streamId: string }) => {
|
||||
if (data.streamId === streamIdRef) {
|
||||
streamIdRef = null;
|
||||
abortSignalIdRef = null;
|
||||
updateClaudeCodeState({ isLoading: false });
|
||||
if (options.onStreamComplete) {
|
||||
options.onStreamComplete(data.sessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[options.onStreamComplete, updateClaudeCodeState],
|
||||
);
|
||||
|
||||
const handleStreamError = useCallback(
|
||||
(data: { error: string; streamId: string }) => {
|
||||
if (data.streamId === streamIdRef) {
|
||||
streamIdRef = null;
|
||||
abortSignalIdRef = null;
|
||||
updateClaudeCodeState({ error: data.error, isLoading: false });
|
||||
if (options.onStreamError) {
|
||||
options.onStreamError(data.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[options.onStreamError, updateClaudeCodeState],
|
||||
);
|
||||
|
||||
// 注册事件监听器
|
||||
useWatchBroadcast('claudeCodeStreamMessage', handleStreamMessage);
|
||||
useWatchBroadcast('claudeCodeStreamComplete', handleStreamComplete);
|
||||
useWatchBroadcast('claudeCodeStreamError', handleStreamError);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './action';
|
||||
export * from './initialState';
|
||||
export * from './selectors';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ClaudeCodeSessionInfo } from '@lobechat/electron-client-ipc';
|
||||
|
||||
// Claude Code 相关状态
|
||||
export interface ClaudeCodeState {
|
||||
apiKeySource: string;
|
||||
error: string | null;
|
||||
isAvailable: boolean;
|
||||
isLoading: boolean;
|
||||
recentSessions: ClaudeCodeSessionInfo[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeStoreState {
|
||||
claudeCode: ClaudeCodeState;
|
||||
}
|
||||
|
||||
export const initialClaudeCodeState: ClaudeCodeStoreState = {
|
||||
claudeCode: {
|
||||
apiKeySource: '',
|
||||
error: null,
|
||||
isAvailable: false,
|
||||
isLoading: false,
|
||||
recentSessions: [],
|
||||
version: '',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ElectronStore } from '../store';
|
||||
|
||||
export const claudeCodeSelectors = {
|
||||
apiKeySource: (s: ElectronStore) => s.claudeCode.apiKeySource,
|
||||
error: (s: ElectronStore) => s.claudeCode.error,
|
||||
isAvailable: (s: ElectronStore) => s.claudeCode.isAvailable,
|
||||
isLoading: (s: ElectronStore) => s.claudeCode.isLoading,
|
||||
recentSessions: (s: ElectronStore) => s.claudeCode.recentSessions,
|
||||
version: (s: ElectronStore) => s.claudeCode.version,
|
||||
};
|
||||
@@ -1,9 +1,26 @@
|
||||
import { DataSyncConfig, ElectronAppState } from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
ClaudeCodeSessionInfo,
|
||||
DataSyncConfig,
|
||||
ElectronAppState,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { initialClaudeCodeState } from './claudeCode/initialState';
|
||||
|
||||
export type RemoteServerError = 'CONFIG_ERROR' | 'AUTH_ERROR' | 'DISCONNECT_ERROR';
|
||||
|
||||
// Claude Code 相关状态
|
||||
export interface ClaudeCodeState {
|
||||
apiKeySource: string;
|
||||
error: string | null;
|
||||
isAvailable: boolean;
|
||||
isLoading: boolean;
|
||||
recentSessions: ClaudeCodeSessionInfo[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ElectronState {
|
||||
appState: ElectronAppState;
|
||||
claudeCode: ClaudeCodeState;
|
||||
dataSyncConfig: DataSyncConfig;
|
||||
isAppStateInit?: boolean;
|
||||
isConnectingServer?: boolean;
|
||||
@@ -19,4 +36,5 @@ export const initialState: ElectronState = {
|
||||
isConnectingServer: false,
|
||||
isInitRemoteServerConfig: false,
|
||||
isSyncActive: false,
|
||||
...initialClaudeCodeState,
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from '../claudeCode/selectors';
|
||||
export * from './desktopState';
|
||||
export * from './sync';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { createDevtools } from '../middleware/createDevtools';
|
||||
import { type ElectronAppAction, createElectronAppSlice } from './actions/app';
|
||||
import { type ClaudeCodeAction, createClaudeCodeSlice } from './claudeCode/action';
|
||||
import { type ElectronRemoteServerAction, remoteSyncSlice } from './actions/sync';
|
||||
import { type ElectronState, initialState } from './initialState';
|
||||
|
||||
@@ -12,7 +13,8 @@ import { type ElectronState, initialState } from './initialState';
|
||||
export interface ElectronStore
|
||||
extends ElectronState,
|
||||
ElectronRemoteServerAction,
|
||||
ElectronAppAction {
|
||||
ElectronAppAction,
|
||||
ClaudeCodeAction {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
@@ -22,6 +24,7 @@ const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> =
|
||||
...initialState,
|
||||
...remoteSyncSlice(...parameters),
|
||||
...createElectronAppSlice(...parameters),
|
||||
...createClaudeCodeSlice(...parameters),
|
||||
});
|
||||
|
||||
// =============== 实装 useStore ============ //
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AsyncLocalStorage } from '@/utils/localStorage';
|
||||
|
||||
export enum SidebarTabKey {
|
||||
Chat = 'chat',
|
||||
ClaudeCode = 'claude-code',
|
||||
Discover = 'discover',
|
||||
Files = 'files',
|
||||
Me = 'me',
|
||||
|
||||
Reference in New Issue
Block a user