mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
Copies the reference's information architecture onto our own black surface. Pin becomes a real primitive. It did not exist for conversations — the 11 `isPinned` hits in client/src are tool/MCP composer pinning, and favorites are agents/models/specs, not chats. So `isPinned` is now a persisted conversation flag written through the same `POST /v1/chat/convos/update` that carries a rename: one route for a conversation's own metadata, taking title, isPinned, or both. `useConvoPin` is its only writer; the id is an argument so one instance serves a row's menu or a multi-select batch. The list projection had to change with it. `getConvosByCursor` selected a fixed field list that included neither `isPinned` nor `tags`, so every row would have rendered perfectly and never looked pinned or tagged — green, and wrong. Both are in the projection now. ONE menu, opened two ways. `DropdownPopup` takes an optional `getAnchorRect`, so a right-click anchors the same Ariakit menu at the pointer that the kebab anchors to its button. No second menu, no duplicated item list. Order follows the reference — Pin/Unpin (P), Rename (R), Add to bookmark (submenu), then Share / Duplicate / Archive, then Delete (D, destructive) — with the shortcut column now rendering the plain dimmed letter (`kbd` had zero call sites, so nothing depended on the old always-hidden `⌘`-prefixed form). Bookmarks ARE our grouping primitive, so "Add to project" is "Add to bookmark": a submenu of the user's real tags with ticks, plus New Bookmark, gated on the existing BOOKMARKS permission. There is deliberately no "Mark as unread". This product has no unread state anywhere, and a menu item that toggles nothing is worse than an absent one. Also: a Pinned section above the dated recents in the sidebar, and a `/chats` pane — title, search, filter, Select mode with bulk pin/archive, New — reachable from the Chats header. Rename is decomplected into `useConvoRename` so the sidebar row and the pane state what a rename does exactly once. New markup carries no Tailwind: chats.css is plain CSS over the app's own `--surface-*` / `--text-*` tokens. Co-authored-by: Hanzo Dev <[email protected]>
321 lines
10 KiB
JavaScript
321 lines
10 KiB
JavaScript
const multer = require('multer');
|
|
const express = require('express');
|
|
const { sleep } = require('@hanzochat/agents');
|
|
const { isEnabled } = require('@hanzochat/api');
|
|
const { logger } = require('@hanzochat/data-schemas');
|
|
const { CacheKeys, EModelEndpoint } = require('@hanzochat/data-provider');
|
|
const {
|
|
createImportLimiters,
|
|
validateConvoAccess,
|
|
createForkLimiters,
|
|
configMiddleware,
|
|
} = require('~/server/middleware');
|
|
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
|
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
|
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
|
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
|
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
|
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
|
const { importConversations } = require('~/server/utils/import');
|
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
|
const getLogStores = require('~/cache/getLogStores');
|
|
|
|
const assistantClients = {
|
|
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
|
[EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
|
|
};
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* Guest-accessible conversation list. Guests have no persisted conversations, so
|
|
* this returns an empty, well-formed page — enough for the UI to mount the chat
|
|
* history pane without a DB lookup. Registered with guest-aware auth; every
|
|
* mutation / read-by-id route below stays JWT-only and rejects guest tokens.
|
|
*/
|
|
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
|
|
if (req.user?.guest === true) {
|
|
return res.status(200).json({ conversations: [], nextCursor: null });
|
|
}
|
|
const limit = parseInt(req.query.limit, 10) || 25;
|
|
const cursor = req.query.cursor;
|
|
const isArchived = isEnabled(req.query.isArchived);
|
|
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
|
const sortBy = req.query.sortBy || 'updatedAt';
|
|
const sortDirection = req.query.sortDirection || 'desc';
|
|
|
|
let tags;
|
|
if (req.query.tags) {
|
|
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
|
|
}
|
|
|
|
try {
|
|
const result = await getConvosByCursor(req.user.id, {
|
|
cursor,
|
|
limit,
|
|
isArchived,
|
|
tags,
|
|
search,
|
|
sortBy,
|
|
sortDirection,
|
|
});
|
|
res.status(200).json(result);
|
|
} catch (error) {
|
|
logger.error('Error fetching conversations', error);
|
|
res.status(500).json({ error: 'Error fetching conversations' });
|
|
}
|
|
});
|
|
|
|
/** Every route below reads or writes persisted user data — JWT-only, no guests. */
|
|
router.use(requireJwtAuth);
|
|
|
|
router.get('/:conversationId', async (req, res) => {
|
|
const { conversationId } = req.params;
|
|
const convo = await getConvo(req.user.id, conversationId);
|
|
|
|
if (convo) {
|
|
res.status(200).json(convo);
|
|
} else {
|
|
res.status(404).end();
|
|
}
|
|
});
|
|
|
|
router.get('/gen_title/:conversationId', async (req, res) => {
|
|
const { conversationId } = req.params;
|
|
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
|
|
const key = `${req.user.id}-${conversationId}`;
|
|
let title = await titleCache.get(key);
|
|
|
|
if (!title) {
|
|
// Exponential backoff: 500ms, 1s, 2s, 4s, 8s (total ~15.5s max wait)
|
|
const delays = [500, 1000, 2000, 4000, 8000];
|
|
for (const delay of delays) {
|
|
await sleep(delay);
|
|
title = await titleCache.get(key);
|
|
if (title) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (title) {
|
|
await titleCache.delete(key);
|
|
res.status(200).json({ title });
|
|
} else {
|
|
res.status(404).json({
|
|
message: "Title not found or method not implemented for the conversation's endpoint",
|
|
});
|
|
}
|
|
});
|
|
|
|
router.delete('/', async (req, res) => {
|
|
let filter = {};
|
|
const { conversationId, source, thread_id, endpoint } = req.body?.arg ?? {};
|
|
|
|
// Prevent deletion of all conversations
|
|
if (!conversationId && !source && !thread_id && !endpoint) {
|
|
return res.status(400).json({
|
|
error: 'no parameters provided',
|
|
});
|
|
}
|
|
|
|
if (conversationId) {
|
|
filter = { conversationId };
|
|
} else if (source === 'button') {
|
|
return res.status(200).send('No conversationId provided');
|
|
}
|
|
|
|
if (
|
|
typeof endpoint !== 'undefined' &&
|
|
Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
|
|
) {
|
|
/** @type {{ openai: OpenAI }} */
|
|
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
|
|
try {
|
|
const response = await openai.beta.threads.delete(thread_id);
|
|
logger.debug('Deleted OpenAI thread:', response);
|
|
} catch (error) {
|
|
logger.error('Error deleting OpenAI thread:', error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const dbResponse = await deleteConvos(req.user.id, filter);
|
|
if (filter.conversationId) {
|
|
await deleteToolCalls(req.user.id, filter.conversationId);
|
|
await deleteConvoSharedLink(req.user.id, filter.conversationId);
|
|
}
|
|
res.status(201).json(dbResponse);
|
|
} catch (error) {
|
|
logger.error('Error clearing conversations', error);
|
|
res.status(500).send('Error clearing conversations');
|
|
}
|
|
});
|
|
|
|
router.delete('/all', async (req, res) => {
|
|
try {
|
|
const dbResponse = await deleteConvos(req.user.id, {});
|
|
await deleteToolCalls(req.user.id);
|
|
await deleteAllSharedLinks(req.user.id);
|
|
res.status(201).json(dbResponse);
|
|
} catch (error) {
|
|
logger.error('Error clearing conversations', error);
|
|
res.status(500).send('Error clearing conversations');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Archives or unarchives a conversation.
|
|
* @route POST /archive
|
|
* @param {string} req.body.arg.conversationId - The conversation ID to archive/unarchive.
|
|
* @param {boolean} req.body.arg.isArchived - Whether to archive (true) or unarchive (false).
|
|
* @returns {object} 200 - The updated conversation object.
|
|
*/
|
|
router.post('/archive', validateConvoAccess, async (req, res) => {
|
|
const { conversationId, isArchived } = req.body?.arg ?? {};
|
|
|
|
if (!conversationId) {
|
|
return res.status(400).json({ error: 'conversationId is required' });
|
|
}
|
|
|
|
if (typeof isArchived !== 'boolean') {
|
|
return res.status(400).json({ error: 'isArchived must be a boolean' });
|
|
}
|
|
|
|
try {
|
|
const dbResponse = await saveConvo(
|
|
req,
|
|
{ conversationId, isArchived },
|
|
{ context: `POST /v1/chat/convos/archive ${conversationId}` },
|
|
);
|
|
res.status(200).json(dbResponse);
|
|
} catch (error) {
|
|
logger.error('Error archiving conversation', error);
|
|
res.status(500).send('Error archiving conversation');
|
|
}
|
|
});
|
|
|
|
/** Maximum allowed length for conversation titles */
|
|
const MAX_CONVO_TITLE_LENGTH = 1024;
|
|
|
|
/**
|
|
* Updates a conversation's own metadata: its title, its pinned state, or both.
|
|
* @route POST /update
|
|
* @param {string} req.body.arg.conversationId - The conversation ID to update.
|
|
* @param {string} [req.body.arg.title] - The new title for the conversation.
|
|
* @param {boolean} [req.body.arg.isPinned] - Whether the conversation is pinned.
|
|
* @returns {object} 201 - The updated conversation object.
|
|
*/
|
|
router.post('/update', validateConvoAccess, async (req, res) => {
|
|
const { conversationId, title, isPinned } = req.body?.arg ?? {};
|
|
|
|
if (!conversationId) {
|
|
return res.status(400).json({ error: 'conversationId is required' });
|
|
}
|
|
|
|
if (title === undefined && isPinned === undefined) {
|
|
return res.status(400).json({ error: 'title or isPinned is required' });
|
|
}
|
|
|
|
if (title !== undefined && typeof title !== 'string') {
|
|
return res.status(400).json({ error: 'title must be a string' });
|
|
}
|
|
|
|
if (isPinned !== undefined && typeof isPinned !== 'boolean') {
|
|
return res.status(400).json({ error: 'isPinned must be a boolean' });
|
|
}
|
|
|
|
const update = { conversationId };
|
|
if (title !== undefined) {
|
|
update.title = title.trim().slice(0, MAX_CONVO_TITLE_LENGTH);
|
|
}
|
|
if (isPinned !== undefined) {
|
|
update.isPinned = isPinned;
|
|
}
|
|
|
|
try {
|
|
const dbResponse = await saveConvo(req, update, {
|
|
context: `POST /v1/chat/convos/update ${conversationId}`,
|
|
});
|
|
res.status(201).json(dbResponse);
|
|
} catch (error) {
|
|
logger.error('Error updating conversation', error);
|
|
res.status(500).send('Error updating conversation');
|
|
}
|
|
});
|
|
|
|
const { importIpLimiter, importUserLimiter } = createImportLimiters();
|
|
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
|
|
const upload = multer({ storage: storage, fileFilter: importFileFilter });
|
|
|
|
/**
|
|
* Imports a conversation from a JSON file and saves it to the database.
|
|
* @route POST /import
|
|
* @param {Express.Multer.File} req.file - The JSON file to import.
|
|
* @returns {object} 201 - success response - application/json
|
|
*/
|
|
router.post(
|
|
'/import',
|
|
importIpLimiter,
|
|
importUserLimiter,
|
|
configMiddleware,
|
|
upload.single('file'),
|
|
async (req, res) => {
|
|
try {
|
|
/* TODO: optimize to return imported conversations and add manually */
|
|
await importConversations({ filepath: req.file.path, requestUserId: req.user.id });
|
|
res.status(201).json({ message: 'Conversation(s) imported successfully' });
|
|
} catch (error) {
|
|
logger.error('Error processing file', error);
|
|
res.status(500).send('Error processing file');
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* POST /fork
|
|
* This route handles forking a conversation based on the TForkConvoRequest and responds with TForkConvoResponse.
|
|
* @route POST /fork
|
|
* @param {express.Request<{}, TForkConvoResponse, TForkConvoRequest>} req - Express request object.
|
|
* @param {express.Response<TForkConvoResponse>} res - Express response object.
|
|
* @returns {Promise<void>} - The response after forking the conversation.
|
|
*/
|
|
router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
|
|
try {
|
|
/** @type {TForkConvoRequest} */
|
|
const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;
|
|
const result = await forkConversation({
|
|
requestUserId: req.user.id,
|
|
originalConvoId: conversationId,
|
|
targetMessageId: messageId,
|
|
latestMessageId,
|
|
records: true,
|
|
splitAtTarget,
|
|
option,
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.error('Error forking conversation:', error);
|
|
res.status(500).send('Error forking conversation');
|
|
}
|
|
});
|
|
|
|
router.post('/duplicate', async (req, res) => {
|
|
const { conversationId, title } = req.body;
|
|
|
|
try {
|
|
const result = await duplicateConversation({
|
|
userId: req.user.id,
|
|
conversationId,
|
|
title,
|
|
});
|
|
res.status(201).json(result);
|
|
} catch (error) {
|
|
logger.error('Error duplicating conversation:', error);
|
|
res.status(500).send('Error duplicating conversation');
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|