Phase 2: providers + chat + tasks + IPC rewire

This commit is contained in:
2026-03-01 19:56:39 +01:00
parent 1c74e9807d
commit b2854cee34
8 changed files with 1851 additions and 174 deletions

513
src/main/engine/ai/chat.ts Normal file
View File

@@ -0,0 +1,513 @@
/**
* ChatService — streaming chat using AI SDK's streamText().
*
* Replaces OpenCodeManager's sendAnthropicMessage/sendOpenAIMessage/
* streaming.ts with a single, provider-agnostic code path.
*
* AI SDK handles:
* - SSE parsing, reconnection, abort
* - Provider-specific request/response format (Anthropic Messages, OpenAI Chat Completions)
* - Tool call/result loop (maxSteps)
* - Token usage extraction
*/
import { streamText, generateText, stepCountIs } from 'ai';
import type { ModelMessage, LanguageModelUsage } from 'ai';
import type { BrowserWindow } from 'electron';
import type { ChatEngine, ChatMessageData } from '../ChatEngine';
import { isRenderTool, generateFromToolCall } from '../../a2ui/generator';
import type { A2UIServerMessage } from '../../a2ui/types';
import { ProviderRegistry, detectProvider } from './providers';
import { createBlogTools, type BlogToolDeps } from './blog-tools';
import { createA2UITools } from './a2ui-tools';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ChatCallbacks {
onDelta?: (delta: string) => void;
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
onToolResult?: (result: { name: string; result: unknown }) => void;
onA2UIMessage?: (message: A2UIServerMessage) => void;
onTokenUsage?: (usage: TokenUsageReport) => void;
}
export interface TokenUsageReport {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number;
cumulativeInputTokens: number;
cumulativeOutputTokens: number;
cumulativeCacheReadTokens: number;
cumulativeCacheWriteTokens: number;
cumulativeTotalTokens: number;
}
export interface SendResult {
success: boolean;
message?: string;
error?: string;
toolCalls?: Array<{ name: string; args: unknown }>;
}
// Maximum tool-call rounds per request
const MAX_TOOL_ROUNDS = 10;
// ---------------------------------------------------------------------------
// Message serialization — DB flat rows ↔ AI SDK messages
// ---------------------------------------------------------------------------
/**
* Convert DB message rows into AI SDK Message[] for `streamText({ messages })`.
* DB stores flat rows: role, content, toolCallId, toolCalls (JSON).
* AI SDK expects structured messages with content parts.
*
* Per Open Questions #3: only user/assistant messages are sent, tool call
* details from previous turns are appended as text annotations.
*/
function dbMessagesToAIMessages(
dbMessages: Pick<ChatMessageData, 'role' | 'content' | 'toolCalls'>[],
): ModelMessage[] {
const messages: ModelMessage[] = [];
for (const msg of dbMessages) {
if (msg.role === 'user') {
messages.push({ role: 'user', content: msg.content || '' });
} else if (msg.role === 'assistant') {
let content = msg.content || '';
// Append tool-call annotation from previous turns (same as OpenCodeManager)
if (msg.toolCalls) {
try {
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args: unknown }>;
if (calls.length > 0) {
const summary = calls
.map(tc => `- ${tc.name}(${JSON.stringify(tc.args)})`)
.join('\n');
content += `\n\n[Tools used in this turn:\n${summary}\n]`;
}
} catch {
// Ignore malformed tool call JSON
}
}
messages.push({ role: 'assistant', content });
}
// System and tool messages from DB are not sent — system is passed separately,
// tool results are only used within the same request via maxSteps.
}
return messages;
}
// ---------------------------------------------------------------------------
// System prompt augmentation
// ---------------------------------------------------------------------------
/** Append live blog stats to the system prompt for data-volume awareness. */
async function appendBlogStats(
basePrompt: string,
blogToolDeps: BlogToolDeps,
): Promise<string> {
try {
const stats = await blogToolDeps.postEngine.getBlogStats();
const mediaList = await blogToolDeps.mediaEngine.getAllMedia();
if (stats.totalPosts === 0) return basePrompt;
const dateRange = stats.oldestPostDate && stats.newestPostDate
? `from ${stats.oldestPostDate.toISOString().split('T')[0]} to ${stats.newestPostDate.toISOString().split('T')[0]}`
: 'unknown';
const yearBreakdown = Object.entries(stats.postsPerYear)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([year, count]) => `${year}: ${count}`)
.join(', ');
return basePrompt + `
--- CURRENT BLOG DATA SUMMARY ---
Total posts: ${stats.totalPosts} (${stats.publishedCount} published, ${stats.draftCount} drafts, ${stats.archivedCount} archived)
Date range: ${dateRange}
Posts per year: ${yearBreakdown}
Unique tags: ${stats.tagCount}, Unique categories: ${stats.categoryCount}
Total media files: ${mediaList.length}
NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all data. Default page size is 20.`;
} catch {
return basePrompt;
}
}
// ---------------------------------------------------------------------------
// Token estimation (for context truncation)
// ---------------------------------------------------------------------------
function estimateTokens(text: string): number {
return Math.ceil(text.length / 3.5);
}
/**
* Drop oldest user+assistant pairs to fit within context budget.
* Preserves the most recent messages for continuity.
*/
function truncateMessages(
messages: ModelMessage[],
systemPrompt: string,
toolsJson: string,
maxContextTokens: number,
): ModelMessage[] {
const systemTokens = estimateTokens(systemPrompt);
const toolsTokens = estimateTokens(toolsJson);
const responseReserve = 4096;
const availableBudget = maxContextTokens - systemTokens - toolsTokens - responseReserve;
if (availableBudget <= 0) return messages.slice(-1);
const messageTokens = () =>
messages.reduce((sum, m) => sum + estimateTokens(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)), 0);
if (messageTokens() <= availableBudget) return messages;
let truncated = [...messages];
while (truncated.length > 2 && messageTokens.call(null) > availableBudget) {
if (truncated[0].role === 'user') {
truncated = truncated.slice(2); // Drop user + assistant pair
} else {
truncated = truncated.slice(1);
}
}
return truncated;
}
// ---------------------------------------------------------------------------
// ChatService
// ---------------------------------------------------------------------------
export class ChatService {
private chatEngine: ChatEngine;
private providers: ProviderRegistry;
private blogToolDeps: BlogToolDeps;
private getMainWindow: () => BrowserWindow | null;
// Abort controllers per conversation
private abortControllers = new Map<string, AbortController>();
// Cumulative token usage per conversation
private conversationUsage = new Map<string, {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
}>();
constructor(
chatEngine: ChatEngine,
providers: ProviderRegistry,
blogToolDeps: BlogToolDeps,
getMainWindow: () => BrowserWindow | null,
) {
this.chatEngine = chatEngine;
this.providers = providers;
this.blogToolDeps = blogToolDeps;
this.getMainWindow = getMainWindow;
}
/**
* Send a user message, stream the AI response with tool use.
* This is the main entry point — replaces OpenCodeManager.sendMessage().
*/
async sendMessage(
conversationId: string,
userMessage: string,
callbacks: ChatCallbacks = {},
): Promise<SendResult> {
try {
// Readiness check
if (!this.providers.isReady()) {
return { success: false, error: 'API key not configured' };
}
// Load conversation
const conversation = await this.chatEngine.getConversation(conversationId);
if (!conversation) {
return { success: false, error: 'Conversation not found' };
}
// Add user message to DB
await this.chatEngine.addMessage({
conversationId,
role: 'user',
content: userMessage,
createdAt: new Date(),
});
// Abort controller
const abortController = new AbortController();
this.abortControllers.set(conversationId, abortController);
const modelId = conversation.model || 'claude-sonnet-4';
const provider = detectProvider(modelId);
// Verify provider key is available
if (!this.providers.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` };
}
// Build system prompt with live blog stats
const systemMessage = conversation.messages.find(m => m.role === 'system');
const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
const systemPrompt = await appendBlogStats(basePrompt, this.blogToolDeps);
// Convert DB messages to AI SDK format
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
dbMessages.push({
conversationId,
role: 'user',
content: userMessage,
createdAt: new Date(),
});
const aiMessages = dbMessagesToAIMessages(dbMessages);
// Build tools
const blogTools = createBlogTools(this.blogToolDeps);
const a2uiToolsRaw = createA2UITools();
const allTools = { ...blogTools, ...a2uiToolsRaw };
// Get context window for truncation
const contextWindow = await this.providers.getModelCatalogEngine().getContextWindow(modelId) ?? 150_000;
const truncatedMessages = truncateMessages(
aiMessages,
systemPrompt,
JSON.stringify(Object.keys(allTools)),
contextWindow,
);
// Resolve model
const model = this.providers.resolveModel(modelId);
// Compute turn index for A2UI messages
const turnIndex = dbMessages.filter(m => m.role === 'user').length - 1;
// Track tool calls for response
const allToolCalls: Array<{ name: string; args: unknown }> = [];
// Build Anthropic-specific provider options for cache control
const providerOptions = modelId.startsWith('claude')
? { anthropic: { cacheControl: { type: 'ephemeral' as const } } }
: undefined;
try {
// --- streamText: the AI SDK replaces our entire SSE/accumulator/tool-loop ---
const result = streamText({
model,
system: systemPrompt,
messages: truncatedMessages,
tools: allTools,
stopWhen: stepCountIs(MAX_TOOL_ROUNDS),
abortSignal: abortController.signal,
maxRetries: 3,
providerOptions,
onChunk: ({ chunk }) => {
if (chunk.type === 'text-delta' && callbacks.onDelta) {
callbacks.onDelta(chunk.text);
}
},
onStepFinish: ({ staticToolCalls: stepToolCalls, staticToolResults: stepToolResults }) => {
// Emit tool call/result events for each step
if (stepToolCalls) {
for (const tc of stepToolCalls) {
allToolCalls.push({ name: tc.toolName, args: tc.input });
callbacks.onToolCall?.({ name: tc.toolName, args: tc.input });
}
}
if (stepToolResults) {
for (const tr of stepToolResults) {
const toolName = tr.toolName;
const toolResult = tr.output;
// Handle A2UI render tools
if (isRenderTool(toolName)) {
// Find the matching tool call args
const matchingCall = stepToolCalls?.find(tc => tc.toolName === toolName);
if (matchingCall) {
const a2uiMessages = generateFromToolCall(
conversationId,
toolName,
matchingCall.input as Record<string, unknown>,
);
if (a2uiMessages && callbacks.onA2UIMessage) {
for (const msg of a2uiMessages) {
if (msg.type === 'createSurface') {
msg.metadata = { ...msg.metadata, turnIndex };
}
callbacks.onA2UIMessage(msg);
}
}
}
}
callbacks.onToolResult?.({ name: toolName, result: toolResult });
}
}
},
});
// Consume the stream to completion
const finalResult = await result.response;
// Extract usage from the response
const usage = await result.usage;
this.emitUsage(conversationId, usage, callbacks);
// Get the final text
const fullResponse = await result.text;
// Save assistant response to DB
if (fullResponse) {
await this.chatEngine.addMessage({
conversationId,
role: 'assistant',
content: fullResponse,
toolCalls: allToolCalls.length > 0 ? JSON.stringify(allToolCalls) : undefined,
createdAt: new Date(),
});
}
// Generate title after first user message
const userMsgCount = conversation.messages.filter(m => m.role === 'user').length;
if (userMsgCount === 0 && fullResponse) {
this.generateConversationTitle(conversationId, userMessage).catch(err =>
console.error('[ChatService] Error generating title:', err),
);
}
return {
success: true,
message: fullResponse,
toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
};
} catch (error) {
const isAborted = abortController.signal.aborted || (error as Error).message === 'Request cancelled';
if (!isAborted) throw error;
return { success: true, message: '' };
} finally {
this.abortControllers.delete(conversationId);
}
} catch (error) {
console.error('[ChatService] Error sending message:', error);
return { success: false, error: (error as Error).message };
}
}
/** Abort an in-flight request for a conversation. */
async abortMessage(conversationId: string): Promise<{ success: boolean; error?: string }> {
const controller = this.abortControllers.get(conversationId);
if (!controller) {
return { success: false, error: 'No active request for this conversation' };
}
controller.abort();
this.abortControllers.delete(conversationId);
return { success: true };
}
/** Abort all in-flight requests. */
async stop(): Promise<void> {
for (const [, controller] of this.abortControllers) {
controller.abort();
}
this.abortControllers.clear();
}
// ---- Private helpers ----
/**
* Generate a short conversation title from the first user message.
* Non-streaming one-shot call using the configured title model.
*/
private async generateConversationTitle(
conversationId: string,
userMessage: string,
): Promise<void> {
try {
let titleModel = await this.chatEngine.getSetting('chat_title_model');
// Fallback chain: setting → haiku → mistral-small
if (!titleModel || !this.providers.isProviderKeySet(detectProvider(titleModel))) {
titleModel = this.providers.getOpencodeKey()
? 'claude-haiku-4-5'
: this.providers.getMistralKey()
? 'mistral-small-latest'
: null;
}
if (!titleModel) return;
const model = this.providers.resolveModel(titleModel);
const { text } = await generateText({
model,
system: 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.',
prompt: `Topic: ${userMessage.substring(0, 100)}`,
maxOutputTokens: 20,
maxRetries: 2,
});
let title = text.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, '');
const MAX_TITLE_LENGTH = 30;
if (title.length > MAX_TITLE_LENGTH) {
title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…';
}
if (title) {
await this.chatEngine.updateConversation(conversationId, { title });
const mainWindow = this.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('chat-title-updated', { conversationId, title });
}
}
} catch (error) {
console.error('[ChatService] Error generating title:', error);
}
}
/** Emit per-turn + cumulative token usage. */
private emitUsage(
conversationId: string,
usage: LanguageModelUsage | undefined,
callbacks: ChatCallbacks,
): void {
if (!usage || !callbacks.onTokenUsage) return;
// AI SDK v6 normalizes usage into inputTokens/outputTokens
// Cache tokens are in inputTokenDetails
const inputTokens = usage.inputTokens ?? 0;
const outputTokens = usage.outputTokens ?? 0;
const cacheReadTokens = usage.inputTokenDetails?.cacheReadTokens ?? 0;
const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens ?? 0;
const adjustedInputTokens = inputTokens - cacheReadTokens - cacheWriteTokens;
const totalTokens = inputTokens + outputTokens;
const prev = this.conversationUsage.get(conversationId) || {
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
};
const cumulative = {
inputTokens: prev.inputTokens + adjustedInputTokens,
outputTokens: prev.outputTokens + outputTokens,
cacheReadTokens: prev.cacheReadTokens + cacheReadTokens,
cacheWriteTokens: prev.cacheWriteTokens + cacheWriteTokens,
};
this.conversationUsage.set(conversationId, cumulative);
callbacks.onTokenUsage({
inputTokens: adjustedInputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens,
cumulativeInputTokens: cumulative.inputTokens,
cumulativeOutputTokens: cumulative.outputTokens,
cumulativeCacheReadTokens: cumulative.cacheReadTokens,
cumulativeCacheWriteTokens: cumulative.cacheWriteTokens,
cumulativeTotalTokens: cumulative.inputTokens + cumulative.outputTokens + cumulative.cacheReadTokens + cumulative.cacheWriteTokens,
});
}
}

View File

@@ -0,0 +1,347 @@
/**
* Provider registry — single source of truth for AI provider routing.
*
* Two provider sources:
* 1. OpenCode Zen gateway — routes claude* → Anthropic Messages API,
* everything else → OpenAI Chat Completions API
* 2. Mistral direct — uses Mistral's native API
*
* Model listing uses raw HTTP (AI SDK has no listing API).
*
* IMPORTANT: OpenAI SDK v6 defaults to Responses API (/responses).
* OpenCode Zen only supports Chat Completions. Use provider.chat(modelId).
*/
import { customProvider } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { createMistral } from '@ai-sdk/mistral';
import type { LanguageModel, Provider } from 'ai';
import { ModelCatalogEngine } from '../ModelCatalogEngine';
import type { ChatModel } from '../../shared/electronApi';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
export const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
export const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// ---------------------------------------------------------------------------
// Gateway factory
// ---------------------------------------------------------------------------
/**
* Creates the OpenCode Zen gateway custom provider.
* Routes claude* → Anthropic Messages API, everything else → OpenAI Chat Completions.
*/
export function createOpenCodeGateway(apiKey: string): Provider {
const anthropicProvider = createAnthropic({
baseURL: ZEN_BASE_URL,
apiKey,
});
const openaiProvider = createOpenAI({
baseURL: ZEN_BASE_URL,
apiKey,
});
// Build a ProviderV3 that routes claude* → Anthropic, else → OpenAI Chat Completions
const gatewayRouter: import('@ai-sdk/provider').ProviderV3 = {
specificationVersion: 'v3',
languageModel: (modelId: string) => {
if (modelId.startsWith('claude')) {
return anthropicProvider(modelId);
}
// Use .chat() for Chat Completions — Zen doesn't support Responses API
return openaiProvider.chat(modelId);
},
embeddingModel: () => {
throw new Error('Embeddings not supported via OpenCode gateway');
},
imageModel: () => {
throw new Error('Image models not supported via OpenCode gateway');
},
};
return customProvider({
languageModels: {},
fallbackProvider: gatewayRouter,
});
}
// ---------------------------------------------------------------------------
// Provider detection — shared utility
// ---------------------------------------------------------------------------
/** Determine which provider backend a model ID belongs to. */
export function detectProvider(modelId: string): string {
const id = modelId.toLowerCase();
if (id.startsWith('claude')) return 'anthropic';
if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai';
if (id.startsWith('gemini')) return 'google';
if (
id.startsWith('mistral') ||
id.startsWith('ministral') ||
id.startsWith('devstral') ||
id.startsWith('codestral') ||
id.startsWith('pixtral')
) return 'mistral';
return 'other';
}
// ---------------------------------------------------------------------------
// ProviderRegistry — manages keys, providers, model resolution
// ---------------------------------------------------------------------------
export class ProviderRegistry {
private opencodeKey = '';
private mistralKey = '';
private opencodeGateway: Provider | null = null;
private mistralProvider: ReturnType<typeof createMistral> | null = null;
private modelCatalogEngine = new ModelCatalogEngine();
// Model cache
private cachedModels: ChatModel[] | null = null;
private cachedModelsAt = 0;
// ---- Key management ----
setOpencodeKey(key: string): void {
this.opencodeKey = key;
this.opencodeGateway = null; // rebuild on next use
this.invalidateModelCache();
}
getOpencodeKey(): string {
return this.opencodeKey;
}
setMistralKey(key: string): void {
this.mistralKey = key;
this.mistralProvider = null; // rebuild on next use
this.invalidateModelCache();
}
getMistralKey(): string {
return this.mistralKey;
}
/** Check whether at least one provider key is configured. */
isReady(): boolean {
return !!(this.opencodeKey || this.mistralKey);
}
/** Check whether the key for a specific provider is set. */
isProviderKeySet(provider: string): boolean {
if (provider === 'mistral') return !!this.mistralKey;
return !!this.opencodeKey;
}
/** Returns status of all configured providers. */
getProviderStatus(): { opencode: boolean; mistral: boolean } {
return {
opencode: !!this.opencodeKey,
mistral: !!this.mistralKey,
};
}
// ---- Provider resolution ----
/** Resolve a model ID to an AI SDK LanguageModel. */
resolveModel(modelId: string): LanguageModel {
const provider = detectProvider(modelId);
if (provider === 'mistral') {
if (!this.mistralKey) {
throw new Error(`Mistral API key not configured for model '${modelId}'`);
}
if (!this.mistralProvider) {
this.mistralProvider = createMistral({ apiKey: this.mistralKey });
}
return this.mistralProvider(modelId);
}
// Everything else goes through the OpenCode gateway
if (!this.opencodeKey) {
throw new Error(`OpenCode API key not configured for model '${modelId}'`);
}
if (!this.opencodeGateway) {
this.opencodeGateway = createOpenCodeGateway(this.opencodeKey);
}
return this.opencodeGateway.languageModel(modelId);
}
// ---- Model listing (raw HTTP — AI SDK has no listing API) ----
invalidateModelCache(): void {
this.cachedModels = null;
this.cachedModelsAt = 0;
}
/** Get the model catalog engine for context window lookups. */
getModelCatalogEngine(): ModelCatalogEngine {
return this.modelCatalogEngine;
}
/** Get available models across all configured providers (cached 5 min). */
async getAvailableModels(): Promise<ChatModel[]> {
if (this.cachedModels && Date.now() - this.cachedModelsAt < MODEL_CACHE_TTL) {
return this.cachedModels;
}
const allModels: ChatModel[] = [];
let fetched = false;
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
// Fetch OpenCode models
if (this.opencodeKey) {
try {
const models = await this.fetchModelsFromEndpoint(
ZEN_MODELS_URL,
{ Authorization: `Bearer ${this.opencodeKey}`, 'x-api-key': this.opencodeKey },
catalogVision,
catalogNames,
);
allModels.push(...models);
fetched = true;
} catch {
// Fall through
}
}
// Fetch Mistral models
if (this.mistralKey) {
try {
const models = await this.fetchModelsFromEndpoint(
MISTRAL_MODELS_URL,
{ Authorization: `Bearer ${this.mistralKey}` },
catalogVision,
catalogNames,
'mistral', // only keep mistral-family models
);
allModels.push(...models);
fetched = true;
} catch {
// Fall through
}
}
if (fetched && allModels.length > 0) {
this.cachedModels = allModels;
this.cachedModelsAt = Date.now();
return allModels;
}
// Fallback: model catalog DB, filtered by available provider keys
return this.getModelsFromCatalog();
}
/** Validate an OpenCode API key against the models endpoint. */
async validateOpencodeKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> {
if (!apiKey || apiKey.length < 3) return { isValid: false, models: [] };
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
const headerSets: Record<string, string>[] = [
{ Authorization: `Bearer ${apiKey}` },
{ 'x-api-key': apiKey },
];
for (const headers of headerSets) {
try {
const models = await this.fetchModelsFromEndpoint(
ZEN_MODELS_URL, headers, catalogVision, catalogNames,
);
return { isValid: true, models };
} catch {
// Try next
}
}
return { isValid: false, models: [] };
}
/** Validate a Mistral API key against the Mistral models endpoint. */
async validateMistralKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> {
if (!apiKey || apiKey.length < 3) return { isValid: false, models: [] };
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
try {
const models = await this.fetchModelsFromEndpoint(
MISTRAL_MODELS_URL,
{ Authorization: `Bearer ${apiKey}` },
catalogVision,
catalogNames,
'mistral',
);
return { isValid: true, models };
} catch {
return { isValid: false, models: [] };
}
}
// ---- Private helpers ----
private async fetchModelsFromEndpoint(
url: string,
headers: Record<string, string>,
catalogVision: Map<string, boolean>,
catalogNames: Map<string, string>,
filterProvider?: string,
): Promise<ChatModel[]> {
const response = await fetch(url, { method: 'GET', headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json() as { data?: Array<{ id: string }> };
if (!data.data || !Array.isArray(data.data)) return [];
let models = data.data;
if (filterProvider) {
models = models.filter(m => detectProvider(m.id) === filterProvider);
}
return models.map(m => ({
id: m.id,
name: catalogNames.get(m.id) ?? m.id,
provider: detectProvider(m.id),
vision: catalogVision.get(m.id) ?? false,
}));
}
private async getCatalogLookups(): Promise<{ vision: Map<string, boolean>; names: Map<string, string> }> {
const vision = new Map<string, boolean>();
const names = new Map<string, string>();
try {
const catalog = await this.modelCatalogEngine.getAll();
for (const m of catalog) {
vision.set(m.id, m.inputModalities.includes('image'));
names.set(m.id, m.name);
}
} catch {
// Catalog unavailable
}
return { vision, names };
}
private async getModelsFromCatalog(): Promise<ChatModel[]> {
try {
const catalog = await this.modelCatalogEngine.getAll();
if (catalog.length > 0) {
return catalog
.map(m => ({
id: m.id,
name: m.name,
provider: detectProvider(m.id),
vision: m.inputModalities.includes('image'),
}))
.filter(m => this.isProviderKeySet(m.provider));
}
} catch {
// Fall through
}
return [];
}
}

258
src/main/engine/ai/tasks.ts Normal file
View File

@@ -0,0 +1,258 @@
/**
* OneShotTasks — non-streaming AI tasks using generateText().
*
* Replaces OpenCodeManager.analyzeTaxonomy() and analyzeMediaImage()
* with provider-agnostic AI SDK calls.
*/
import { generateText } from 'ai';
import type { ChatEngine } from '../ChatEngine';
import type { MediaEngine } from '../MediaEngine';
import { ProviderRegistry, detectProvider } from './providers';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TaxonomyAnalysisResult {
success: boolean;
categoryMappings?: Record<string, string>;
tagMappings?: Record<string, string>;
error?: string;
}
export interface ImageAnalysisResult {
success: boolean;
title?: string;
alt?: string;
caption?: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Language map for image analysis prompts
// ---------------------------------------------------------------------------
const LANGUAGE_NAMES: Record<string, string> = {
en: 'English', de: 'German', es: 'Spanish', fr: 'French', it: 'Italian',
pt: 'Portuguese', nl: 'Dutch', pl: 'Polish', ru: 'Russian', ja: 'Japanese',
zh: 'Chinese', ko: 'Korean', ar: 'Arabic', hi: 'Hindi', tr: 'Turkish',
sv: 'Swedish', da: 'Danish', no: 'Norwegian', fi: 'Finnish', cs: 'Czech',
};
// ---------------------------------------------------------------------------
// OneShotTasks
// ---------------------------------------------------------------------------
export class OneShotTasks {
private providers: ProviderRegistry;
private chatEngine: ChatEngine;
private mediaEngine: MediaEngine;
constructor(
providers: ProviderRegistry,
chatEngine: ChatEngine,
mediaEngine: MediaEngine,
) {
this.providers = providers;
this.chatEngine = chatEngine;
this.mediaEngine = mediaEngine;
}
/**
* Analyze taxonomy items from a WordPress import and suggest mappings
* from NEW items to EXISTING items to avoid duplicates.
*/
async analyzeTaxonomy(
categories: Array<{ name: string; slug: string; existsInProject: boolean }>,
tags: Array<{ name: string; slug: string; existsInProject: boolean }>,
modelId: string,
): Promise<TaxonomyAnalysisResult> {
const provider = detectProvider(modelId);
if (!this.providers.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `${providerLabel} API key not set` };
}
const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name);
const newCategories = categories.filter(c => !c.existsInProject).map(c => c.name);
const existingTags = tags.filter(t => t.existsInProject).map(t => t.name);
const newTags = tags.filter(t => !t.existsInProject).map(t => t.name);
const systemPrompt = `You are an expert at analyzing taxonomy terms (tags and categories) for a blog import system.
Your task is to identify NEW tags/categories from an import that should be mapped to EXISTING tags/categories in the project to avoid creating duplicates.
CRITICAL RULES:
1. ONLY map NEW items to EXISTING items - never map new to new
2. The goal is to prevent duplicate creation, NOT to reduce the number of new items
3. A new item should only map to an existing item if they represent the same concept
4. Consider language differences: a new tag can match an existing tag in a different language (e.g., "Photography" should map to "Fotografie" if that exists)
5. Consider variations like: different casing, singular/plural, abbreviations, hyphenation, synonyms
6. Only suggest mappings where there is a clear semantic match - not every new item needs a mapping
EXAMPLES OF VALID MAPPINGS (new → existing):
- "Photos" → "Photography" (if Photography exists)
- "Fotografie" → "Photography" (language variation, if Photography exists)
- "tech" → "Technology" (abbreviation, if Technology exists)
- "Web Dev" → "Web Development" (abbreviation, if Web Development exists)
DO NOT:
- Map a new item to another new item
- Suggest mappings just because items are in the same topic area
- Create mappings for items that are distinct concepts
RESPONSE FORMAT:
You MUST respond with valid JSON only, no other text. Use this exact structure:
{
"categoryMappings": { "New Category": "Existing Category", ... },
"tagMappings": { "New Tag": "Existing Tag", ... }
}
The source (key) MUST be from the NEW items list, and the target (value) MUST be from the EXISTING items list.
If there are no sensible mappings to suggest, return empty objects.`;
const userPrompt = `Analyze these taxonomy items from a WordPress import. Identify NEW items that should be mapped to EXISTING items to avoid duplicates.
EXISTING CATEGORIES IN PROJECT (map TO these):
${existingCategories.length > 0 ? existingCategories.join(', ') : '(none)'}
NEW CATEGORIES FROM IMPORT (map FROM these):
${newCategories.length > 0 ? newCategories.join(', ') : '(none)'}
EXISTING TAGS IN PROJECT (map TO these):
${existingTags.length > 0 ? existingTags.join(', ') : '(none)'}
NEW TAGS FROM IMPORT (map FROM these):
${newTags.length > 0 ? newTags.join(', ') : '(none)'}
Remember: Only suggest mappings from NEW items to EXISTING items. Consider language differences (e.g., German/English equivalents). Response must be valid JSON only.`;
try {
const model = this.providers.resolveModel(modelId);
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: 4096,
maxRetries: 2,
});
// Extract and parse JSON from response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
return { success: false, error: 'Invalid response format from AI' };
}
const result = JSON.parse(jsonMatch[0]);
// Validate mappings: only new→existing allowed
const validatedCategoryMappings: Record<string, string> = {};
const validatedTagMappings: Record<string, string> = {};
const newCatSet = new Set(newCategories);
const existingCatSet = new Set(existingCategories);
for (const [source, target] of Object.entries(result.categoryMappings || {})) {
if (newCatSet.has(source) && existingCatSet.has(target as string)) {
validatedCategoryMappings[source] = target as string;
}
}
const newTagSet = new Set(newTags);
const existingTagSet = new Set(existingTags);
for (const [source, target] of Object.entries(result.tagMappings || {})) {
if (newTagSet.has(source) && existingTagSet.has(target as string)) {
validatedTagMappings[source] = target as string;
}
}
return {
success: true,
categoryMappings: validatedCategoryMappings,
tagMappings: validatedTagMappings,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Analyze an image and generate title, alt text, and caption.
* Uses multimodal input — AI SDK handles the provider-specific format.
*/
async analyzeMediaImage(
mediaId: string,
language: string = 'en',
): Promise<ImageAnalysisResult> {
// Determine model with smart fallback
let modelId = await this.chatEngine.getSetting('chat_image_analysis_model');
if (!modelId || !this.providers.isProviderKeySet(detectProvider(modelId))) {
modelId = this.providers.getOpencodeKey()
? 'claude-sonnet-4-5'
: this.providers.getMistralKey()
? 'mistral-large-latest'
: null;
}
if (!modelId) {
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
}
// Get media metadata
const mediaItem = await this.mediaEngine.getMedia(mediaId);
if (!mediaItem) return { success: false, error: 'Media item not found' };
if (!mediaItem.mimeType.startsWith('image/')) {
return { success: false, error: `Cannot analyze this file type: ${mediaItem.mimeType}. Only images are supported.` };
}
// Get thumbnail
let dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'large');
if (!dataUrl) dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'medium');
if (!dataUrl) {
return { success: false, error: 'Image thumbnail not available. Try regenerating thumbnails from Settings.' };
}
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const languageName = LANGUAGE_NAMES[language] || language;
const systemPrompt = `Generate title, alt text, and caption for this image in ${languageName}.
TITLE: A short, descriptive title for display in lists and search results (3-8 words). Should identify the main subject.
ALT: Describe ONLY what is visually present in the image. Be factual, neutral, and concise (5-12 words max). No interpretations, emotions, or "Image of" prefix. Example: "Red bicycle leaning against white brick wall"
CAPTION: Short, engaging blog caption (5-20 words).
Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
try {
const model = this.providers.resolveModel(modelId);
// AI SDK handles provider-specific multimodal format automatically
const { text } = await generateText({
model,
system: systemPrompt,
messages: [{
role: 'user',
content: [
{ type: 'image', image: `data:image/webp;base64,${base64Data}` },
{ type: 'text', text: 'Analyze and respond with JSON.' },
],
}],
maxOutputTokens: 200,
maxRetries: 2,
});
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
const result = JSON.parse(jsonMatch[0]);
return {
success: true,
title: result.title || undefined,
alt: result.alt || undefined,
caption: result.caption || undefined,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
}

View File

@@ -1,18 +1,25 @@
/**
* Chat IPC handlers - AI chat functionality using OpenCode Zen API
* Chat IPC handlers AI chat via AI SDK v6.
*
* Uses ProviderRegistry, ChatService, and OneShotTasks instead of OpenCodeManager.
*/
import { ipcMain, BrowserWindow } from 'electron';
import { ChatEngine } from '../engine/ChatEngine';
import { OpenCodeManager } from '../engine/OpenCodeManager';
import { SecureKeyStore } from '../engine/SecureKeyStore';
import { ProviderRegistry } from '../engine/ai/providers';
import { ChatService } from '../engine/ai/chat';
import { OneShotTasks } from '../engine/ai/tasks';
import { getDatabase } from '../database';
import type { EngineBundle } from '../engine/EngineBundle';
import type { BlogToolDeps } from '../engine/ai/blog-tools';
let chatEngine: ChatEngine | null = null;
let openCodeManager: OpenCodeManager | null = null;
let secureKeyStore: SecureKeyStore | null = null;
let openCodeManagerInitPromise: Promise<void> | null = null;
let providers: ProviderRegistry | null = null;
let chatService: ChatService | null = null;
let oneShotTasks: OneShotTasks | null = null;
let initPromise: Promise<void> | null = null;
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
let engineBundle: EngineBundle | null = null;
@@ -45,58 +52,66 @@ function getChatEngine(): ChatEngine {
}
/**
* Get or create the OpenCodeManager instance.
* Returns a promise that resolves when the manager is fully initialized
* (including loading the API key from settings).
* Get the ProviderRegistry (lazy-init + load keys from encrypted storage).
*/
async function getOpenCodeManager(): Promise<OpenCodeManager> {
if (!openCodeManager) {
openCodeManager = new OpenCodeManager(
getChatEngine(),
engineBundle!.postEngine,
engineBundle!.mediaEngine,
engineBundle!.postMediaEngine,
() => mainWindowGetter?.() || null
);
function getProviders(): ProviderRegistry {
if (!providers) {
providers = new ProviderRegistry();
}
return providers;
}
// Load API key from encrypted storage
/**
* Get the ChatService (lazy-init).
*/
function getChatService(): ChatService {
if (!chatService) {
const engine = getChatEngine();
const reg = getProviders();
const deps: BlogToolDeps = {
postEngine: engineBundle!.postEngine,
mediaEngine: engineBundle!.mediaEngine,
postMediaEngine: engineBundle!.postMediaEngine,
};
chatService = new ChatService(engine, reg, deps, () => mainWindowGetter?.() || null);
}
return chatService;
}
/**
* Get the OneShotTasks helper (lazy-init).
*/
function getOneShotTasks(): OneShotTasks {
if (!oneShotTasks) {
oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine);
}
return oneShotTasks;
}
/**
* Ensure API keys are loaded from encrypted storage exactly once.
*/
async function ensureInitialized(): Promise<void> {
if (!initPromise) {
const reg = getProviders();
const keyStore = getSecureKeyStore();
openCodeManagerInitPromise = (async () => {
// Clean up old plain-text key from settings (pre-keychain storage)
try {
await keyStore.cleanupPlainTextKey('opencode_api_key');
} catch {
// Best-effort cleanup; not critical
}
// Load API key from encrypted storage
initPromise = (async () => {
// Clean up old plain-text key from settings (pre-keychain storage)
try { await keyStore.cleanupPlainTextKey('opencode_api_key'); } catch { /* best-effort */ }
try {
const key = await keyStore.retrieve('opencode_api_key');
if (key) {
openCodeManager!.setApiKey(key);
}
} catch {
// Silently ignore errors loading the key
}
if (key) reg.setOpencodeKey(key);
} catch { /* ignore */ }
// Load Mistral API key from encrypted storage
try {
const mistralKey = await keyStore.retrieve('mistral_api_key');
if (mistralKey) {
openCodeManager!.setMistralApiKey(mistralKey);
}
} catch {
// Silently ignore errors loading the Mistral key
}
if (mistralKey) reg.setMistralKey(mistralKey);
} catch { /* ignore */ }
})();
}
// Always wait for initialization to complete before returning
if (openCodeManagerInitPromise) {
await openCodeManagerInitPromise;
}
return openCodeManager;
await initPromise;
}
/**
@@ -108,13 +123,14 @@ export function registerChatHandlers(): void {
// Check if service is ready
ipcMain.handle('chat:checkReady', async () => {
try {
const manager = await getOpenCodeManager();
const result = await manager.checkReady();
await ensureInitialized();
const reg = getProviders();
const ready = reg.isReady();
return {
ready: result.ready,
error: result.error,
ready,
error: ready ? undefined : 'API key not configured',
backend: 'opencode',
providers: result.providers,
providers: reg.getProviderStatus(),
};
} catch (error) {
console.error('[Chat IPC] Error checking ready:', error);
@@ -125,9 +141,9 @@ export function registerChatHandlers(): void {
// Validate API key
ipcMain.handle('chat:validateApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const result = await manager.validateApiKey(apiKey);
return result;
await ensureInitialized();
const reg = getProviders();
return await reg.validateOpencodeKey(apiKey);
} catch (error) {
console.error('[Chat IPC] Error validating API key:', error);
return { isValid: false, models: [] };
@@ -137,15 +153,16 @@ export function registerChatHandlers(): void {
// Set API key
ipcMain.handle('chat:setApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const previousKey = manager.getApiKey();
manager.setApiKey(apiKey);
await ensureInitialized();
const reg = getProviders();
const previousKey = reg.getOpencodeKey();
reg.setOpencodeKey(apiKey);
// Persist to encrypted storage — roll back in-memory key on failure
try {
await getSecureKeyStore().store('opencode_api_key', apiKey);
} catch (storeError) {
manager.setApiKey(previousKey);
reg.setOpencodeKey(previousKey);
throw storeError;
}
@@ -159,10 +176,9 @@ export function registerChatHandlers(): void {
// Get API key (masked)
ipcMain.handle('chat:getApiKey', async () => {
try {
const manager = await getOpenCodeManager();
const key = manager.getApiKey();
await ensureInitialized();
const key = getProviders().getOpencodeKey();
if (!key) return { hasKey: false, maskedKey: '' };
// Mask all but last 4 characters
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
} catch (error) {
@@ -176,9 +192,8 @@ export function registerChatHandlers(): void {
// Validate Mistral API key
ipcMain.handle('chat:validateMistralApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const result = await manager.validateMistralApiKey(apiKey);
return result;
await ensureInitialized();
return await getProviders().validateMistralKey(apiKey);
} catch (error) {
console.error('[Chat IPC] Error validating Mistral API key:', error);
return { isValid: false, models: [] };
@@ -188,15 +203,16 @@ export function registerChatHandlers(): void {
// Set Mistral API key
ipcMain.handle('chat:setMistralApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const previousKey = manager.getMistralApiKey();
manager.setMistralApiKey(apiKey);
await ensureInitialized();
const reg = getProviders();
const previousKey = reg.getMistralKey();
reg.setMistralKey(apiKey);
// Persist to encrypted storage — roll back in-memory key on failure
try {
await getSecureKeyStore().store('mistral_api_key', apiKey);
} catch (storeError) {
manager.setMistralApiKey(previousKey);
reg.setMistralKey(previousKey);
throw storeError;
}
@@ -210,8 +226,8 @@ export function registerChatHandlers(): void {
// Get Mistral API key (masked)
ipcMain.handle('chat:getMistralApiKey', async () => {
try {
const manager = await getOpenCodeManager();
const key = manager.getMistralApiKey();
await ensureInitialized();
const key = getProviders().getMistralKey();
if (!key) return { hasKey: false, maskedKey: '' };
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
@@ -276,8 +292,8 @@ export function registerChatHandlers(): void {
// Get available models
ipcMain.handle('chat:getAvailableModels', async () => {
try {
const manager = await getOpenCodeManager();
const models = await manager.getAvailableModels();
await ensureInitialized();
const models = await getProviders().getAvailableModels();
const engine = getChatEngine();
const selectedModel = await engine.getSelectedModel();
return { success: true, models, selectedModel };
@@ -328,11 +344,12 @@ export function registerChatHandlers(): void {
// Refresh model catalog from models.dev (conditional GET with ETag)
ipcMain.handle('chat:refreshModelCatalog', async () => {
try {
const manager = await getOpenCodeManager();
const result = await manager.getModelCatalogEngine().refresh();
await ensureInitialized();
const reg = getProviders();
const result = await reg.getModelCatalogEngine().refresh();
// Invalidate the in-memory model cache so vision/name data
// from the freshly populated catalog is picked up immediately.
manager.invalidateModelCache();
reg.invalidateModelCache();
return result;
} catch (error) {
console.error('[Chat IPC] Error refreshing model catalog:', error);
@@ -343,8 +360,8 @@ export function registerChatHandlers(): void {
// Get all model catalog entries
ipcMain.handle('chat:getModelCatalog', async () => {
try {
const manager = await getOpenCodeManager();
const entries = await manager.getModelCatalogEngine().getAll();
await ensureInitialized();
const entries = await getProviders().getModelCatalogEngine().getAll();
return { success: true, entries };
} catch (error) {
console.error('[Chat IPC] Error getting model catalog:', error);
@@ -423,13 +440,13 @@ export function registerChatHandlers(): void {
// ============ Chat Messaging ============
// Send a message
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, metadata?: { surface?: 'tab' | 'sidebar' }) => {
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, _metadata?: { surface?: 'tab' | 'sidebar' }) => {
try {
const manager = await getOpenCodeManager();
await ensureInitialized();
const service = getChatService();
const mainWindow = mainWindowGetter?.();
const result = await manager.sendMessage(conversationId, message, {
metadata,
const result = await service.sendMessage(conversationId, message, {
onDelta: (delta) => {
if (mainWindow) {
mainWindow.webContents.send('chat-stream-delta', { conversationId, delta });
@@ -483,8 +500,8 @@ export function registerChatHandlers(): void {
// Abort a running message
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
try {
const manager = await getOpenCodeManager();
return await manager.abortMessage(conversationId);
await ensureInitialized();
return await getChatService().abortMessage(conversationId);
} catch (error) {
console.error('[Chat IPC] Error aborting message:', error);
return { success: false, error: (error as Error).message };
@@ -531,8 +548,8 @@ export function registerChatHandlers(): void {
// Analyze taxonomy items (tags/categories) and suggest mappings
ipcMain.handle('chat:analyzeTaxonomy', async (_, categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => {
try {
const manager = await getOpenCodeManager();
return await manager.analyzeTaxonomy(categories, tags, modelId);
await ensureInitialized();
return await getOneShotTasks().analyzeTaxonomy(categories, tags, modelId);
} catch (error) {
console.error('[Chat IPC] Error analyzing taxonomy:', error);
return { success: false, error: (error as Error).message };
@@ -544,8 +561,8 @@ export function registerChatHandlers(): void {
// Analyze a media image and generate title, alt text, and caption
ipcMain.handle('chat:analyzeMediaImage', async (_, mediaId: string, language?: string) => {
try {
const manager = await getOpenCodeManager();
return await manager.analyzeMediaImage(mediaId, language || 'en');
await ensureInitialized();
return await getOneShotTasks().analyzeMediaImage(mediaId, language || 'en');
} catch (error) {
console.error('[Chat IPC] Error analyzing media image:', error);
return { success: false, error: (error as Error).message };
@@ -571,11 +588,13 @@ export function registerChatHandlers(): void {
* Cleanup chat resources
*/
export async function cleanupChatHandlers(): Promise<void> {
if (openCodeManager) {
await openCodeManager.stop();
openCodeManager = null;
if (chatService) {
await chatService.stop();
chatService = null;
}
openCodeManagerInitPromise = null;
initPromise = null;
providers = null;
oneShotTasks = null;
secureKeyStore = null;
chatEngine = null;
}