Compare commits

...

3 Commits

Author SHA1 Message Date
arvinxx 3ed61d87da refactor claude code 2025-07-11 17:11:26 +08:00
arvinxx 45919e6db8 refactor claude code 2025-07-11 10:57:29 +08:00
arvinxx 01b411cbc3 init claude code 2025-07-10 22:35:19 +08:00
24 changed files with 1968 additions and 16 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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);
// 清理过期的 AbortController30分钟后)
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;
}
+1 -1
View File
@@ -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;
+1
View File
@@ -33,6 +33,7 @@ export const config = {
'/',
'/discover',
'/discover(.*)',
'/claude-code',
'/chat',
'/chat(.*)',
'/changelog(.*)',
+307
View File
@@ -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);
},
});
+3
View File
@@ -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,
};
+19 -1
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from '../claudeCode/selectors';
export * from './desktopState';
export * from './sync';
+4 -1
View File
@@ -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 ============ //
+1
View File
@@ -8,6 +8,7 @@ import { AsyncLocalStorage } from '@/utils/localStorage';
export enum SidebarTabKey {
Chat = 'chat',
ClaudeCode = 'claude-code',
Discover = 'discover',
Files = 'files',
Me = 'me',