Phase 2: providers + chat + tasks + IPC rewire
This commit is contained in:
513
src/main/engine/ai/chat.ts
Normal file
513
src/main/engine/ai/chat.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
347
src/main/engine/ai/providers.ts
Normal file
347
src/main/engine/ai/providers.ts
Normal 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
258
src/main/engine/ai/tasks.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user