Skip to content

Commit bce7e3a

Browse files
Merge pull request #10 from solid/serve-side-chat-management
Serve side chat management
2 parents 6ebee06 + e371f8f commit bce7e3a

49 files changed

Lines changed: 6964 additions & 342 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,22 @@ APPLE_KEY_ID=
496496
APPLE_PRIVATE_KEY_PATH=
497497
APPLE_CALLBACK_URL=/oauth/apple/callback
498498

499+
#SolidOpenID (dynamic: use SOLID_OPENID_PROVIDERS)
500+
# Client ID document is served at DOMAIN_SERVER/solid-client-id; use that URL as clientId for each provider.
501+
# SOLID_OPENID_CLIENT_ID is only needed if your client_id document URL is not DOMAIN_SERVER/solid-client-id.
502+
SOLID_OPENID_CLIENT_ID=http://localhost:3080/solid-client-id
503+
SOLID_OPENID_SESSION_SECRET=[JustGenerateARandomSessionSecret]
504+
# Required: JSON array of Solid IdPs. Each item: issuer, clientId, clientSecret (optional), scope (optional), label (optional).
505+
SOLID_OPENID_PROVIDERS=[{"issuer":"http://localhost:3000/","clientId":"http://localhost:3080/solid-client-id","clientSecret":"","label":"Local CSS"}]
506+
# Optional: add more issuers to the array, e.g. {"issuer":"https://solidcommunity.net/","clientId":"https://your-app.com/solid-client-id","clientSecret":"...","label":"Solid Community"}
507+
SOLID_OPENID_SCOPE="openid webid offline_access"
508+
SOLID_OPENID_CALLBACK_URL=/oauth/openid/callback
509+
SOLID_OPENID_BUTTON_LABEL=Continue with Solid
510+
# Optional: allow users to type any Solid OIDC provider URL in the login modal.
511+
# SOLID_OPENID_CUSTOM_CLIENT_ID=
512+
# SOLID_OPENID_CUSTOM_CLIENT_SECRET=
513+
# SOLID_OPENID_CUSTOM_SCOPE="openid webid offline_access"
514+
499515
# OpenID
500516
OPENID_CLIENT_ID=
501517
OPENID_CLIENT_SECRET=

api/app/clients/BaseClient.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,10 @@ class BaseClient {
10311031
* @param {Partial<TMessage>} message
10321032
*/
10331033
async updateMessageInDatabase(message) {
1034+
// Ensure conversationId is included if available
1035+
if (!message.conversationId && this.conversationId) {
1036+
message.conversationId = this.conversationId;
1037+
}
10341038
await updateMessage(this.options.req, message);
10351039
}
10361040

api/models/Conversation.js

Lines changed: 154 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,34 @@ const { logger } = require('@librechat/data-schemas');
22
const { createTempChatExpirationDate } = require('@librechat/api');
33
const { getMessages, deleteMessages } = require('./Message');
44
const { Conversation } = require('~/db/models');
5+
const { isSolidUser } = require('~/server/utils/isSolidUser');
6+
const {
7+
getConvoFromSolid,
8+
saveConvoToSolid,
9+
getConvosByCursorFromSolid,
10+
deleteConvosFromSolid,
11+
} = require('~/server/services/SolidStorage');
512

613
/**
714
* Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
815
* @param {string} conversationId - The conversation's ID.
16+
* @param {Object} [req] - Optional request object for Solid storage support.
917
* @returns {Promise<{conversationId: string, user: string} | null>} The conversation object with selected fields or null if not found.
1018
*/
11-
const searchConversation = async (conversationId) => {
19+
const searchConversation = async (conversationId, req = null) => {
1220
try {
21+
// Solid users: use Solid storage only; no MongoDB fallback
22+
if (isSolidUser(req)) {
23+
const convo = await getConvoFromSolid(req, conversationId);
24+
if (convo) {
25+
return {
26+
conversationId: convo.conversationId,
27+
user: convo.user,
28+
};
29+
}
30+
return null;
31+
}
32+
1333
return await Conversation.findOne({ conversationId }, 'conversationId user').lean();
1434
} catch (error) {
1535
logger.error('[searchConversation] Error searching conversation', error);
@@ -21,9 +41,16 @@ const searchConversation = async (conversationId) => {
2141
* Retrieves a single conversation for a given user and conversation ID.
2242
* @param {string} user - The user's ID.
2343
* @param {string} conversationId - The conversation's ID.
44+
* @param {Object} [req] - Optional request object for Solid storage support.
2445
* @returns {Promise<TConversation>} The conversation object.
2546
*/
26-
const getConvo = async (user, conversationId) => {
47+
const getConvo = async (user, conversationId, req = null) => {
48+
// Solid users: use Solid storage only; no MongoDB fallback
49+
if (isSolidUser(req)) {
50+
const convo = await getConvoFromSolid(req, conversationId);
51+
return convo ?? null;
52+
}
53+
2754
try {
2855
return await Conversation.findOne({ user, conversationId }).lean();
2956
} catch (error) {
@@ -87,31 +114,58 @@ module.exports = {
87114
* @returns {Promise<TConversation>} The conversation object.
88115
*/
89116
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
90-
try {
91-
if (metadata?.context) {
92-
logger.debug(`[saveConvo] ${metadata.context}`);
117+
if (metadata?.context) {
118+
logger.debug(`[saveConvo] ${metadata.context}`);
119+
}
120+
121+
// Build shared payload: expiredAt and base convo fields
122+
let expiredAt = null;
123+
if (req?.body?.isTemporary) {
124+
try {
125+
const appConfig = req.config;
126+
expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
127+
} catch (err) {
128+
logger.error('Error creating temporary chat expiration date:', err);
129+
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
93130
}
131+
}
132+
const baseConvo = {
133+
conversationId,
134+
newConversationId,
135+
...convo,
136+
expiredAt,
137+
};
94138

95-
const messages = await getMessages({ conversationId }, '_id');
96-
const update = { ...convo, messages, user: req.user.id };
139+
if (isSolidUser(req)) {
140+
try {
141+
// Full document aligned with schema (same shape as MongoDB); SolidStorage adds messages + timestamps
142+
const finalConversationId = newConversationId || conversationId;
143+
const convoDocument = {
144+
...baseConvo,
145+
conversationId: finalConversationId,
146+
user: req.user.id,
147+
};
148+
if (newConversationId && newConversationId !== conversationId) {
149+
convoDocument.previousConversationId = conversationId;
150+
}
151+
const savedConvo = await saveConvoToSolid(req, convoDocument, metadata);
152+
return savedConvo;
153+
} catch (error) {
154+
logger.error('[saveConvo] Error saving conversation to Solid Pod', error);
155+
if (metadata && metadata?.context) {
156+
logger.info(`[saveConvo] ${metadata.context}`);
157+
}
158+
throw error;
159+
}
160+
}
97161

162+
try {
163+
const messages = await getMessages({ conversationId }, '_id');
164+
const update = { ...convo, messages, user: req.user.id, expiredAt };
98165
if (newConversationId) {
99166
update.conversationId = newConversationId;
100167
}
101168

102-
if (req?.body?.isTemporary) {
103-
try {
104-
const appConfig = req.config;
105-
update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
106-
} catch (err) {
107-
logger.error('Error creating temporary chat expiration date:', err);
108-
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
109-
update.expiredAt = null;
110-
}
111-
} else {
112-
update.expiredAt = null;
113-
}
114-
115169
/** @type {{ $set: Partial<TConversation>; $unset?: Record<keyof TConversation, number> }} */
116170
const updateOperation = { $set: update };
117171
if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
@@ -122,10 +176,7 @@ module.exports = {
122176
const conversation = await Conversation.findOneAndUpdate(
123177
{ conversationId, user: req.user.id },
124178
updateOperation,
125-
{
126-
new: true,
127-
upsert: metadata?.noUpsert !== true,
128-
},
179+
{ new: true, upsert: metadata?.noUpsert !== true },
129180
);
130181

131182
if (!conversation) {
@@ -170,8 +221,20 @@ module.exports = {
170221
search,
171222
sortBy = 'updatedAt',
172223
sortDirection = 'desc',
224+
req, // Optional req object for Solid storage
173225
} = {},
174226
) => {
227+
if (isSolidUser(req)) {
228+
return await getConvosByCursorFromSolid(req, {
229+
cursor,
230+
limit,
231+
isArchived,
232+
tags,
233+
search,
234+
sortBy,
235+
sortDirection,
236+
});
237+
}
175238
const filters = [{ user }];
176239
if (isArchived) {
177240
filters.push({ isArchived: true });
@@ -228,7 +291,7 @@ module.exports = {
228291
},
229292
],
230293
};
231-
} catch (err) {
294+
} catch (_err) {
232295
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
233296
}
234297
if (cursorFilter) {
@@ -337,6 +400,7 @@ module.exports = {
337400
* @function
338401
* @param {string|ObjectId} user - The user's ID.
339402
* @param {Object} filter - Additional filter criteria for the conversations to be deleted.
403+
* @param {Object} [req] - Optional Express request object for Solid storage support.
340404
* @returns {Promise<{ n: number, ok: number, deletedCount: number, messages: { n: number, ok: number, deletedCount: number } }>}
341405
* An object containing the count of deleted conversations and associated messages.
342406
* @throws {Error} Throws an error if there's an issue with the database operations.
@@ -347,7 +411,71 @@ module.exports = {
347411
* const result = await deleteConvos(user, filter);
348412
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
349413
*/
350-
deleteConvos: async (user, filter) => {
414+
deleteConvos: async (user, filter, req = null) => {
415+
// Use Solid storage when user logged in via "Continue with Solid"
416+
if (isSolidUser(req)) {
417+
try {
418+
let conversationIds = [];
419+
420+
// If conversationId is specified in filter, use it directly
421+
if (filter.conversationId) {
422+
conversationIds = [filter.conversationId];
423+
} else {
424+
// Otherwise, get all conversations matching the filter from Solid Pod
425+
// For now, we'll get all conversations and filter in memory
426+
// This is not ideal for large datasets, but Solid Pod doesn't support complex queries
427+
const allConversations = await getConvosByCursorFromSolid(req, {
428+
limit: 10000, // Large limit to get all conversations
429+
isArchived: filter.isArchived,
430+
});
431+
432+
// Filter conversations based on the filter criteria
433+
let filteredConversations = allConversations.conversations;
434+
435+
if (filter.conversationId) {
436+
filteredConversations = filteredConversations.filter(
437+
(c) => c.conversationId === filter.conversationId,
438+
);
439+
}
440+
441+
if (filter.endpoint) {
442+
filteredConversations = filteredConversations.filter(
443+
(c) => c.endpoint === filter.endpoint,
444+
);
445+
}
446+
447+
conversationIds = filteredConversations.map((c) => c.conversationId);
448+
}
449+
450+
if (!conversationIds.length) {
451+
throw new Error('Conversation not found or already deleted.');
452+
}
453+
454+
const deletedCount = await deleteConvosFromSolid(req, conversationIds);
455+
456+
// Return format similar to MongoDB deleteMany result
457+
return {
458+
n: deletedCount,
459+
ok: deletedCount > 0 ? 1 : 0,
460+
deletedCount,
461+
messages: {
462+
n: deletedCount, // Messages are deleted as part of deleteConvosFromSolid
463+
ok: deletedCount > 0 ? 1 : 0,
464+
deletedCount,
465+
},
466+
};
467+
} catch (error) {
468+
logger.error('[deleteConvos] Error deleting conversations from Solid Pod', {
469+
error: error.message,
470+
stack: error.stack,
471+
filter,
472+
userId: user,
473+
});
474+
throw error;
475+
}
476+
}
477+
478+
// MongoDB storage (original code)
351479
try {
352480
const userFilter = { ...filter, user };
353481
const conversations = await Conversation.find(userFilter).select('conversationId');

0 commit comments

Comments
 (0)