This commit is contained in:
seb
2026-10-03 23:13:36 +02:00
parent 1786879c4e
commit 9a7f345d8b
39 changed files with 4611 additions and 50 deletions

View File

@@ -0,0 +1,577 @@
import * as db from '../../db/modelconversation.js';
import { emit } from '../sse.js';
import { getOrCreateManager, removeManager } from '../../services/conversationManager.js';
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 };
}
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;
}
/**
* 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'
*/
app.get(`${basePath}/modelconversation`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const conversations = await db.list(req.user.id);
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 });
}
});
/**
* @openapi
* /modelconversation:
* post:
* tags: [ModelConversation]
* summary: Create conversation
* description: Creates a new conversation in state `idle`.
* security:
* - cookieAuth: []
* 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'
*/
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 ?? {};
try {
const conversation = await db.create(req.user.id, { provider, model });
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 });
}
});
/**
* @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'
*/
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 = 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 });
}
});
/**
* @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: []
* 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'
*/
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 });
}
});
/**
* @openapi
* /modelconversation/{id}/stream:
* get:
* tags: [ModelConversation]
* summary: Stream conversation events (SSE)
* description: |
* Opens a Server-Sent Events stream for the specified conversation.
* Streams `message` events (persisted user/assistant messages), live `delta`
* text chunks of the model answer, and `state` events (`thinking`, `working`,
* `done`). The SSE states are not stored; the database only knows `idle`/`busy`.
* `done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or
* `error` (string) 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 `message` events.
* content:
* text/event-stream:
* schema:
* type: string
* example: |
* event: state
* data: {"state":"thinking"}
*
* event: state
* data: {"state":"working"}
*
* event: delta
* data: {"delta":"Hel","text":"Hel"}
*
* event: message
* data: {"id":2,"conversation_id":1,"created_at":"...","role":"assistant","message":"Hello there"}
*
* event: state
* data: {"state":"done","usage":{"input":20,"cached":0,"output":3,"cost":0.0001}}
* 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'
*/
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 });
}
});
/**
* @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'
*/
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 });
}
});
/**
* @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: []
* 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'
*/
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 } = 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,
});
} else {
if (!message) {
return res.status(400).json({ error: 'Message required' });
}
created = await manager.handleUserMessage(req.user.id, { message });
}
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 });
}
});
}