import * as db from '../../db/modelconversation.js'; import env from '../../config/env.js'; import { RETIRED_MODELS, resolveDefaultModel } from '../../tools/llm-key.js'; import { emit } from '../sse.js'; import { getOrCreateManager, removeManager, cleanUserMessageContent } from '../../services/conversationManager.js'; const FIRST_MESSAGE_MAX_CHARS = 100; // #region Helpers function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } return { id }; } /** * Removes XML elements together with their content (e.g. `…`) * and any leftover self-closing tags. * @param {string} text * @returns {string} */ function stripXmlElements(text) { const elementPattern = /<([A-Za-z][\w:.-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/g; let previous; let stripped = text; do { previous = stripped; stripped = stripped.replace(elementPattern, ''); } while (stripped !== previous); return stripped.replace(/<[A-Za-z][\w:.-]*(?:\s[^>]*)?\/>/g, ''); } /** * Turns the stored first user message payload (`{"content": ...}`) into short plain text * for conversation lists: JSON content only, XML elements stripped, outer whitespace trimmed. * @param {string|null} storedMessage * @returns {string|null} */ function buildFirstMessagePreview(storedMessage) { if (!storedMessage) return null; let rawContent; try { rawContent = JSON.parse(storedMessage).content; } catch { rawContent = storedMessage; } const text = stripXmlElements(cleanUserMessageContent(rawContent)).trim(); return text.length > FIRST_MESSAGE_MAX_CHARS ? `${text.slice(0, FIRST_MESSAGE_MAX_CHARS)}…` : text; } /** * Replaces the store's raw `first_user_message` with the plain-text `first_message` preview. * @param {{ first_user_message: string|null }} row * @returns {object} */ function buildConversationResponse({ first_user_message: storedFirstMessage, ...conversation }) { return { ...conversation, first_message: buildFirstMessagePreview(storedFirstMessage) }; } function modelConversationErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Conversation not found') return 404; if (error.message === 'Provider required') return 400; if (error.message === 'Model required') return 400; if (error.message === 'Message required') return 400; if (error.message === 'Conversation is not idle') return 409; return 500; } // #endregion /** * Registers model conversation routes. Conversations are private to the * authenticated user within their selected company. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /modelconversation: * get: * tags: [ModelConversation] * summary: List conversations * description: Lists the authenticated user's model conversations in the selected company, most recently updated first. * security: * - cookieAuth: [] * responses: * 200: * description: List of conversations * content: * application/json: * schema: * type: object * properties: * conversations: * type: array * items: * $ref: '#/components/schemas/ModelConversation' * 400: * description: No company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /modelconversation app.get(`${basePath}/modelconversation`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const rows = await db.list(req.user.id); const conversations = rows.map(buildConversationResponse); res.status(200).json({ conversations }); } catch (error) { const status = modelConversationErrorStatus(error); if (status === 500) console.error('List model conversations failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /modelconversation: * post: * tags: [ModelConversation] * summary: Create conversation * description: Creates a new conversation in state `idle`. * security: * - cookieAuth: [] * x-signals: [modelconversation] * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/ModelConversationCreateInput' * responses: * 201: * description: Created conversation * content: * application/json: * schema: * type: object * properties: * conversation: * $ref: '#/components/schemas/ModelConversation' * 400: * description: Invalid input or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /modelconversation app.post(`${basePath}/modelconversation`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const { provider, model } = req.body ?? {}; // Clients may still send the retired `stealth/space-bunny-alpha` id, which now returns // empty responses from the provider. Map it onto the configured default rather than // ignoring the client's choice entirely, so an explicit model selection still works. const requestedModel = typeof model === 'string' ? model.trim() : ''; const defaultModel = await resolveDefaultModel(req.user.id); const effectiveModel = !requestedModel || RETIRED_MODELS.has(requestedModel) || requestedModel === 'openai/gpt-6-luna' || requestedModel === 'openai/gpt-6-luna:floor' || requestedModel === 'openrouter/free' ? defaultModel : requestedModel; try { const conversation = await db.create(req.user.id, { provider, model: effectiveModel }); emit('modelconversation', { companyId: selected.companyId }); res.status(201).json({ conversation }); } catch (error) { const status = modelConversationErrorStatus(error); if (status === 500) console.error('Create model conversation failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /modelconversation/{id}: * get: * tags: [ModelConversation] * summary: Get conversation * description: Gets details and current state of one of the authenticated user's model conversations. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Conversation ID * responses: * 200: * description: Conversation details * content: * application/json: * schema: * type: object * properties: * conversation: * $ref: '#/components/schemas/ModelConversation' * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Conversation not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /modelconversation/:id app.get(`${basePath}/modelconversation/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const conversation = buildConversationResponse(await db.get(req.user.id, parsedId.id)); res.status(200).json({ conversation }); } catch (error) { const status = modelConversationErrorStatus(error); if (status === 500) console.error('Get model conversation failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /modelconversation/{id}: * delete: * tags: [ModelConversation] * summary: Delete conversation * description: Deletes one of the authenticated user's model conversations and all its messages. * security: * - cookieAuth: [] * x-signals: [modelconversation] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Conversation ID * responses: * 204: * description: Conversation deleted * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Conversation not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /modelconversation/:id app.delete(`${basePath}/modelconversation/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { await db.remove(req.user.id, parsedId.id); removeManager(parsedId.id); emit('modelconversation', { companyId: selected.companyId }); res.sendStatus(204); } catch (error) { const status = modelConversationErrorStatus(error); if (status === 500) console.error('Delete model conversation failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /modelconversation/{id}/stream: * get: * tags: [ModelConversation] * summary: Stream conversation events (SSE) * description: | * Opens a Server-Sent Events stream for the specified conversation. * Streams live `delta` text chunks of the model answer, `usage` stats per turn, * and `state` events (`thinking`, `connected`, `firstevent`, `reasoning`, * `content`, `working`, `toolcall`, `done`). The SSE states are not stored; * the database only knows `idle`/`busy`. * `done` indicates generation has finished, carrying `error` (string) only if the run failed. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Conversation ID * responses: * 200: * description: | * SSE stream (`text/event-stream`). Emits `state`, `delta`, and `usage` events. * content: * text/event-stream: * schema: * type: string * example: | * event: state * data: {"state":"thinking"} * * event: state * data: {"state":"connected"} * * event: state * data: {"state":"firstevent"} * * event: state * data: {"state":"content"} * * event: delta * data: {"delta":"Hel","text":"Hel"} * * event: usage * data: {"input":20,"cached":0,"output":3,"cost":0.0001} * * event: state * data: {"state":"done"} * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Conversation not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /modelconversation/:id/stream app.get(`${basePath}/modelconversation/:id/stream`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { // Ensure conversation exists and user has access await db.get(req.user.id, parsedId.id); const manager = getOrCreateManager(parsedId.id, { companyId: selected.companyId, googleAuthId: req.user.id, }); manager.subscribe(res, req); } catch (error) { const status = modelConversationErrorStatus(error); if (status === 500) console.error('Stream model conversation failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /modelconversation/{id}/message: * get: * tags: [ModelConversation] * summary: List conversation messages * description: Lists messages of one of the user's own conversations, oldest first. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Conversation ID * responses: * 200: * description: List of messages * content: * application/json: * schema: * type: object * properties: * messages: * type: array * items: * $ref: '#/components/schemas/ModelMessage' * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Conversation not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /modelconversation/:id/message app.get(`${basePath}/modelconversation/:id/message`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const messages = await db.listMessages(req.user.id, parsedId.id); res.status(200).json({ messages }); } catch (error) { const status = modelConversationErrorStatus(error); if (status === 500) console.error('List model messages failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /modelconversation/{id}/message: * post: * tags: [ModelConversation] * summary: Post a user message * description: | * Appends a message with role `user` to the conversation. * Refused with 409 unless the conversation state is `idle`. * security: * - cookieAuth: [] * x-signals: [modelconversation-] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Conversation ID * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/ModelMessageCreateInput' * responses: * 201: * description: Created message * content: * application/json: * schema: * type: object * properties: * message: * $ref: '#/components/schemas/ModelMessage' * 400: * description: Invalid ID, empty message or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Conversation not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Conversation is not idle * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /modelconversation/:id/message app.post(`${basePath}/modelconversation/:id/message`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } const { message, tool_call_id, role, content, answer, name, attachments } = req.body ?? {}; try { const manager = getOrCreateManager(parsedId.id, { companyId: selected.companyId, googleAuthId: req.user.id, }); let created; if (role === 'tool' || tool_call_id) { if (!tool_call_id) { return res.status(400).json({ error: 'tool_call_id is required for tool response' }); } created = await manager.handleToolMessage(req.user.id, { tool_call_id, content, answer: answer !== undefined ? answer : message, name, }); } else { const hasAttachments = Array.isArray(attachments) && attachments.length > 0; if (!message && !content && !hasAttachments) { return res.status(400).json({ error: 'Message required' }); } created = await manager.handleUserMessage(req.user.id, { message, attachments, content }); } emit(`modelconversation-${parsedId.id}`, { companyId: selected.companyId }); res.status(201).json({ message: created }); } catch (error) { const status = modelConversationErrorStatus(error); if (status === 500) console.error('Post model message failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }