u
This commit is contained in:
577
src/api/routes/modelconversation.js
Normal file
577
src/api/routes/modelconversation.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user