679 lines
24 KiB
JavaScript
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 = 50;
|
|
|
|
// #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
|
|
}
|