mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 09:13:36 +00:00
316 lines
8.3 KiB
TypeScript
316 lines
8.3 KiB
TypeScript
import {
|
|
query,
|
|
type CanUseTool,
|
|
type ModelInfo,
|
|
type Options,
|
|
} from '@anthropic-ai/claude-agent-sdk';
|
|
import path from 'node:path';
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js';
|
|
import type {
|
|
ProviderModel,
|
|
ProviderSessionEvent,
|
|
RuntimePermissionMode,
|
|
StartSessionInput,
|
|
} from '@/modules/llm/providers/provider.interface.js';
|
|
|
|
type ClaudeExecutionInput = StartSessionInput & {
|
|
sessionId: string;
|
|
isResume: boolean;
|
|
emitEvent?: (event: ProviderSessionEvent) => void;
|
|
};
|
|
|
|
const CLAUDE_THINKING_LEVELS = new Set(['low', 'medium', 'high', 'max']);
|
|
const SUPPORTED_CLAUDE_IMAGE_TYPES = new Map<string, 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'>([
|
|
['.jpg', 'image/jpeg'],
|
|
['.jpeg', 'image/jpeg'],
|
|
['.png', 'image/png'],
|
|
['.gif', 'image/gif'],
|
|
['.webp', 'image/webp'],
|
|
]);
|
|
|
|
type ClaudeUserPromptMessage = {
|
|
type: 'user';
|
|
message: {
|
|
role: 'user';
|
|
content: Array<
|
|
| {
|
|
type: 'text';
|
|
text: string;
|
|
}
|
|
| {
|
|
type: 'image';
|
|
source: {
|
|
type: 'base64';
|
|
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
|
data: string;
|
|
};
|
|
}
|
|
>;
|
|
};
|
|
parent_tool_use_id: null;
|
|
timestamp: string;
|
|
};
|
|
|
|
/**
|
|
* Safely reads one optional string value from unknown data.
|
|
*/
|
|
const readString = (value: unknown): string | undefined => {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = value.trim();
|
|
return normalized.length ? normalized : undefined;
|
|
};
|
|
|
|
/**
|
|
* Claude SDK provider implementation.
|
|
*/
|
|
export class ClaudeProvider extends BaseSdkProvider {
|
|
constructor() {
|
|
super('claude', {
|
|
supportsRuntimePermissionRequests: true,
|
|
supportsThinkingModeControl: true,
|
|
supportsModelSwitching: true,
|
|
supportsSessionResume: true,
|
|
supportsSessionStop: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieves available Claude models from the SDK.
|
|
*/
|
|
async listModels(): Promise<ProviderModel[]> {
|
|
const probe = query({
|
|
prompt: 'model_probe',
|
|
options: {
|
|
permissionMode: 'plan',
|
|
},
|
|
});
|
|
|
|
try {
|
|
const models = await probe.supportedModels();
|
|
return models.map((model) => this.mapModelInfo(model));
|
|
} finally {
|
|
probe.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a Claude SDK query execution for start/resume flows.
|
|
*/
|
|
protected async createSdkExecution(input: ClaudeExecutionInput): Promise<{
|
|
stream: AsyncIterable<unknown>;
|
|
stop: () => Promise<boolean>;
|
|
}> {
|
|
const options: Options = {
|
|
cwd: input.workspacePath,
|
|
model: input.model,
|
|
effort: this.resolveClaudeEffort(input.thinkingMode),
|
|
canUseTool: this.resolvePermissionHandler(input.runtimePermissionMode, input.emitEvent),
|
|
};
|
|
|
|
if (input.isResume) {
|
|
options.resume = input.sessionId;
|
|
} else {
|
|
options.sessionId = input.sessionId;
|
|
}
|
|
|
|
const promptInput = await this.buildPromptInput(input.prompt, input.imagePaths, input.workspacePath);
|
|
const queryInstance = query({
|
|
prompt: promptInput as any,
|
|
options,
|
|
});
|
|
|
|
return {
|
|
stream: queryInstance,
|
|
stop: async () => {
|
|
await queryInstance.interrupt();
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds a Claude prompt payload. When images are present, this returns an async iterable user message.
|
|
*/
|
|
private async buildPromptInput(
|
|
prompt: string,
|
|
imagePaths?: string[],
|
|
workspacePath?: string,
|
|
): Promise<string | AsyncIterable<ClaudeUserPromptMessage>> {
|
|
if (!imagePaths || imagePaths.length === 0) {
|
|
return prompt;
|
|
}
|
|
|
|
const content: ClaudeUserPromptMessage['message']['content'] = [
|
|
{ type: 'text', text: prompt },
|
|
];
|
|
|
|
for (const imagePath of imagePaths) {
|
|
const resolvedPath = path.isAbsolute(imagePath)
|
|
? imagePath
|
|
: path.resolve(workspacePath ?? process.cwd(), imagePath);
|
|
const extension = path.extname(resolvedPath).toLowerCase();
|
|
const mediaType = SUPPORTED_CLAUDE_IMAGE_TYPES.get(extension);
|
|
if (!mediaType) {
|
|
continue;
|
|
}
|
|
|
|
const imageBytes = await readFile(resolvedPath);
|
|
content.push({
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: mediaType,
|
|
data: imageBytes.toString('base64'),
|
|
},
|
|
});
|
|
}
|
|
|
|
const sdkPrompt = (async function* (): AsyncIterable<ClaudeUserPromptMessage> {
|
|
yield {
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content,
|
|
},
|
|
parent_tool_use_id: null,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
})();
|
|
|
|
return sdkPrompt;
|
|
}
|
|
|
|
/**
|
|
* Produces compact event metadata for frontend stream rendering.
|
|
*/
|
|
protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null {
|
|
if (typeof rawEvent !== 'object' || rawEvent === null) {
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
channel: 'sdk',
|
|
message: String(rawEvent),
|
|
};
|
|
}
|
|
|
|
const messageType = this.getStringProperty(rawEvent, 'type');
|
|
const messageSubtype = this.getStringProperty(rawEvent, 'subtype');
|
|
const message = [messageType, messageSubtype].filter(Boolean).join(':') || 'claude_event';
|
|
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
channel: 'sdk',
|
|
message,
|
|
data: rawEvent,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalizes Claude model metadata to the shared model shape.
|
|
*/
|
|
private mapModelInfo(model: ModelInfo): ProviderModel {
|
|
return {
|
|
value: model.value,
|
|
displayName: model.displayName,
|
|
description: model.description,
|
|
supportsThinkingModes: Boolean(model.supportsEffort),
|
|
supportedThinkingModes: model.supportedEffortLevels,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Maps requested thinking mode to Claude effort levels.
|
|
*/
|
|
private resolveClaudeEffort(thinkingMode?: string): Options['effort'] {
|
|
if (!thinkingMode) {
|
|
return 'high';
|
|
}
|
|
|
|
const normalized = thinkingMode.trim().toLowerCase();
|
|
if (CLAUDE_THINKING_LEVELS.has(normalized)) {
|
|
return normalized as Options['effort'];
|
|
}
|
|
|
|
return 'high';
|
|
}
|
|
|
|
/**
|
|
* Builds a runtime permission callback when explicit allow/deny is requested.
|
|
*/
|
|
private resolvePermissionHandler(
|
|
mode?: RuntimePermissionMode,
|
|
emitEvent?: (event: ProviderSessionEvent) => void,
|
|
): CanUseTool | undefined {
|
|
if (!mode || mode === 'ask') {
|
|
return undefined;
|
|
}
|
|
|
|
if (mode === 'allow') {
|
|
return async (toolName, input, options) => {
|
|
const optionsRecord = options as Record<string, unknown>;
|
|
emitEvent?.({
|
|
timestamp: new Date().toISOString(),
|
|
channel: 'system',
|
|
message: `Tool permission requested for "${toolName}".`,
|
|
data: {
|
|
type: 'tool_use_request',
|
|
toolName,
|
|
input,
|
|
toolUseID: options.toolUseID,
|
|
title: readString(optionsRecord.title),
|
|
displayName: readString(optionsRecord.displayName),
|
|
description: readString(optionsRecord.description),
|
|
blockedPath: options.blockedPath,
|
|
},
|
|
});
|
|
return { behavior: 'allow' };
|
|
};
|
|
}
|
|
|
|
return async (toolName, input, options) => {
|
|
const optionsRecord = options as Record<string, unknown>;
|
|
emitEvent?.({
|
|
timestamp: new Date().toISOString(),
|
|
channel: 'system',
|
|
message: `Tool permission denied for "${toolName}".`,
|
|
data: {
|
|
type: 'tool_use_request',
|
|
toolName,
|
|
input,
|
|
toolUseID: options.toolUseID,
|
|
title: readString(optionsRecord.title),
|
|
displayName: readString(optionsRecord.displayName),
|
|
description: readString(optionsRecord.description),
|
|
blockedPath: options.blockedPath,
|
|
},
|
|
});
|
|
return {
|
|
behavior: 'deny',
|
|
message: 'Permission denied by runtime permission mode.',
|
|
interrupt: false,
|
|
};
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reads one optional string property from an unknown event object.
|
|
*/
|
|
private getStringProperty(value: unknown, key: string): string | undefined {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined;
|
|
}
|
|
|
|
const record = value as Record<string, unknown>;
|
|
const rawValue = record[key];
|
|
if (typeof rawValue !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
return rawValue;
|
|
}
|
|
}
|