| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- import 'server-only'
- import { aiSetup } from '@/features/integrations/Lib/ai'
- import { localeNames, type Locale } from '@/i18n/config'
- import OpenAI from 'openai'
- import { describeAiError } from '@/lib/ai-error'
- import {
- ANTHROPIC_BASE,
- ANTHROPIC_VERSION,
- OPENAI_BASE,
- normalizeBaseUrl,
- } from '@/integrations/ai/models'
- interface AiConfig {
- provider: string
- apiKey: string
- model: string
- /** API root of an OpenAI-compatible server, for the connector that takes one. */
- baseUrl?: string
- }
- /**
- * The AI vendor this workshop sends to. Either an active connection in the
- * integrations catalog, or the settings a workshop saved before AI moved
- * there, adopted into one on this first call.
- */
- export async function getAiConfig(organizationId: string): Promise<AiConfig> {
- const setup = await aiSetup(organizationId)
- if (!setup) {
- throw new Error('AI is not connected. Connect an AI provider in Settings → Integrations.')
- }
- return {
- provider: setup.provider,
- apiKey: setup.apiKey,
- model: setup.model,
- ...(setup.baseUrl && { baseUrl: setup.baseUrl }),
- }
- }
- /**
- * Every provider gets its address spelled out. Left blank, the SDK would
- * read OPENAI_BASE_URL from the environment on its own, and completions
- * would quietly go somewhere the connector's key check never looked.
- */
- export function createClient(config: AiConfig): OpenAI {
- if (config.provider === 'anthropic') {
- return new OpenAI({
- apiKey: config.apiKey,
- baseURL: `${ANTHROPIC_BASE}/`,
- defaultHeaders: {
- 'anthropic-version': ANTHROPIC_VERSION,
- },
- })
- }
- if (config.provider === 'openai-compatible') {
- return new OpenAI({
- // The SDK refuses to start without a key; a server that wants none
- // ignores whatever the header says.
- apiKey: config.apiKey || 'none',
- baseURL: normalizeBaseUrl(config.baseUrl ?? ''),
- })
- }
- return new OpenAI({ apiKey: config.apiKey, baseURL: OPENAI_BASE })
- }
- /**
- * OpenAI's reasoning models (the o-series and the GPT-5 family) reject the
- * classic `max_tokens` parameter with a 400 and only accept
- * `max_completion_tokens`; they likewise refuse any temperature other than the
- * default. Every current OpenAI chat model accepts `max_completion_tokens`, so
- * it is used across the board there. The Anthropic compatibility endpoint
- * keeps the classic parameter, and so does a self-hosted OpenAI-compatible
- * server: Ollama and LocalAI still only read `max_tokens`.
- *
- * Reasoning models spend billed-but-hidden thinking tokens inside the same
- * cap before producing a visible answer, so they get headroom on top of the
- * requested answer budget; without it the reply comes back truncated or empty.
- */
- const REASONING_HEADROOM = 4000
- function isReasoningModel(model: string): boolean {
- return /^(o\d|gpt-5)/i.test(model)
- }
- export function completionTuning(
- config: AiConfig,
- maxTokens: number,
- temperature?: number
- ): { max_tokens?: number; max_completion_tokens?: number; temperature?: number } {
- if (config.provider !== 'openai') {
- return { max_tokens: maxTokens, ...(temperature !== undefined && { temperature }) }
- }
- const reasoning = isReasoningModel(config.model)
- return {
- max_completion_tokens: reasoning ? maxTokens + REASONING_HEADROOM : maxTokens,
- ...(temperature !== undefined && !reasoning && { temperature }),
- }
- }
- function languageInstruction(locale: Locale): string {
- if (locale === 'en') return ''
- const name = localeNames[locale] || locale
- return `\n\nIMPORTANT: You MUST respond entirely in ${name}.`
- }
- async function chatCompletion(
- organizationId: string,
- systemPrompt: string,
- userPrompt: string
- ): Promise<string> {
- const config = await getAiConfig(organizationId)
- const client = createClient(config)
- try {
- const response = await client.chat.completions.create({
- model: config.model,
- messages: [
- { role: 'system', content: systemPrompt },
- { role: 'user', content: userPrompt },
- ],
- ...completionTuning(config, 2000, 0.7),
- })
- return response.choices[0]?.message?.content ?? ''
- } catch (error) {
- console.error('[ai] chat completion failed:', error)
- throw new Error(describeAiError(error))
- }
- }
- export async function visionCompletion(
- organizationId: string,
- systemPrompt: string,
- userText: string,
- imageUrls: string | string[]
- ): Promise<string> {
- const config = await getAiConfig(organizationId)
- const client = createClient(config)
- const urls = Array.isArray(imageUrls) ? imageUrls : [imageUrls]
- const content: Array<
- { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }
- > = [
- { type: 'text', text: userText },
- ...urls.map((url) => ({ type: 'image_url' as const, image_url: { url } })),
- ]
- try {
- const response = await client.chat.completions.create({
- model: config.model,
- messages: [
- { role: 'system', content: systemPrompt },
- { role: 'user', content },
- ],
- ...completionTuning(config, 1000, 0.3),
- })
- return response.choices[0]?.message?.content ?? ''
- } catch (error) {
- console.error('[ai] vision completion failed:', error)
- throw new Error(describeAiError(error))
- }
- }
- // ─── AI Feature Functions ────────────────────────────────────────────────────
- export interface ServiceContext {
- vehicleMake: string | null
- vehicleModel: string | null
- vehicleYear: number | null
- licensePlate?: string | null
- serviceType: string
- serviceTitle: string
- parts: { name: string; quantity: number }[]
- labor: { description: string; hours: number }[]
- }
- export async function generateServiceDescription(
- organizationId: string,
- context: ServiceContext,
- locale: Locale = 'en'
- ): Promise<string> {
- const systemPrompt = `You are a professional automotive service writer. Generate a clear, professional service description for a customer-facing invoice. Be concise but thorough. Do not include pricing. Write in plain text, no markdown.${languageInstruction(locale)}`
- const partsStr =
- context.parts.length > 0
- ? context.parts.map((p) => `- ${p.name} (qty: ${p.quantity})`).join('\n')
- : 'No parts listed'
- const laborStr =
- context.labor.length > 0
- ? context.labor.map((l) => `- ${l.description} (${l.hours}h)`).join('\n')
- : 'No labor listed'
- const vehicleStr = context.vehicleMake
- ? `${context.vehicleYear} ${context.vehicleMake} ${context.vehicleModel}${context.licensePlate ? ` (${context.licensePlate})` : ''}`
- : 'None (parts-only sale, no vehicle)'
- const userPrompt = `Vehicle: ${vehicleStr}
- Service type: ${context.serviceType}
- Title: ${context.serviceTitle}
- Parts used:
- ${partsStr}
- Labor performed:
- ${laborStr}
- Write a professional service description and diagnostic notes for the invoice.`
- return chatCompletion(organizationId, systemPrompt, userPrompt)
- }
- export interface ServiceHistoryRecord {
- title: string
- description: string | null
- serviceDate: Date | null
- startDateTime?: Date | null
- type: string
- cost: number
- mileage: number | null
- }
- export async function summarizeServiceHistory(
- organizationId: string,
- vehicle: { make: string; model: string; year: number; licensePlate?: string | null },
- records: ServiceHistoryRecord[],
- locale: Locale = 'en'
- ): Promise<string> {
- const systemPrompt = `You are an automotive service advisor. Summarize a vehicle's complete service history as structured JSON.
- Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
- {"overview":"1-2 sentence general condition summary","majorWork":[{"title":"Work title","date":"YYYY-MM-DD or null","cost":0}],"recurringIssues":[{"title":"Issue name","description":"Brief explanation of the pattern"}],"upcomingMaintenance":[{"item":"Maintenance item","urgency":"high|medium|low","reason":"Why it's needed"}]}
- majorWork: list the 3-5 most significant services performed (most recent first).
- recurringIssues: list any patterns or repeated problems with title and description (empty array if none).
- upcomingMaintenance: predict 2-4 likely upcoming needs based on mileage, age, and service history.${languageInstruction(locale)}`
- const recordsStr = records
- .map(
- (r) =>
- `- ${r.serviceDate ? new Date(r.startDateTime ?? r.serviceDate).toISOString().slice(0, 10) : 'Unknown date'}: ${r.title} (${r.type}) — Cost: ${r.cost}${r.mileage ? `, Mileage: ${r.mileage}` : ''}${r.description ? `\n Notes: ${r.description}` : ''}`
- )
- .join('\n')
- const userPrompt = `Vehicle: ${vehicle.year} ${vehicle.make} ${vehicle.model}${vehicle.licensePlate ? ` (${vehicle.licensePlate})` : ''}
- Service history (${records.length} records):
- ${recordsStr || 'No service records found.'}
- Return JSON only.`
- return chatCompletion(organizationId, systemPrompt, userPrompt)
- }
- export async function getCommonIssues(
- organizationId: string,
- vehicle: { make: string; model: string; year: number },
- locale: Locale = 'en'
- ): Promise<string> {
- const systemPrompt = `You are an expert automotive technician. Return a JSON array of exactly 5 critical known issues for the requested vehicle, sorted by severity and cost (highest first). Only serious, well-documented problems.
- Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
- [{"title":"Issue name","description":"1-2 sentence explanation","cost":"X,XXX–X,XXX","risk":"safety|engine|transmission|electrical|other","severity":5}]
- severity is 1-5 based on how widely reported the issue is (5 = extremely common/well-documented, affects majority of vehicles; 1 = rare but critical).${languageInstruction(locale)}`
- const userPrompt = `What are the 5 most critical and common issues with the ${vehicle.year} ${vehicle.make} ${vehicle.model}? Return JSON only.`
- return chatCompletion(organizationId, systemPrompt, userPrompt)
- }
- export async function testAiConnection(organizationId: string): Promise<boolean> {
- const config = await getAiConfig(organizationId)
- const client = createClient(config)
- const response = await client.chat.completions.create({
- model: config.model,
- messages: [{ role: 'user', content: 'Say OK' }],
- ...completionTuning(config, 5),
- })
- return !!response.choices[0]?.message?.content
- }
|