Fix Void services for web mode: add web-compatible LLM service and stubs

Services that depend on IMainProcessService (Electron-only) now have
web-compatible replacements registered via void.web.services.ts:

- LLMMessageServiceWeb: uses fetch() + SSE streaming for OpenRouter and
  other OpenAI-compatible providers directly from the browser
- MCPServiceWeb, MetricsServiceWeb, VoidUpdateServiceWeb,
  GenerateCommitMessageServiceWeb: no-op stubs for non-essential services

Also handles INativeHostService (Electron-only) gracefully in React
settings component with optional chaining.

Co-Authored-By: Danial Piterson <danial.samiei@gmail.com>
This commit is contained in:
Devin AI 2026-02-16 16:44:05 +00:00
parent 755beb6e12
commit 3884cb204b
4 changed files with 299 additions and 2 deletions

View file

@ -220,7 +220,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IWorkspaceContextService: accessor.get(IWorkspaceContextService),
IVoidCommandBarService: accessor.get(IVoidCommandBarService),
INativeHostService: accessor.get(INativeHostService),
INativeHostService: (() => { try { return accessor.get(INativeHostService); } catch { return undefined; } })() as any,
IToolsService: accessor.get(IToolsService),
IConvertToLLMMessageService: accessor.get(IConvertToLLMMessageService),
ITerminalService: accessor.get(ITerminalService),

View file

@ -1459,7 +1459,7 @@ export const Settings = () => {
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { commandService.executeCommand('workbench.action.selectTheme') }}>
Theme Settings
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { nativeHostService.showItemInFolder(environmentService.logsHome.fsPath) }}>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { nativeHostService?.showItemInFolder(environmentService.logsHome.fsPath) }}>
Open Logs
</VoidButtonBgDarken>
</div>

View file

@ -0,0 +1,294 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { ILLMMessageService } from '../common/sendLLMMessageService.js';
import { ServiceSendLLMMessageParams, ServiceModelListParams, OllamaModelResponse, OpenaiCompatibleModelResponse } from '../common/sendLLMMessageTypes.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
import { IMCPService } from '../common/mcpService.js';
import { MCPToolCallParams, RawMCPToolCall } from '../common/mcpServiceTypes.js';
import { InternalToolInfo } from '../common/prompt/prompts.js';
import { IMetricsService } from '../common/metricsService.js';
import { IVoidUpdateService } from '../common/voidUpdateService.js';
import { IGenerateCommitMessageService } from './voidSCMService.js';
import { ProviderName } from '../common/voidSettingsTypes.js';
const OPENAI_COMPAT_BASE_URLS: Partial<Record<ProviderName, string>> = {
openRouter: 'https://openrouter.ai/api/v1',
openAI: 'https://api.openai.com/v1',
deepseek: 'https://api.deepseek.com',
groq: 'https://api.groq.com/openai/v1',
xAI: 'https://api.x.ai/v1',
mistral: 'https://api.mistral.ai/v1',
};
class LLMMessageServiceWeb extends Disposable implements ILLMMessageService {
readonly _serviceBrand: undefined;
private readonly _abortControllers = new Map<string, AbortController>();
constructor(
@IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService,
) {
super();
}
sendLLMMessage(params: ServiceSendLLMMessageParams): string | null {
const { onError, modelSelection } = params;
if (modelSelection === null) {
onError({ message: 'Please add a provider in Void\'s Settings.', fullError: null });
return null;
}
if (params.messagesType === 'chatMessages' && (params.messages?.length ?? 0) === 0) {
onError({ message: 'No messages detected.', fullError: null });
return null;
}
if (params.messagesType === 'FIMMessage') {
onError({ message: 'Autocomplete (FIM) is not supported in web mode.', fullError: null });
return null;
}
const requestId = generateUuid();
const abortController = new AbortController();
this._abortControllers.set(requestId, abortController);
this._doSendChat(params, requestId, abortController);
return requestId;
}
private async _doSendChat(
params: ServiceSendLLMMessageParams,
requestId: string,
abortController: AbortController
) {
const { onText, onFinalMessage, onError, modelSelection } = params;
if (params.messagesType !== 'chatMessages' || !modelSelection) return;
try {
const { settingsOfProvider } = this.voidSettingsService.state;
const providerSettings = settingsOfProvider[modelSelection.providerName];
const apiKey = (providerSettings as Record<string, unknown>).apiKey as string | undefined;
if (!apiKey) {
onError({
message: `API key not set for ${modelSelection.providerName}. Please configure it in Void Settings.`,
fullError: null
});
return;
}
const baseUrl = this._getBaseUrl(modelSelection.providerName, providerSettings);
if (!baseUrl) {
onError({
message: `Provider "${modelSelection.providerName}" requires the desktop app. Use OpenRouter instead.`,
fullError: null
});
return;
}
const messages = this._buildMessages(params.messages, params.separateSystemMessage);
const body: Record<string, unknown> = {
model: modelSelection.modelName,
messages,
stream: true,
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
};
if (modelSelection.providerName === 'openRouter') {
headers['HTTP-Referer'] = 'https://ide.orcest.ai';
headers['X-Title'] = 'ide.orcest.ai';
}
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: abortController.signal,
});
if (!response.ok) {
const errorText = await response.text();
onError({
message: `API error (${response.status}): ${errorText}`,
fullError: new Error(errorText)
});
this._abortControllers.delete(requestId);
return;
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let fullText = '';
let fullReasoning = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') {
onFinalMessage({ fullText, fullReasoning, anthropicReasoning: null });
this._abortControllers.delete(requestId);
return;
}
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
if (delta?.content) {
fullText += delta.content;
onText({ fullText, fullReasoning });
}
if (delta?.reasoning_content || delta?.reasoning) {
fullReasoning += (delta.reasoning_content || delta.reasoning);
onText({ fullText, fullReasoning });
}
} catch {
// skip malformed SSE chunks
}
}
}
onFinalMessage({ fullText, fullReasoning, anthropicReasoning: null });
this._abortControllers.delete(requestId);
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
const message = err instanceof Error ? err.message : String(err);
onError({ message, fullError: err instanceof Error ? err : new Error(message) });
this._abortControllers.delete(requestId);
}
}
private _getBaseUrl(providerName: ProviderName, providerSettings: Record<string, unknown>): string | null {
const known = OPENAI_COMPAT_BASE_URLS[providerName];
if (known) return known;
if (providerName === 'openAICompatible' || providerName === 'liteLLM' || providerName === 'awsBedrock') {
return (providerSettings.endpoint as string) || null;
}
return null;
}
private _buildMessages(
messages: unknown[],
separateSystemMessage: string | undefined
): { role: string; content: string }[] {
const result: { role: string; content: string }[] = [];
if (separateSystemMessage) {
result.push({ role: 'system', content: separateSystemMessage });
}
for (const msg of messages) {
const m = msg as { role: string; content: unknown };
if (typeof m.content === 'string') {
result.push({ role: m.role === 'model' ? 'assistant' : m.role, content: m.content });
} else if (Array.isArray(m.content)) {
const textParts = m.content
.filter((p: Record<string, unknown>) => p.type === 'text' || p.text)
.map((p: Record<string, unknown>) => (p.text as string) || '')
.join('');
if (textParts) {
result.push({ role: m.role === 'model' ? 'assistant' : m.role, content: textParts });
}
}
}
return result;
}
abort(requestId: string) {
const controller = this._abortControllers.get(requestId);
if (controller) {
controller.abort();
this._abortControllers.delete(requestId);
}
}
ollamaList(params: ServiceModelListParams<OllamaModelResponse>) {
params.onError({ error: 'Ollama model listing is not available in web mode.' });
}
openAICompatibleList(params: ServiceModelListParams<OpenaiCompatibleModelResponse>) {
params.onError({ error: 'Model listing is not available in web mode.' });
}
}
class MCPServiceWeb extends Disposable implements IMCPService {
readonly _serviceBrand: undefined;
state: { mcpServerOfName: Record<string, never>; error: string | undefined } = {
mcpServerOfName: {},
error: undefined,
};
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
async revealMCPConfigFile(): Promise<void> { }
async toggleServerIsOn(): Promise<void> { }
getMCPTools(): InternalToolInfo[] | undefined { return undefined; }
async callMCPTool(_toolData: MCPToolCallParams): Promise<{ result: RawMCPToolCall }> {
throw new Error('MCP is not available in web mode.');
}
stringifyResult(result: RawMCPToolCall): string {
return JSON.stringify(result);
}
}
class MetricsServiceWeb implements IMetricsService {
readonly _serviceBrand: undefined;
capture(): void { }
setOptOut(): void { }
async getDebuggingProperties(): Promise<object> { return { mode: 'web' }; }
}
class VoidUpdateServiceWeb implements IVoidUpdateService {
readonly _serviceBrand: undefined;
check: IVoidUpdateService['check'] = async () => null;
}
class GenerateCommitMessageServiceWeb implements IGenerateCommitMessageService {
readonly _serviceBrand: undefined;
async generateCommitMessage(): Promise<void> { }
abort(): void { }
}
registerSingleton(ILLMMessageService, LLMMessageServiceWeb, InstantiationType.Eager);
registerSingleton(IMCPService, MCPServiceWeb, InstantiationType.Eager);
registerSingleton(IMetricsService, MetricsServiceWeb, InstantiationType.Eager);
registerSingleton(IVoidUpdateService, VoidUpdateServiceWeb, InstantiationType.Eager);
registerSingleton(IGenerateCommitMessageService, GenerateCommitMessageServiceWeb, InstantiationType.Delayed);

View file

@ -121,6 +121,9 @@ registerSingleton(IDefaultAccountService, NullDefaultAccountService, Instantiati
//#region --- workbench contributions
// Void: web-compatible service overrides (must come after common to override Electron-only registrations)
import './contrib/void/browser/void.web.services.js';
// Logs
import './contrib/logs/browser/logs.contribution.js';