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 };
}
}
}