Files
dashback/src/api/routes/modelconversation.js
2026-10-12 18:09:57 +02:00

679 lines
24 KiB
JavaScript

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. `<USERENV><ROUTE>…</ROUTE></USERENV>`)
* 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-<id>]
* 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
}