import * as codearts from '@codearts/plugin';
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import * as http from 'http';
import { exposable, expose } from '@cloudide/messaging';
import { AbstractBackend, PluginLike } from './abstract-backend';
import { AIService } from './ai-service';
import { ChatMessage, AIConfig, AIProvider, DEFAULT_CONFIG, ToolCall, AIResponse, ToolDefinition } from '../common/types';
import { getToolDefinitions, executeTool, ToolContext } from './tools';
const MAX_TOOL_ITERATIONS = 15;
@exposable
export class Backend extends AbstractBackend {
private aiService: AIService;
private conversationHistory: ChatMessage[] = [];
private viewId = 'view_type_vibedev_chat';
constructor(plugin: PluginLike, context: codearts.ExtensionContext) {
super(plugin, context);
this.aiService = new AIService();
}
async init(): Promise<void> { }
public async run(): Promise<void> {
this.plugin.log('INFO', 'VibeDev AI backend started');
}
public stop(): void {
this.conversationHistory = [];
}
private getConfig(): AIConfig {
const cfg = codearts.workspace.getConfiguration('vibedev');
return {
provider: cfg.get<AIProvider>('provider', DEFAULT_CONFIG.provider),
apiKey: cfg.get<string>('apiKey', DEFAULT_CONFIG.apiKey),
baseUrl: cfg.get<string>('baseUrl', DEFAULT_CONFIG.baseUrl),
model: cfg.get<string>('model', DEFAULT_CONFIG.model),
maxTokens: cfg.get<number>('maxTokens', DEFAULT_CONFIG.maxTokens),
temperature: cfg.get<number>('temperature', DEFAULT_CONFIG.temperature)
};
}
private getWorkspaceRoot(): string {
const folders = codearts.workspace.workspaceFolders;
if (folders && folders.length > 0) {
return folders[0].uri.fsPath;
}
return process.cwd();
}
private async notifyToolStatus(toolName: string, description: string): Promise<void> {
await this.plugin.call(`${this.viewId}::page.toolStatus`, toolName, description);
}
private inlineSendMessage(
messages: ChatMessage[],
config: AIConfig,
tools?: ToolDefinition[],
onChunk?: (content: string) => void
): Promise<AIResponse> {
let base = config.baseUrl.replace(/\/+$/, '');
if (base.endsWith('/v1')) base = base.slice(0, -3);
const url = new URL(`${base}/v1/chat/completions`);
const isHttps = url.protocol === 'https:';
const mod = isHttps ? https : http;
this.plugin.log('INFO', `[VibeDev-inline] Request URL: ${url.href}`);
const bodyObj: Record<string, unknown> = {
model: config.model,
messages: messages.map(m => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
if (m.toolCalls && m.toolCalls.length > 0) {
msg.tool_calls = m.toolCalls.map(tc => ({
id: tc.id, type: 'function',
function: { name: tc.name, arguments: tc.arguments }
}));
}
if (m.toolCallId) msg.tool_call_id = m.toolCallId;
if (m.role === 'assistant' && m.reasoningContent) {
msg.reasoning_content = m.reasoningContent;
}
return msg;
}),
max_tokens: config.maxTokens,
temperature: config.temperature,
stream: true
};
if (tools && tools.length > 0) {
bodyObj.tools = tools.map(t => ({
type: 'function',
function: { name: t.name, description: t.description, parameters: t.parameters }
}));
}
const body = JSON.stringify(bodyObj);
this.plugin.log('INFO', `[VibeDev-inline] Request body preview: ${body.slice(0, 500)}`);
return new Promise<AIResponse>((resolve, reject) => {
const req = mod.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
'Accept': 'text/event-stream'
}
}, (res) => {
if (res.statusCode !== 200) {
let errBody = '';
res.on('data', (chunk: Buffer) => { errBody += chunk.toString(); });
res.on('end', () => reject(new Error(`API error ${res.statusCode}: ${errBody}`)));
return;
}
let fullContent = '';
let fullReasoningContent = '';
let buffer = '';
let chunkCount = 0;
const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
res.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
chunkCount++;
const delta = parsed.choices?.[0]?.delta;
if (!delta) continue;
if (chunkCount <= 3) {
this.plugin.log('INFO', `[VibeDev-inline] chunk#${chunkCount} delta keys: [${Object.keys(delta).join(',')}], choice keys: [${Object.keys(parsed.choices[0]).join(',')}]`);
}
if (delta.content) {
fullContent += delta.content;
if (onChunk) onChunk(delta.content);
}
const rc = delta.reasoning_content || delta.reasoning ||
delta.thought || delta.thinking ||
parsed.choices?.[0]?.reasoning_content || parsed.choices?.[0]?.reasoning;
if (rc) fullReasoningContent += rc;
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index;
if (!toolCallMap.has(idx)) {
toolCallMap.set(idx, { id: tc.id || '', name: '', arguments: '' });
}
const entry = toolCallMap.get(idx)!;
if (tc.id) entry.id = tc.id;
if (tc.function?.name) entry.name = tc.function.name;
if (tc.function?.arguments) entry.arguments += tc.function.arguments;
}
}
} catch { }
}
});
res.on('end', () => {
if (buffer.trim()) {
const trimmed = buffer.trim();
if (trimmed.startsWith('data: ') && trimmed.slice(6) !== '[DONE]') {
try {
const parsed = JSON.parse(trimmed.slice(6));
const delta = parsed.choices?.[0]?.delta;
if (delta?.content) {
fullContent += delta.content;
if (onChunk) onChunk(delta.content);
}
const rc2 = delta?.reasoning_content || delta?.reasoning ||
delta?.thought || delta?.thinking ||
parsed.choices?.[0]?.reasoning_content || parsed.choices?.[0]?.reasoning;
if (rc2) fullReasoningContent += rc2;
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index;
if (!toolCallMap.has(idx)) {
toolCallMap.set(idx, { id: tc.id || '', name: '', arguments: '' });
}
const entry = toolCallMap.get(idx)!;
if (tc.id) entry.id = tc.id;
if (tc.function?.name) entry.name = tc.function.name;
if (tc.function?.arguments) entry.arguments += tc.function.arguments;
}
}
} catch { }
}
}
const toolCalls: ToolCall[] = [];
for (const entry of toolCallMap.values()) {
toolCalls.push({ id: entry.id, name: entry.name, arguments: entry.arguments });
}
this.plugin.log('INFO', `[VibeDev-inline] Done: contentLen=${fullContent.length}, toolCalls=${toolCalls.length}, reasoningLen=${fullReasoningContent.length}, chunks=${chunkCount}`);
resolve({
content: fullContent,
toolCalls,
reasoningContent: fullReasoningContent || undefined
});
});
res.on('error', reject);
});
req.on('error', reject);
req.write(body);
req.end();
});
}
@expose('ai.getSettings')
public getSettings(): AIConfig {
return this.getConfig();
}
@expose('ai.saveSettings')
public async saveSettings(settings: Partial<AIConfig>): Promise<void> {
const cfg = codearts.workspace.getConfiguration('vibedev');
if (settings.provider !== undefined) await cfg.update('provider', settings.provider);
if (settings.apiKey !== undefined) await cfg.update('apiKey', settings.apiKey);
if (settings.baseUrl !== undefined) await cfg.update('baseUrl', settings.baseUrl);
if (settings.model !== undefined) await cfg.update('model', settings.model);
if (settings.maxTokens !== undefined) await cfg.update('maxTokens', settings.maxTokens);
if (settings.temperature !== undefined) await cfg.update('temperature', settings.temperature);
this.plugin.log('INFO', 'VibeDev settings saved');
}
@expose('ai.chat')
public async chat(userMessage: string): Promise<void> {
const config = this.getConfig();
if (!config.apiKey) {
await this.plugin.call(`${this.viewId}::page.addMessage`, {
role: 'assistant',
content: 'Please configure your API key in the settings first.'
});
await this.plugin.call(`${this.viewId}::page.chatDone`);
return;
}
this.conversationHistory.push({
role: 'user',
content: userMessage,
timestamp: Date.now()
});
let contextPrefix = '';
try {
const editor = codearts.window.activeTextEditor;
if (editor) {
const doc = editor.document;
const selection = editor.selection;
const selectedText = doc.getText(selection);
const languageId = doc.languageId;
if (selectedText) {
contextPrefix = `[Selected ${languageId} code]:\n\`\`\`${languageId}\n${selectedText}\n\`\`\`\n\n`;
} else {
const fullText = doc.getText();
if (fullText.length < 8000) {
contextPrefix = `[Current file: ${doc.fileName} (${languageId})]:\n\`\`\`${languageId}\n${fullText}\n\`\`\`\n\n`;
}
}
}
} catch { }
const systemMessage: ChatMessage = {
role: 'system',
content: 'You are a helpful AI coding assistant. You have access to tools that let you read, write, and search files in the workspace. Use them when the user asks you to work with files. Provide clear, concise answers.'
};
const messages: ChatMessage[] = [systemMessage];
const historySlice = this.conversationHistory.slice(-20);
for (const msg of historySlice) {
if (msg.role === 'user' && messages.length === 1 && contextPrefix) {
messages.push({ ...msg, content: contextPrefix + msg.content });
} else {
messages.push(msg);
}
}
const tools = getToolDefinitions();
const toolCtx: ToolContext = { workspaceRoot: this.getWorkspaceRoot() };
try {
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let streamedContent = '';
const msgSummary = messages.map(m => ({
role: m.role,
hasReasoning: !!m.reasoningContent,
reasoningLen: m.reasoningContent?.length || 0,
hasToolCalls: !!(m.toolCalls && m.toolCalls.length > 0),
hasToolCallId: !!m.toolCallId,
contentLen: m.content?.length || 0
}));
console.log(`[VibeDev] Sending ${messages.length} messages (iteration ${iteration}):`, JSON.stringify(msgSummary));
const response: AIResponse = await this.inlineSendMessage(
messages, config, tools,
(chunk) => {
streamedContent += chunk;
this.plugin.call(`${this.viewId}::page.addChunk`, chunk);
}
);
console.log(`[VibeDev] Response: contentLen=${response.content.length}, toolCalls=${response.toolCalls.length}, hasReasoning=${!!response.reasoningContent}, reasoningLen=${response.reasoningContent?.length || 0}`);
if (response.toolCalls.length === 0) {
this.conversationHistory.push({
role: 'assistant',
content: response.content,
timestamp: Date.now(),
reasoningContent: response.reasoningContent
});
break;
}
messages.push({
role: 'assistant',
content: response.content,
toolCalls: response.toolCalls,
reasoningContent: response.reasoningContent
});
this.conversationHistory.push({
role: 'assistant',
content: response.content,
toolCalls: response.toolCalls,
timestamp: Date.now(),
reasoningContent: response.reasoningContent
});
for (const tc of response.toolCalls) {
await this.notifyToolStatus(tc.name, tc.arguments);
const result = await executeTool(tc, toolCtx);
const toolMsg: ChatMessage = {
role: 'tool',
content: result.content,
toolCallId: result.toolCallId
};
messages.push(toolMsg);
this.conversationHistory.push(toolMsg);
}
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.plugin.log('ERROR', `AI chat error: ${errMsg}`);
await this.plugin.call(`${this.viewId}::page.addMessage`, {
role: 'assistant',
content: `Error: ${errMsg}`
});
}
await this.plugin.call(`${this.viewId}::page.chatDone`);
}
@expose('ai.clearHistory')
public clearHistory(): void {
this.conversationHistory = [];
this.plugin.log('INFO', 'Conversation history cleared');
}
@expose('ai.readFile')
public async readFile(filePath: string): Promise<{ success: boolean; content?: string; error?: string }> {
try {
const absPath = path.isAbsolute(filePath) ? filePath : path.join(this.getWorkspaceRoot(), filePath);
const content = fs.readFileSync(absPath, 'utf-8');
return { success: true, content };
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
return { success: false, error: errMsg };
}
}
@expose('ai.writeFile')
public async writeFile(filePath: string, content: string): Promise<{ success: boolean; error?: string }> {
try {
const absPath = path.isAbsolute(filePath) ? filePath : path.join(this.getWorkspaceRoot(), filePath);
const dir = path.dirname(absPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(absPath, content, 'utf-8');
this.plugin.log('INFO', `File written: ${absPath}`);
try {
const doc = await codearts.workspace.openTextDocument(absPath);
await codearts.window.showTextDocument(doc);
} catch { }
return { success: true };
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
return { success: false, error: errMsg };
}
}
@expose('ai.applyCode')
public async applyCode(filePath: string, code: string): Promise<{ success: boolean; error?: string }> {
return this.writeFile(filePath, code);
}
}