用户每次开启对话时,都可以选择勾选记忆是否开启。但在对话期间不能切换开启/关闭,以保证对话连贯性

This commit is contained in:
Gaofeng 2025-11-28 01:56:07 +08:00
parent 3a0e714807
commit 4e31bbaf0d
2 changed files with 225 additions and 47 deletions

View file

@ -126,10 +126,11 @@
let selectedToolIds = [];
let selectedFilterIds = [];
let imageGenerationEnabled = false;
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
let memoryEnabled = true;
let imageGenerationEnabled = false;
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
let memoryEnabled = true;
let memoryLocked = false;
let showCommands = false;
@ -168,6 +169,7 @@
webSearchEnabled = false;
imageGenerationEnabled = false;
memoryEnabled = true;
memoryLocked = false;
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
@ -192,8 +194,10 @@
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
codeInterpreterEnabled = input.codeInterpreterEnabled;
if (!memoryLocked) {
memoryEnabled = input.memoryEnabled;
}
}
} catch (e) {}
}
@ -1052,6 +1056,9 @@
params = chatContent?.params ?? {};
chatFiles = chatContent?.files ?? [];
if (chatContent?.memory_enabled !== undefined) {
memoryEnabled = chatContent.memory_enabled;
}
autoScroll = true;
await tick();
@ -1064,6 +1071,8 @@
}
}
memoryLocked = Object.keys(history?.messages ?? {}).length > 0;
const taskRes = await getTaskIdsByChatId(localStorage.token, $chatId).catch((error) => {
return null;
});
@ -1493,26 +1502,80 @@
// Chat functions
//////////////////////////
/**
* 提交用户消息 - 前端聊天流程的核心入口函数
*
* 这是用户发送消息时调用的主函数,负责:
* 1. 校验用户输入prompt、模型选择、文件状态
* 2. 创建用户消息对象并更新本地聊天历史
* 3. 处理文件附件(图片、文档等)
* 4. 调用 sendMessage 发起 API 请求
*
* 1. 模型验证 (1511-1520)
- 检查选中模型是否仍然存在
- 过滤掉已删除的模型,避免请求失败
2. 输入验证 (1522-1558)
- 检查是否输入了内容或上传了文件
- 检查是否选择了模型
- 检查文件上传状态(非图片文件需等待上传完成)
- 检查文件数量限制(防止请求过大)
3. 聊天状态检查 (1560-1576)
- 检查上一条消息是否已完成(防止重复提交)
- 检查上一条消息是否有错误
4. 清空输入框 (1578-1580)
- 清空输入框内容
- 重置 prompt 变量
5. 处理文件附件 (1582-1603)
- 深拷贝文件列表
- 将文档类文件添加到聊天上下文(用于 RAG 检索)
- 去重防止重复添加
- 清空当前输入的文件列表
6. 创建用户消息对象 (1605-1616)
- 生成唯一消息 ID (UUID)
- 构造消息对象id、parentId、childrenIds、role、content、files、timestamp、models
7. 更新本地聊天历史 (1618-1629)
- 将用户消息添加到 history.messages
- 设置 history.currentId 为当前消息 ID
- 更新父消息的 childrenIds构建消息树支持对话分支
8. UI 操作 (1631-1637)
- 重新聚焦输入框
- 保存选中的模型到 sessionStorage用于页面刷新恢复
9. 发送消息到后端 (1639-1641)
- 调用 sendMessage(history, userMessageId, { newChat: true })
- newChat: true 表示如果是新对话的第一条消息,需先创建聊天记录
*
* @param userPrompt - 用户输入的文本内容
* @param _raw - 是否使用原始格式(当前未使用)
*/
const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
console.log('submitPrompt', userPrompt, $chatId);
// === 1. 模型验证:确保选中的模型仍然存在 ===
// 过滤掉已被删除或不可用的模型,避免发送请求时出错
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
// 如果模型列表发生变化,同步更新
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
selectedModels = _selectedModels;
}
// === 2. 输入验证 ===
// 2.1 检查是否输入了内容或上传了文件
if (userPrompt === '' && files.length === 0) {
toast.error($i18n.t('Please enter a prompt'));
return;
}
// 2.2 检查是否选择了模型
if (selectedModels.includes('')) {
toast.error($i18n.t('Model not selected'));
return;
}
// 2.3 检查文件上传状态(非图片文件需要等待上传完成)
// 图片文件可以立即发送,因为支持本地 base64 编码
if (
files.length > 0 &&
files.filter((file) => file.type !== 'image' && file.status === 'uploading').length > 0
@ -1523,6 +1586,7 @@
return;
}
// 2.4 检查文件数量限制(防止用户上传过多文件导致请求过大)
if (
($config?.file?.max_count ?? null) !== null &&
files.length + chatFiles.length > $config?.file?.max_count
@ -1535,13 +1599,17 @@
return;
}
// === 3. 检查当前聊天状态 ===
if (history?.currentId) {
const lastMessage = history.messages[history.currentId];
// 3.1 如果上一条消息还没完成(正在生成中),禁止提交新消息
if (lastMessage.done != true) {
// Response not done
return;
}
// 3.2 如果上一条消息有错误且没有内容,提示用户
if (lastMessage.error && !lastMessage.content) {
// Error in response
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
@ -1549,57 +1617,126 @@
}
}
// === 4. 清空输入框 ===
messageInput?.setText('');
prompt = '';
// === 5. 处理文件附件 ===
const messages = createMessagesList(history, history.currentId);
const _files = JSON.parse(JSON.stringify(files));
const _files = JSON.parse(JSON.stringify(files)); // 深拷贝文件列表
// 5.1 将当前消息的文档类文件添加到聊天上下文文件列表
// 这些文件将在整个对话中保持可用(用于 RAG 检索等)
chatFiles.push(
..._files.filter((item) =>
['doc', 'text', 'file', 'note', 'chat', 'folder', 'collection'].includes(item.type)
)
);
// 5.2 去重:防止同一文件被多次添加到上下文
chatFiles = chatFiles.filter(
// Remove duplicates
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
// 5.3 清空当前输入的文件列表(已保存到 _files 和 chatFiles
files = [];
messageInput?.setText('');
// Create user message
let userMessageId = uuidv4();
// === 6. 创建用户消息对象 ===
let userMessageId = uuidv4(); // 生成唯一消息 ID
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
parentId: messages.length !== 0 ? messages.at(-1).id : null, // 链接到父消息(上一条消息)
childrenIds: [], // 初始化子消息列表(用于分支对话)
role: 'user',
content: userPrompt,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
models: selectedModels
files: _files.length > 0 ? _files : undefined, // 附加文件(图片、文档等)
timestamp: Math.floor(Date.now() / 1000), // Unix 时间戳
models: selectedModels // 记录使用的模型(用于多模型对话)
};
// Add message to history and Set currentId to messageId
console.debug('[chat] send user message', {
chatId: $chatId,
messageId: userMessageId,
contentPreview: userPrompt.slice(0, 200),
files: _files?.map((f) => f.name ?? f.id) ?? []
});
// 锁定记忆开关:首条用户消息创建后不再允许切换
memoryLocked = true;
// === 7. 更新本地聊天历史 ===
// 7.1 将用户消息添加到历史记录
history.messages[userMessageId] = userMessage;
// 7.2 设置当前消息 ID用于定位当前对话位置
history.currentId = userMessageId;
// Append messageId to childrenIds of parent message
// 7.3 更新父消息的子消息列表(构建消息树结构)
// 这种树状结构支持对话分支(用户可以回到之前的消息重新生成响应)
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
// focus on chat input
// === 8. UI 操作 ===
// 重新聚焦输入框,方便用户继续输入
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
// 保存当前选中的模型到 sessionStorage用于刷新页面后恢复
saveSessionSelectedModels();
// === 9. 发送消息到后端 ===
// newChat: true 表示如果是新对话的第一条消息,需要先创建聊天记录
await sendMessage(history, userMessageId, { newChat: true });
};
/**
* 发送消息到后端 - 创建响应消息并调用 API
*
* 这是聊天消息发送的核心函数,负责:
* 1. 为每个选中的模型创建空的响应消息占位符
* 2. 如果是新对话的第一条消息,先创建聊天记录
* 3. 并发向所有选中的模型发送请求(支持多模型对话)
* 4. 更新聊天列表
*
* 1. UI 自动滚动 (1708-1711)
- 如果启用了自动滚动,滚动到底部
2. 深拷贝数据 (1713-1715)
- 深拷贝 chatId 和 history避免状态污染
3. 确定模型列表 (1717-1724)
- 优先级:指定的 modelId > atSelectedModel@ 选择的模型)> selectedModels全局选择
4. 创建响应消息占位符 (1726-1765)
- 为每个选中的模型创建空的响应消息对象
- 初始 content 为空,后续通过 WebSocket 流式填充
- 将响应消息添加到 history.messages
- 更新父消息的 childrenIds构建消息树
- 记录 responseMessageIdkey 格式modelId-modelIdx
5. 创建聊天记录 (1767-1771)
- 如果是新对话的第一条消息newChat=true 且 parentId=null
- 调用 initChatHandler 创建聊天记录并获取 chatId
6. 保存聊天历史 (1775-1778)
- 调用 saveChatHandler 将消息树保存到数据库
7. 并发发送请求 (1780-1832)
- 使用 Promise.all 并行向所有选中的模型发送请求
- 对每个模型:
- 7.1 检查模型视觉能力(如果消息包含图片)
- 7.2 获取响应消息 ID
- 7.3 启动聊天事件发射器(定时发送心跳,用于统计模型使用)
- 7.4 调用 sendMessageSocket 发送 API 请求
- 7.5 清理事件发射器
8. 更新聊天列表 (1834-1836)
- 刷新侧边栏聊天列表
* @param _history - 聊天历史对象(消息树)
* @param parentId - 父消息 ID用户消息 ID
* @param messages - 可选的自定义消息列表(用于重新生成等场景)
* @param modelId - 可选的指定模型 ID用于单模型重新生成
* @param modelIdx - 可选的模型索引(用于多模型对话中的特定模型)
* @param newChat - 是否是新对话的第一条消息
*/
const sendMessage = async (
_history,
parentId: string,
@ -1615,44 +1752,50 @@
newChat?: boolean;
} = {}
) => {
// === 1. UI 自动滚动 ===
if (autoScroll) {
scrollToBottom();
}
// === 2. 深拷贝数据,避免状态污染 ===
let _chatId = JSON.parse(JSON.stringify($chatId));
_history = JSON.parse(JSON.stringify(_history));
// === 3. 确定要使用的模型列表 ===
const responseMessageIds: Record<PropertyKey, string> = {};
// If modelId is provided, use it, else use selected model
// 优先级:指定的 modelId > atSelectedModel@ 选择的模型)> selectedModels全局选择
let selectedModelIds = modelId
? [modelId]
: atSelectedModel !== undefined
? [atSelectedModel.id]
: selectedModels;
// Create response messages for each selected model
// === 4. 为每个选中的模型创建响应消息占位符 ===
// 这样 UI 可以立即显示"正在输入..."状态
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
const model = $models.filter((m) => m.id === modelId).at(0);
if (model) {
// 4.1 生成响应消息 ID 和空消息对象
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
content: '', // 初始为空,后续通过 WebSocket 流式填充
model: model.id,
modelName: model.name ?? model.id,
modelIdx: modelIdx ? modelIdx : _modelIdx,
modelIdx: modelIdx ? modelIdx : _modelIdx, // 多模型对话时,区分不同模型的响应
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
// Add message to history and Set currentId to messageId
// 4.2 将响应消息添加到历史记录
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
// 4.3 更新父消息(用户消息)的子消息列表
// 构建消息树user message -> [assistant message 1, assistant message 2, ...]
if (parentId !== null && history.messages[parentId]) {
// Add null check before accessing childrenIds
history.messages[parentId].childrenIds = [
@ -1661,33 +1804,40 @@
];
}
// 4.4 记录响应消息 ID用于后续查找
// key 格式modelId-modelIdx例如 "gpt-4-0"
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
}
}
history = history;
// Create new chat if newChat is true and first user message
// === 5. 如果是新对话的第一条消息,先创建聊天记录 ===
// 检查条件newChat=true 且当前消息没有父消息(说明是第一条用户消息)
if (newChat && _history.messages[_history.currentId].parentId === null) {
_chatId = await initChatHandler(_history);
}
await tick();
// === 6. 保存聊天历史到数据库 ===
_history = JSON.parse(JSON.stringify(history));
// Save chat after all messages have been created
await saveChatHandler(_chatId, _history);
// === 7. 并发向所有选中的模型发送请求 ===
// 使用 Promise.all 实现并行请求,提升多模型对话的性能
await Promise.all(
selectedModelIds.map(async (modelId, _modelIdx) => {
console.log('modelId', modelId);
const model = $models.filter((m) => m.id === modelId).at(0);
if (model) {
// If there are image files, check if model is vision capable
// 7.1 检查模型视觉能力(如果消息包含图片)
const hasImages = createMessagesList(_history, parentId).some((message) =>
message.files?.some((file) => file.type === 'image')
);
// 如果消息包含图片,但模型不支持视觉,提示错误
if (hasImages && !(model.info?.meta?.capabilities?.vision ?? true)) {
toast.error(
$i18n.t('Model {{modelName}} is not vision capable', {
@ -1696,21 +1846,31 @@
);
}
// 7.2 获取响应消息 ID
let responseMessageId =
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`];
// 7.3 启动聊天事件发射器(定时向后端发送心跳,用于统计模型使用情况)
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
scrollToBottom();
// 7.4 发送 API 请求(核心函数)
// sendMessageSocket 负责:
// - 构造请求 payloadmessages、files、tools、features 等)
// - 调用 generateOpenAIChatCompletion API
// - 处理流式响应(通过 WebSocket 实时更新消息内容)
await sendMessageSocket(
model,
messages && messages.length > 0
? messages
: createMessagesList(_history, responseMessageId),
? messages // 使用自定义消息列表(例如重新生成时追加 follow-up
: createMessagesList(_history, responseMessageId), // 使用完整历史记录
_history,
responseMessageId,
_chatId
);
// 7.5 清理事件发射器
if (chatEventEmitter) clearInterval(chatEventEmitter);
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
@ -1718,6 +1878,7 @@
})
);
// === 8. 更新聊天列表(刷新侧边栏)===
currentChatPage.set(1);
chats.set(await getChatList(localStorage.token, $currentChatPage));
};
@ -2204,6 +2365,7 @@
params: params,
history: history,
messages: createMessagesList(history, history.currentId),
memory_enabled: memoryEnabled,
tags: [],
timestamp: Date.now()
},
@ -2238,7 +2400,8 @@
history: history,
messages: createMessagesList(history, history.currentId),
params: params,
files: chatFiles
files: chatFiles,
memory_enabled: memoryEnabled
});
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
@ -2470,6 +2633,7 @@
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:memoryEnabled
{memoryLocked}
bind:atSelectedModel
bind:showCommands
toolServers={$toolServers}
@ -2523,6 +2687,7 @@
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:memoryEnabled
{memoryLocked}
bind:atSelectedModel
bind:showCommands
toolServers={$toolServers}

View file

@ -110,6 +110,7 @@
export let webSearchEnabled = false;
export let codeInterpreterEnabled = false;
export let memoryEnabled = false;
export let memoryLocked = false;
let showInputVariablesModal = false;
let inputVariablesModalCallback = (variableValues) => {};
@ -1510,20 +1511,32 @@
{/if}
{#if showMemoryButton}
<Tooltip
content={
memoryLocked
? $i18n.t('对话进行中无法切换记忆')
: $i18n.t('记忆开关')
}
placement="top"
>
<div
class="flex items-center gap-2 bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full px-2.5 py-1.5 transition"
class="flex items-center gap-2 bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full px-2.5 py-1.5 transition {memoryLocked ? 'opacity-60 cursor-not-allowed' : ''}"
>
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
<Sparkles className="size-4" strokeWidth="1.5" />
<span class="text-sm">{$i18n.t('Memory')}</span>
</div>
<div class={memoryLocked ? 'pointer-events-none' : ''}>
<Switch
bind:state={memoryEnabled}
on:change={async () => {
if (memoryLocked) return;
await tick();
}}
/>
</div>
</div>
</Tooltip>
{/if}
{#if selectedModelIds.length === 1 && $models.find((m) => m.id === selectedModelIds[0])?.has_user_valves}