ai.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import 'server-only'
  2. import { aiSetup } from '@/features/integrations/Lib/ai'
  3. import { localeNames, type Locale } from '@/i18n/config'
  4. import OpenAI from 'openai'
  5. import { describeAiError } from '@/lib/ai-error'
  6. import {
  7. ANTHROPIC_BASE,
  8. ANTHROPIC_VERSION,
  9. OPENAI_BASE,
  10. normalizeBaseUrl,
  11. } from '@/integrations/ai/models'
  12. interface AiConfig {
  13. provider: string
  14. apiKey: string
  15. model: string
  16. /** API root of an OpenAI-compatible server, for the connector that takes one. */
  17. baseUrl?: string
  18. }
  19. /**
  20. * The AI vendor this workshop sends to. Either an active connection in the
  21. * integrations catalog, or the settings a workshop saved before AI moved
  22. * there, adopted into one on this first call.
  23. */
  24. export async function getAiConfig(organizationId: string): Promise<AiConfig> {
  25. const setup = await aiSetup(organizationId)
  26. if (!setup) {
  27. throw new Error('AI is not connected. Connect an AI provider in Settings → Integrations.')
  28. }
  29. return {
  30. provider: setup.provider,
  31. apiKey: setup.apiKey,
  32. model: setup.model,
  33. ...(setup.baseUrl && { baseUrl: setup.baseUrl }),
  34. }
  35. }
  36. /**
  37. * Every provider gets its address spelled out. Left blank, the SDK would
  38. * read OPENAI_BASE_URL from the environment on its own, and completions
  39. * would quietly go somewhere the connector's key check never looked.
  40. */
  41. export function createClient(config: AiConfig): OpenAI {
  42. if (config.provider === 'anthropic') {
  43. return new OpenAI({
  44. apiKey: config.apiKey,
  45. baseURL: `${ANTHROPIC_BASE}/`,
  46. defaultHeaders: {
  47. 'anthropic-version': ANTHROPIC_VERSION,
  48. },
  49. })
  50. }
  51. if (config.provider === 'openai-compatible') {
  52. return new OpenAI({
  53. // The SDK refuses to start without a key; a server that wants none
  54. // ignores whatever the header says.
  55. apiKey: config.apiKey || 'none',
  56. baseURL: normalizeBaseUrl(config.baseUrl ?? ''),
  57. })
  58. }
  59. return new OpenAI({ apiKey: config.apiKey, baseURL: OPENAI_BASE })
  60. }
  61. /**
  62. * OpenAI's reasoning models (the o-series and the GPT-5 family) reject the
  63. * classic `max_tokens` parameter with a 400 and only accept
  64. * `max_completion_tokens`; they likewise refuse any temperature other than the
  65. * default. Every current OpenAI chat model accepts `max_completion_tokens`, so
  66. * it is used across the board there. The Anthropic compatibility endpoint
  67. * keeps the classic parameter, and so does a self-hosted OpenAI-compatible
  68. * server: Ollama and LocalAI still only read `max_tokens`.
  69. *
  70. * Reasoning models spend billed-but-hidden thinking tokens inside the same
  71. * cap before producing a visible answer, so they get headroom on top of the
  72. * requested answer budget; without it the reply comes back truncated or empty.
  73. */
  74. const REASONING_HEADROOM = 4000
  75. function isReasoningModel(model: string): boolean {
  76. return /^(o\d|gpt-5)/i.test(model)
  77. }
  78. export function completionTuning(
  79. config: AiConfig,
  80. maxTokens: number,
  81. temperature?: number
  82. ): { max_tokens?: number; max_completion_tokens?: number; temperature?: number } {
  83. if (config.provider !== 'openai') {
  84. return { max_tokens: maxTokens, ...(temperature !== undefined && { temperature }) }
  85. }
  86. const reasoning = isReasoningModel(config.model)
  87. return {
  88. max_completion_tokens: reasoning ? maxTokens + REASONING_HEADROOM : maxTokens,
  89. ...(temperature !== undefined && !reasoning && { temperature }),
  90. }
  91. }
  92. function languageInstruction(locale: Locale): string {
  93. if (locale === 'en') return ''
  94. const name = localeNames[locale] || locale
  95. return `\n\nIMPORTANT: You MUST respond entirely in ${name}.`
  96. }
  97. async function chatCompletion(
  98. organizationId: string,
  99. systemPrompt: string,
  100. userPrompt: string
  101. ): Promise<string> {
  102. const config = await getAiConfig(organizationId)
  103. const client = createClient(config)
  104. try {
  105. const response = await client.chat.completions.create({
  106. model: config.model,
  107. messages: [
  108. { role: 'system', content: systemPrompt },
  109. { role: 'user', content: userPrompt },
  110. ],
  111. ...completionTuning(config, 2000, 0.7),
  112. })
  113. return response.choices[0]?.message?.content ?? ''
  114. } catch (error) {
  115. console.error('[ai] chat completion failed:', error)
  116. throw new Error(describeAiError(error))
  117. }
  118. }
  119. export async function visionCompletion(
  120. organizationId: string,
  121. systemPrompt: string,
  122. userText: string,
  123. imageUrls: string | string[]
  124. ): Promise<string> {
  125. const config = await getAiConfig(organizationId)
  126. const client = createClient(config)
  127. const urls = Array.isArray(imageUrls) ? imageUrls : [imageUrls]
  128. const content: Array<
  129. { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }
  130. > = [
  131. { type: 'text', text: userText },
  132. ...urls.map((url) => ({ type: 'image_url' as const, image_url: { url } })),
  133. ]
  134. try {
  135. const response = await client.chat.completions.create({
  136. model: config.model,
  137. messages: [
  138. { role: 'system', content: systemPrompt },
  139. { role: 'user', content },
  140. ],
  141. ...completionTuning(config, 1000, 0.3),
  142. })
  143. return response.choices[0]?.message?.content ?? ''
  144. } catch (error) {
  145. console.error('[ai] vision completion failed:', error)
  146. throw new Error(describeAiError(error))
  147. }
  148. }
  149. // ─── AI Feature Functions ────────────────────────────────────────────────────
  150. export interface ServiceContext {
  151. vehicleMake: string | null
  152. vehicleModel: string | null
  153. vehicleYear: number | null
  154. licensePlate?: string | null
  155. serviceType: string
  156. serviceTitle: string
  157. parts: { name: string; quantity: number }[]
  158. labor: { description: string; hours: number }[]
  159. }
  160. export async function generateServiceDescription(
  161. organizationId: string,
  162. context: ServiceContext,
  163. locale: Locale = 'en'
  164. ): Promise<string> {
  165. 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)}`
  166. const partsStr =
  167. context.parts.length > 0
  168. ? context.parts.map((p) => `- ${p.name} (qty: ${p.quantity})`).join('\n')
  169. : 'No parts listed'
  170. const laborStr =
  171. context.labor.length > 0
  172. ? context.labor.map((l) => `- ${l.description} (${l.hours}h)`).join('\n')
  173. : 'No labor listed'
  174. const vehicleStr = context.vehicleMake
  175. ? `${context.vehicleYear} ${context.vehicleMake} ${context.vehicleModel}${context.licensePlate ? ` (${context.licensePlate})` : ''}`
  176. : 'None (parts-only sale, no vehicle)'
  177. const userPrompt = `Vehicle: ${vehicleStr}
  178. Service type: ${context.serviceType}
  179. Title: ${context.serviceTitle}
  180. Parts used:
  181. ${partsStr}
  182. Labor performed:
  183. ${laborStr}
  184. Write a professional service description and diagnostic notes for the invoice.`
  185. return chatCompletion(organizationId, systemPrompt, userPrompt)
  186. }
  187. export interface ServiceHistoryRecord {
  188. title: string
  189. description: string | null
  190. serviceDate: Date | null
  191. startDateTime?: Date | null
  192. type: string
  193. cost: number
  194. mileage: number | null
  195. }
  196. export async function summarizeServiceHistory(
  197. organizationId: string,
  198. vehicle: { make: string; model: string; year: number; licensePlate?: string | null },
  199. records: ServiceHistoryRecord[],
  200. locale: Locale = 'en'
  201. ): Promise<string> {
  202. const systemPrompt = `You are an automotive service advisor. Summarize a vehicle's complete service history as structured JSON.
  203. Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
  204. {"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"}]}
  205. majorWork: list the 3-5 most significant services performed (most recent first).
  206. recurringIssues: list any patterns or repeated problems with title and description (empty array if none).
  207. upcomingMaintenance: predict 2-4 likely upcoming needs based on mileage, age, and service history.${languageInstruction(locale)}`
  208. const recordsStr = records
  209. .map(
  210. (r) =>
  211. `- ${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}` : ''}`
  212. )
  213. .join('\n')
  214. const userPrompt = `Vehicle: ${vehicle.year} ${vehicle.make} ${vehicle.model}${vehicle.licensePlate ? ` (${vehicle.licensePlate})` : ''}
  215. Service history (${records.length} records):
  216. ${recordsStr || 'No service records found.'}
  217. Return JSON only.`
  218. return chatCompletion(organizationId, systemPrompt, userPrompt)
  219. }
  220. export async function getCommonIssues(
  221. organizationId: string,
  222. vehicle: { make: string; model: string; year: number },
  223. locale: Locale = 'en'
  224. ): Promise<string> {
  225. 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.
  226. Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
  227. [{"title":"Issue name","description":"1-2 sentence explanation","cost":"X,XXX–X,XXX","risk":"safety|engine|transmission|electrical|other","severity":5}]
  228. 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)}`
  229. const userPrompt = `What are the 5 most critical and common issues with the ${vehicle.year} ${vehicle.make} ${vehicle.model}? Return JSON only.`
  230. return chatCompletion(organizationId, systemPrompt, userPrompt)
  231. }
  232. export async function testAiConnection(organizationId: string): Promise<boolean> {
  233. const config = await getAiConfig(organizationId)
  234. const client = createClient(config)
  235. const response = await client.chat.completions.create({
  236. model: config.model,
  237. messages: [{ role: 'user', content: 'Say OK' }],
  238. ...completionTuning(config, 5),
  239. })
  240. return !!response.choices[0]?.message?.content
  241. }