mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-06 08:07:10 +00:00
perf: lazily load branched chat history
This commit is contained in:
parent
52145eede9
commit
5af4a481e0
11 changed files with 1957 additions and 539 deletions
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
|
||||
# local imports
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
|
|
@ -12,6 +13,14 @@ from open_webui.models.automations import AutomationRun
|
|||
from open_webui.models.chat_messages import ChatMessage, ChatMessages
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.tags import Tag, TagModel, Tags
|
||||
from open_webui.utils.chat_history import (
|
||||
MESSAGE_LOADED_KEY,
|
||||
MESSAGE_WINDOW_KEY,
|
||||
build_window_chat,
|
||||
create_message_list,
|
||||
create_message_window,
|
||||
prepare_messages_for_storage,
|
||||
)
|
||||
from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from sqlalchemy import (
|
||||
|
|
@ -598,7 +607,7 @@ class ChatTable:
|
|||
"""Persist updated chat content, sanitizing null bytes."""
|
||||
try: # load the chat record for in-place mutation
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
chat_item = await session.get(Chat, id, with_for_update=True)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
|
|
@ -613,7 +622,8 @@ class ChatTable:
|
|||
await session.commit()
|
||||
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.exception('Failed to update chat %s: %s', id, exc)
|
||||
return
|
||||
|
||||
async def update_chat_variables_by_id(
|
||||
|
|
@ -639,6 +649,132 @@ class ChatTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def update_chat_window_by_id(
|
||||
self,
|
||||
id: str,
|
||||
chat: dict,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
touch: bool = True,
|
||||
) -> ChatModel | None:
|
||||
"""Merge a window save without replacing already-persisted message bodies."""
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id, with_for_update=True)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
existing_chat = deepcopy(chat_item.chat or {})
|
||||
incoming_chat = self._clean_null_bytes(chat)
|
||||
merged_chat = {**existing_chat, **incoming_chat}
|
||||
|
||||
existing_history = existing_chat.get('history') or {}
|
||||
incoming_history = incoming_chat.get('history') or {}
|
||||
existing_messages = prepare_messages_for_storage(existing_history.get('messages'))
|
||||
incoming_messages = prepare_messages_for_storage(incoming_history.get('messages'))
|
||||
|
||||
# Existing messages are authoritative. Window saves may append messages,
|
||||
# while edits to existing messages must use the single-message PATCH API.
|
||||
merged_messages = {
|
||||
**existing_messages,
|
||||
**{
|
||||
message_id: message
|
||||
for message_id, message in incoming_messages.items()
|
||||
if message_id not in existing_messages
|
||||
},
|
||||
}
|
||||
|
||||
for message in merged_messages.values():
|
||||
message['childrenIds'] = []
|
||||
for message_id, message in merged_messages.items():
|
||||
parent_id = message.get('parentId')
|
||||
if parent_id in merged_messages:
|
||||
merged_messages[parent_id]['childrenIds'].append(message_id)
|
||||
|
||||
current_id = incoming_history.get('currentId')
|
||||
if current_id not in merged_messages:
|
||||
current_id = existing_history.get('currentId')
|
||||
if current_id not in merged_messages:
|
||||
current_id = None
|
||||
|
||||
merged_history = {**existing_history, **incoming_history}
|
||||
merged_history.pop(MESSAGE_WINDOW_KEY, None)
|
||||
merged_history['messages'] = merged_messages
|
||||
merged_history['currentId'] = current_id
|
||||
merged_chat['history'] = merged_history
|
||||
|
||||
# Rebuild the canonical branch list from authoritative message bodies;
|
||||
# the top-level list in a window response is only a partial snapshot.
|
||||
merged_chat['messages'] = create_message_list(merged_messages, current_id)
|
||||
|
||||
chat_item.chat = merged_chat
|
||||
flag_modified(chat_item, 'chat')
|
||||
if 'title' in incoming_chat:
|
||||
chat_item.title = self._clean_null_bytes(incoming_chat['title'])
|
||||
chat_item.current_message_id = current_id
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception as exc:
|
||||
log.exception('Failed to update chat window %s: %s', id, exc)
|
||||
return None
|
||||
|
||||
async def patch_message_by_chat_id_and_message_id(
|
||||
self,
|
||||
id: str,
|
||||
message_id: str,
|
||||
patch: dict,
|
||||
*,
|
||||
touch: bool = True,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict | None:
|
||||
"""Patch one existing embedded message under the chat row lock."""
|
||||
protected_fields = {
|
||||
MESSAGE_LOADED_KEY,
|
||||
'childrenIds',
|
||||
'id',
|
||||
'parentId',
|
||||
'role',
|
||||
'timestamp',
|
||||
}
|
||||
clean_patch = {
|
||||
key: value for key, value in self._clean_null_bytes(patch).items() if key not in protected_fields
|
||||
}
|
||||
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id, with_for_update=True)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
chat_data = deepcopy(chat_item.chat or {})
|
||||
history = chat_data.get('history') or {}
|
||||
messages = history.get('messages') or {}
|
||||
message = messages.get(message_id)
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
|
||||
for key, value in clean_patch.items():
|
||||
if value is None:
|
||||
message.pop(key, None)
|
||||
else:
|
||||
message[key] = value
|
||||
|
||||
chat_item.chat = chat_data
|
||||
chat_item.current_message_id = history.get('currentId')
|
||||
flag_modified(chat_item, 'chat')
|
||||
if clean_patch and touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
||||
await session.commit()
|
||||
return deepcopy(message)
|
||||
except Exception as exc:
|
||||
log.exception('Failed to patch message %s for chat %s: %s', message_id, id, exc)
|
||||
return None
|
||||
|
||||
async def update_chat_last_read_at_by_id(
|
||||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> tuple[int, bool] | None:
|
||||
|
|
@ -966,7 +1102,13 @@ class ChatTable:
|
|||
return chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
|
||||
|
||||
async def upsert_message_to_chat_by_id_and_message_id(
|
||||
self, id: str, message_id: str, message: dict, *, touch: bool = True
|
||||
self,
|
||||
id: str,
|
||||
message_id: str,
|
||||
message: dict,
|
||||
*,
|
||||
touch: bool = True,
|
||||
include_messages: bool = False,
|
||||
) -> ChatModel | None:
|
||||
if not message.get('content'):
|
||||
output_text = get_output_text(message.get('output'))
|
||||
|
|
@ -977,88 +1119,158 @@ class ChatTable:
|
|||
if isinstance(message.get('content'), str):
|
||||
message['content'] = sanitize_text_for_db(message['content'])
|
||||
|
||||
clean_message = self._clean_null_bytes(message)
|
||||
|
||||
try:
|
||||
async with get_async_db_context() as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
chat_item = await session.get(Chat, id, with_for_update=True)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
self._sanitize_chat_row(chat_item)
|
||||
chat = chat_item.chat or {}
|
||||
self._repair_chat_current_id(chat)
|
||||
chat_data = deepcopy(chat_item.chat or {})
|
||||
self._repair_chat_current_id(chat_data)
|
||||
history = chat_data.setdefault('history', {})
|
||||
messages = history.setdefault('messages', {})
|
||||
existing_message = messages.get(message_id)
|
||||
is_new_message = not isinstance(existing_message, dict)
|
||||
|
||||
history = chat.get('history', {})
|
||||
saved_message = self.upsert_message_to_history(history, message_id, message)
|
||||
chat['history'] = history
|
||||
clean_chat = self._clean_null_bytes(chat)
|
||||
chat_item.chat = clean_chat
|
||||
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
|
||||
chat_item.current_message_id = self.get_current_message_id(clean_chat)
|
||||
old_parent_id = existing_message.get('parentId') if not is_new_message else None
|
||||
payload = dict(clean_message)
|
||||
if is_new_message:
|
||||
parent_id = payload.get('parentId')
|
||||
if parent_id is None:
|
||||
parent_id = next(
|
||||
(
|
||||
candidate_id
|
||||
for candidate_id, candidate in messages.items()
|
||||
if message_id in (candidate.get('childrenIds') or [])
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
parent = messages.get(parent_id) if parent_id else None
|
||||
output = payload.get('output') or []
|
||||
output_role = next(
|
||||
(item.get('role') for item in output if isinstance(item, dict) and item.get('role')),
|
||||
None,
|
||||
)
|
||||
role = payload.get('role') or output_role
|
||||
if not role:
|
||||
parent_role = parent.get('role') if parent else None
|
||||
role = (
|
||||
'assistant'
|
||||
if parent_role == 'user'
|
||||
else 'user'
|
||||
if parent_role == 'assistant'
|
||||
else 'assistant'
|
||||
)
|
||||
|
||||
payload = {
|
||||
**payload,
|
||||
'id': message_id,
|
||||
'parentId': parent_id,
|
||||
'childrenIds': (
|
||||
payload.get('childrenIds') if isinstance(payload.get('childrenIds'), list) else []
|
||||
),
|
||||
'role': role,
|
||||
'timestamp': payload.get('timestamp') or int(time.time()),
|
||||
}
|
||||
messages[message_id] = payload
|
||||
else:
|
||||
messages[message_id] = {**existing_message, **payload}
|
||||
|
||||
new_parent_id = messages[message_id].get('parentId')
|
||||
if old_parent_id and old_parent_id != new_parent_id and old_parent_id in messages:
|
||||
messages[old_parent_id]['childrenIds'] = [
|
||||
child_id
|
||||
for child_id in (messages[old_parent_id].get('childrenIds') or [])
|
||||
if child_id != message_id
|
||||
]
|
||||
if new_parent_id and (is_new_message or old_parent_id != new_parent_id) and new_parent_id in messages:
|
||||
children_ids = [
|
||||
child_id
|
||||
for child_id in (messages[new_parent_id].get('childrenIds') or [])
|
||||
if child_id != message_id
|
||||
]
|
||||
messages[new_parent_id]['childrenIds'] = [*children_ids, message_id]
|
||||
|
||||
if is_new_message:
|
||||
history['currentId'] = message_id
|
||||
chat_item.current_message_id = history.get('currentId')
|
||||
|
||||
chat_item.chat = chat_data
|
||||
chat_item.title = self._clean_null_bytes(chat_data['title']) if 'title' in chat_data else 'New Chat'
|
||||
flag_modified(chat_item, 'chat')
|
||||
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
||||
await session.commit()
|
||||
updated_chat = ChatModel.model_validate(chat_item)
|
||||
user_id = chat_item.user_id
|
||||
await session.refresh(chat_item)
|
||||
result = ChatModel.model_validate(chat_item)
|
||||
|
||||
# Dual-write to chat_message table
|
||||
try:
|
||||
await ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=id,
|
||||
user_id=user_id,
|
||||
data=saved_message,
|
||||
user_id=result.user_id,
|
||||
data=messages[message_id],
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to write to chat_message table: {e}')
|
||||
except Exception as exc:
|
||||
log.warning('Failed to mirror message %s for chat %s: %s', message_id, id, exc)
|
||||
|
||||
return updated_chat
|
||||
except Exception:
|
||||
if not include_messages:
|
||||
return result
|
||||
return result
|
||||
except Exception as exc:
|
||||
log.exception('Failed to upsert message %s for chat %s: %s', message_id, id, exc)
|
||||
return None
|
||||
|
||||
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
|
||||
async def delete_message_from_chat_by_id_and_message_id(
|
||||
self,
|
||||
id: str,
|
||||
message_id: str,
|
||||
*,
|
||||
include_messages: bool = True,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ChatModel | None:
|
||||
del include_messages
|
||||
try:
|
||||
async with get_async_db_context() as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id, with_for_update=True)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
self._sanitize_chat_row(chat_item)
|
||||
chat = chat_item.chat or {}
|
||||
self._repair_chat_current_id(chat)
|
||||
|
||||
history = chat.get('history', {})
|
||||
chat_data = deepcopy(chat_item.chat or {})
|
||||
self._repair_chat_current_id(chat_data)
|
||||
history = chat_data.get('history') or {}
|
||||
deleted_ids = self.delete_message_from_history(history, message_id)
|
||||
if not deleted_ids:
|
||||
clean_chat = self._clean_null_bytes(chat)
|
||||
chat_item.chat = clean_chat
|
||||
chat_item.title = (
|
||||
self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
|
||||
)
|
||||
chat_item.current_message_id = self.get_current_message_id(clean_chat)
|
||||
chat_item.chat = self._clean_null_bytes(chat_data)
|
||||
chat_item.current_message_id = self.get_current_message_id(chat_data)
|
||||
flag_modified(chat_item, 'chat')
|
||||
await session.commit()
|
||||
return ChatModel.model_validate(chat_item)
|
||||
|
||||
messages = history.get('messages') or {}
|
||||
chat['history'] = history
|
||||
clean_chat = self._clean_null_bytes(chat)
|
||||
chat_data['history'] = history
|
||||
clean_chat = self._clean_null_bytes(chat_data)
|
||||
chat_item.chat = clean_chat
|
||||
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
|
||||
chat_item.current_message_id = self.get_current_message_id(clean_chat)
|
||||
flag_modified(chat_item, 'chat')
|
||||
chat_item.updated_at = int(time.time())
|
||||
flag_modified(chat_item, 'chat')
|
||||
await session.commit()
|
||||
updated_chat = ChatModel.model_validate(chat_item)
|
||||
await session.refresh(chat_item)
|
||||
result = ChatModel.model_validate(chat_item)
|
||||
user_id = chat_item.user_id
|
||||
messages = history.get('messages') or {}
|
||||
|
||||
await self.backfill_messages_by_chat_id(id, user_id, messages)
|
||||
await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids)
|
||||
|
||||
return updated_chat
|
||||
except Exception:
|
||||
return result
|
||||
except Exception as exc:
|
||||
log.exception('Failed to delete message %s from chat %s: %s', message_id, id, exc)
|
||||
return None
|
||||
|
||||
async def add_message_status_to_chat_by_id_and_message_id(
|
||||
|
|
@ -1492,13 +1704,46 @@ class ChatTable:
|
|||
'total': total,
|
||||
}
|
||||
|
||||
async def get_chat_window(
|
||||
self,
|
||||
chat: ChatModel,
|
||||
limit: int = 32,
|
||||
current_id: str | None = None,
|
||||
before_id: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ChatModel:
|
||||
"""Return full branch topology with only one bounded page of message bodies."""
|
||||
del db
|
||||
history = chat.chat.get('history') or {}
|
||||
messages = history.get('messages') or {}
|
||||
current_id = current_id or history.get('currentId')
|
||||
window = create_message_window(
|
||||
messages,
|
||||
current_id=current_id,
|
||||
limit=limit,
|
||||
before_id=before_id,
|
||||
)
|
||||
window_chat = build_window_chat(
|
||||
chat.chat,
|
||||
topology=window['topology'],
|
||||
loaded_messages=window['messages'],
|
||||
loaded_ids=window['loaded_ids'],
|
||||
has_more=window['has_more'],
|
||||
limit=limit,
|
||||
current_id=window['current_id'],
|
||||
)
|
||||
return chat.model_copy(update={'chat': window_chat})
|
||||
|
||||
# retrieve conversation
|
||||
async def get_chat_by_id(
|
||||
self,
|
||||
id: str,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
include_messages: bool = True,
|
||||
) -> ChatModel | None:
|
||||
"""Fetch a chat by PK, auto-sanitizing null bytes on read."""
|
||||
del include_messages
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
|
|
@ -1508,7 +1753,11 @@ class ChatTable:
|
|||
repaired_history = self._repair_chat_current_id(chat_item.chat or {})
|
||||
if repaired_history:
|
||||
flag_modified(chat_item, 'chat')
|
||||
if self._sanitize_chat_row(chat_item) or repaired_history:
|
||||
current_message_id = self.get_current_message_id(chat_item.chat or {})
|
||||
repaired_column = chat_item.current_message_id != current_message_id
|
||||
if repaired_column:
|
||||
chat_item.current_message_id = current_message_id
|
||||
if self._sanitize_chat_row(chat_item) or repaired_history or repaired_column:
|
||||
await session.commit()
|
||||
|
||||
return ChatModel.model_validate(chat_item)
|
||||
|
|
@ -1537,8 +1786,14 @@ class ChatTable:
|
|||
return None
|
||||
|
||||
async def get_chat_by_id_and_user_id(
|
||||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
self,
|
||||
id: str,
|
||||
user_id: str,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
include_messages: bool = True,
|
||||
) -> ChatModel | None:
|
||||
del include_messages
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(Chat).filter_by(id=id, user_id=user_id))
|
||||
|
|
@ -1549,7 +1804,11 @@ class ChatTable:
|
|||
repaired_history = self._repair_chat_current_id(chat.chat or {})
|
||||
if repaired_history:
|
||||
flag_modified(chat, 'chat')
|
||||
if self._sanitize_chat_row(chat) or repaired_history:
|
||||
current_message_id = self.get_current_message_id(chat.chat or {})
|
||||
repaired_column = chat.current_message_id != current_message_id
|
||||
if repaired_column:
|
||||
chat.current_message_id = current_message_id
|
||||
if self._sanitize_chat_row(chat) or repaired_history or repaired_column:
|
||||
await session.commit()
|
||||
|
||||
return ChatModel.model_validate(chat)
|
||||
|
|
|
|||
|
|
@ -1301,35 +1301,115 @@ async def compact_chat_by_id(
|
|||
############################
|
||||
|
||||
|
||||
@router.get('/{id}', response_model=ChatResponse | None)
|
||||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
async def get_accessible_chat_by_id(
|
||||
id: str,
|
||||
user,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_messages: bool,
|
||||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(
|
||||
id,
|
||||
user.id,
|
||||
db=db,
|
||||
include_messages=include_messages,
|
||||
)
|
||||
|
||||
if not chat and user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if chat:
|
||||
return chat
|
||||
|
||||
candidate = None
|
||||
if user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db, include_messages=include_messages)
|
||||
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
|
||||
chat = candidate
|
||||
return candidate
|
||||
|
||||
# Access explicitly granted to this user applies to admins too, so an admin
|
||||
# does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off.
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
return await Chats.get_chat_by_id(id, db=db, include_messages=include_messages)
|
||||
|
||||
if candidate is None:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db, include_messages=include_messages)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get('/{id}/window', response_model=ChatResponse | None)
|
||||
async def get_chat_window_by_id(
|
||||
id: str,
|
||||
limit: int = 32,
|
||||
current_id: str | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
limit = max(1, min(limit, 128))
|
||||
chat = await get_accessible_chat_by_id(id, user, db, include_messages=False)
|
||||
if not chat:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
try:
|
||||
chat = await Chats.get_chat_window(chat, limit=limit, current_id=current_id, db=db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
data = ChatResponse(**chat.model_dump()).model_dump()
|
||||
data['context_usage'] = await get_chat_context_usage(chat)
|
||||
return data
|
||||
|
||||
|
||||
@router.get('/{id}/history/window')
|
||||
async def get_chat_history_window_by_id(
|
||||
id: str,
|
||||
current_id: str,
|
||||
limit: int = 32,
|
||||
before_id: str | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
limit = max(1, min(limit, 128))
|
||||
chat = await get_accessible_chat_by_id(id, user, db, include_messages=False)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
try:
|
||||
window_chat = await Chats.get_chat_window(
|
||||
chat,
|
||||
current_id=current_id,
|
||||
limit=limit,
|
||||
before_id=before_id,
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
# Check folder-based access (shared folders)
|
||||
if not chat:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
chat = candidate
|
||||
history = window_chat.chat.get('history') or {}
|
||||
metadata = history.get('messageWindow') or {}
|
||||
loaded_ids = metadata.get('loadedIds') or []
|
||||
return {
|
||||
'messages': {
|
||||
message_id: history['messages'][message_id]
|
||||
for message_id in loaded_ids
|
||||
if message_id in (history.get('messages') or {})
|
||||
},
|
||||
'loadedIds': loaded_ids,
|
||||
'hasMore': metadata.get('hasMore', False),
|
||||
'currentId': history.get('currentId'),
|
||||
}
|
||||
|
||||
|
||||
@router.get('/{id}', response_model=ChatResponse | None)
|
||||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await get_accessible_chat_by_id(id, user, db, include_messages=True)
|
||||
|
||||
if chat:
|
||||
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
|
||||
|
|
@ -1344,6 +1424,39 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
|
|||
############################
|
||||
|
||||
|
||||
@router.post('/{id}/window', response_model=ChatResponse | None)
|
||||
async def update_chat_window_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ChatForm,
|
||||
limit: int = 32,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
limit = max(1, min(limit, 128))
|
||||
if not await Chats.is_chat_owner(id, user.id, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chat = await Chats.update_chat_window_by_id(id, form_data.chat, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chat = await Chats.get_chat_window(chat, limit=limit, db=db)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_UPDATED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'title': chat.title},
|
||||
)
|
||||
data = ChatResponse(**chat.model_dump()).model_dump()
|
||||
data['context_usage'] = await get_chat_context_usage(chat)
|
||||
return data
|
||||
|
||||
|
||||
@router.post('/{id}', response_model=ChatResponse | None)
|
||||
async def update_chat_by_id(
|
||||
request: Request,
|
||||
|
|
@ -1402,6 +1515,78 @@ class MessageForm(BaseModel):
|
|||
content: str
|
||||
|
||||
|
||||
class MessagePatchForm(BaseModel):
|
||||
message: dict
|
||||
|
||||
|
||||
@router.patch('/{id}/messages/{message_id}', response_model=dict | None)
|
||||
async def patch_chat_message_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
message_id: str,
|
||||
form_data: MessagePatchForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id(id, db=db, include_messages=False)
|
||||
|
||||
if not chat or (chat.user_id != user.id and user.role != 'admin'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
message = await Chats.patch_message_by_chat_id_and_message_id(
|
||||
id,
|
||||
message_id,
|
||||
form_data.message,
|
||||
db=db,
|
||||
)
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
event_emitter = await get_event_emitter(
|
||||
{
|
||||
'user_id': chat.user_id,
|
||||
'chat_id': id,
|
||||
'message_id': message_id,
|
||||
},
|
||||
False,
|
||||
)
|
||||
protected_fields = {'__loaded', 'childrenIds', 'id', 'parentId', 'role', 'timestamp'}
|
||||
response_patch = {
|
||||
key: message.get(key) for key in form_data.message if key not in protected_fields and key in message
|
||||
}
|
||||
|
||||
if event_emitter:
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'chat:message',
|
||||
'data': {
|
||||
'chat_id': id,
|
||||
'message_id': message_id,
|
||||
**response_patch,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
content = form_data.message.get('content')
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MESSAGE_UPDATED,
|
||||
actor=user,
|
||||
subject_id=message_id,
|
||||
data={
|
||||
'chat_id': id,
|
||||
'content_preview': content[:300] if isinstance(content, str) else '',
|
||||
},
|
||||
)
|
||||
return {'id': message_id, **response_patch}
|
||||
|
||||
|
||||
@router.post('/{id}/messages/{message_id}', response_model=ChatResponse | None)
|
||||
async def update_chat_message_by_id(
|
||||
request: Request,
|
||||
|
|
@ -1469,10 +1654,11 @@ async def delete_chat_message_by_id(
|
|||
request: Request,
|
||||
id: str,
|
||||
message_id: str,
|
||||
compact: bool = False,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
chat = await Chats.get_chat_by_id(id, db=db, include_messages=False)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
|
|
@ -1486,7 +1672,18 @@ async def delete_chat_message_by_id(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chat = await Chats.delete_message_from_chat_by_id_and_message_id(id, message_id)
|
||||
if await has_active_tasks(request.app.state.redis, id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before deleting messages.',
|
||||
)
|
||||
|
||||
chat = await Chats.delete_message_from_chat_by_id_and_message_id(
|
||||
id,
|
||||
message_id,
|
||||
include_messages=not compact,
|
||||
db=db,
|
||||
)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -1500,6 +1697,8 @@ async def delete_chat_message_by_id(
|
|||
subject_id=message_id,
|
||||
data={'chat_id': id},
|
||||
)
|
||||
if compact:
|
||||
chat = await Chats.get_chat_window(chat, limit=32, db=db)
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
|
|
|
|||
238
backend/open_webui/utils/chat_history.py
Normal file
238
backend/open_webui/utils/chat_history.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
MESSAGE_STORAGE_VERSION = 1
|
||||
MESSAGE_STORAGE_KEY = 'messageStorage'
|
||||
MESSAGE_WINDOW_KEY = 'messageWindow'
|
||||
MESSAGE_LOADED_KEY = '__loaded'
|
||||
TOPOLOGY_KEYS = (
|
||||
'id',
|
||||
'parentId',
|
||||
'childrenIds',
|
||||
'role',
|
||||
'timestamp',
|
||||
'model',
|
||||
'modelIdx',
|
||||
'modelName',
|
||||
'models',
|
||||
'done',
|
||||
)
|
||||
|
||||
|
||||
def _message_map(value: Any) -> dict[str, dict]:
|
||||
if isinstance(value, dict):
|
||||
return {str(message_id): message for message_id, message in value.items() if isinstance(message, dict)}
|
||||
|
||||
if isinstance(value, list):
|
||||
return {str(message['id']): message for message in value if isinstance(message, dict) and message.get('id')}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def has_embedded_messages(chat: dict | None) -> bool:
|
||||
if not isinstance(chat, dict):
|
||||
return False
|
||||
history = chat.get('history')
|
||||
return (isinstance(history, dict) and 'messages' in history) or 'messages' in chat
|
||||
|
||||
|
||||
def uses_normalized_message_storage(chat: dict | None) -> bool:
|
||||
if not isinstance(chat, dict):
|
||||
return False
|
||||
history = chat.get('history')
|
||||
storage = history.get(MESSAGE_STORAGE_KEY) if isinstance(history, dict) else None
|
||||
return isinstance(storage, dict) and storage.get('version') == MESSAGE_STORAGE_VERSION
|
||||
|
||||
|
||||
def prepare_messages_for_storage(messages: Any) -> dict[str, dict]:
|
||||
"""Return lossless message copies without response-only window markers."""
|
||||
prepared: dict[str, dict] = {}
|
||||
for message_id, message in _message_map(messages).items():
|
||||
clean_message = deepcopy(message)
|
||||
clean_message.pop(MESSAGE_LOADED_KEY, None)
|
||||
clean_message.setdefault('id', message_id)
|
||||
prepared[message_id] = clean_message
|
||||
return prepared
|
||||
|
||||
|
||||
def copy_chat_without_messages(chat: dict | None) -> dict:
|
||||
"""Copy chat metadata without adding storage-specific response markers."""
|
||||
compact = deepcopy(chat or {})
|
||||
history = compact.get('history')
|
||||
if not isinstance(history, dict):
|
||||
history = {}
|
||||
else:
|
||||
history = deepcopy(history)
|
||||
|
||||
history.pop('messages', None)
|
||||
history.pop(MESSAGE_WINDOW_KEY, None)
|
||||
compact.pop('messages', None)
|
||||
compact['history'] = history
|
||||
return compact
|
||||
|
||||
|
||||
def split_chat_messages(chat: dict | None) -> tuple[dict, dict[str, dict]]:
|
||||
"""Separate embedded messages from compact chat metadata."""
|
||||
source = deepcopy(chat or {})
|
||||
source_history = source.get('history') if isinstance(source.get('history'), dict) else {}
|
||||
messages = _message_map(source_history.get('messages'))
|
||||
top_level_messages = source.get('messages')
|
||||
if not messages:
|
||||
messages = _message_map(top_level_messages)
|
||||
|
||||
compact = copy_chat_without_messages(source)
|
||||
history = compact['history']
|
||||
history[MESSAGE_STORAGE_KEY] = {'version': MESSAGE_STORAGE_VERSION}
|
||||
return compact, prepare_messages_for_storage(messages)
|
||||
|
||||
|
||||
def merge_compact_chat(existing: dict | None, incoming: dict | None) -> tuple[dict, dict[str, dict]]:
|
||||
"""Merge chat metadata while returning only messages supplied by the caller."""
|
||||
existing_compact, _ = split_chat_messages(existing)
|
||||
incoming_compact, incoming_messages = split_chat_messages(incoming)
|
||||
|
||||
merged = {**existing_compact, **incoming_compact}
|
||||
existing_history = existing_compact.get('history') or {}
|
||||
incoming_history = incoming_compact.get('history') or {}
|
||||
merged['history'] = {**existing_history, **incoming_history}
|
||||
merged['history'][MESSAGE_STORAGE_KEY] = {'version': MESSAGE_STORAGE_VERSION}
|
||||
return merged, incoming_messages
|
||||
|
||||
|
||||
def create_message_list(messages: dict[str, dict], current_id: str | None) -> list[dict]:
|
||||
message_list: list[dict] = []
|
||||
visited: set[str] = set()
|
||||
|
||||
while current_id and current_id not in visited:
|
||||
visited.add(current_id)
|
||||
message = messages.get(current_id)
|
||||
if not isinstance(message, dict):
|
||||
break
|
||||
message_list.append(message)
|
||||
current_id = message.get('parentId')
|
||||
|
||||
message_list.reverse()
|
||||
return message_list
|
||||
|
||||
|
||||
def create_message_window(
|
||||
messages: dict[str, dict],
|
||||
current_id: str | None,
|
||||
limit: int,
|
||||
before_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a branch window from a legacy in-memory message map."""
|
||||
if limit < 1:
|
||||
raise ValueError('limit must be at least 1')
|
||||
|
||||
clean_messages = prepare_messages_for_storage(messages)
|
||||
if current_id is not None and current_id not in clean_messages:
|
||||
raise ValueError('current_id does not exist in this chat')
|
||||
|
||||
topology = {
|
||||
message_id: {key: message[key] for key in TOPOLOGY_KEYS if key in message}
|
||||
for message_id, message in clean_messages.items()
|
||||
}
|
||||
|
||||
branch_ids = [message.get('id') for message in create_message_list(clean_messages, current_id) if message.get('id')]
|
||||
if before_id is not None:
|
||||
if before_id not in branch_ids:
|
||||
raise ValueError('before_id is not an ancestor of current_id')
|
||||
branch_ids = branch_ids[: branch_ids.index(before_id)]
|
||||
|
||||
loaded_ids = branch_ids[-limit:]
|
||||
return {
|
||||
'topology': topology,
|
||||
'messages': {message_id: clean_messages[message_id] for message_id in loaded_ids},
|
||||
'loaded_ids': loaded_ids,
|
||||
'has_more': len(branch_ids) > len(loaded_ids),
|
||||
'current_id': current_id,
|
||||
}
|
||||
|
||||
|
||||
def hydrate_chat(chat: dict | None, messages: dict[str, dict]) -> dict:
|
||||
"""Rebuild the legacy full-chat shape for compatibility endpoints."""
|
||||
hydrated, _ = split_chat_messages(chat)
|
||||
history = hydrated.setdefault('history', {})
|
||||
clean_messages = prepare_messages_for_storage(messages)
|
||||
history['messages'] = clean_messages
|
||||
hydrated['messages'] = create_message_list(clean_messages, history.get('currentId'))
|
||||
return hydrated
|
||||
|
||||
|
||||
def build_window_chat(
|
||||
chat: dict | None,
|
||||
topology: dict[str, dict],
|
||||
loaded_messages: dict[str, dict],
|
||||
loaded_ids: list[str],
|
||||
has_more: bool,
|
||||
limit: int,
|
||||
current_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Build a response containing full topology and only a window of message bodies."""
|
||||
window_chat = copy_chat_without_messages(chat)
|
||||
history = window_chat.setdefault('history', {})
|
||||
if current_id is not None:
|
||||
history['currentId'] = current_id
|
||||
|
||||
clean_loaded = prepare_messages_for_storage(loaded_messages)
|
||||
messages: dict[str, dict] = {}
|
||||
|
||||
for message_id, topology_message in topology.items():
|
||||
topology_copy = deepcopy(topology_message)
|
||||
if message_id in clean_loaded:
|
||||
messages[message_id] = {
|
||||
**clean_loaded[message_id],
|
||||
**topology_copy,
|
||||
MESSAGE_LOADED_KEY: True,
|
||||
}
|
||||
else:
|
||||
messages[message_id] = {
|
||||
**topology_copy,
|
||||
MESSAGE_LOADED_KEY: False,
|
||||
}
|
||||
|
||||
for message_id, message in clean_loaded.items():
|
||||
if message_id not in messages:
|
||||
messages[message_id] = {**message, MESSAGE_LOADED_KEY: True}
|
||||
|
||||
unique_loaded_ids = list(dict.fromkeys(loaded_ids))
|
||||
history['messages'] = messages
|
||||
history[MESSAGE_WINDOW_KEY] = {
|
||||
'loadedIds': [message_id for message_id in unique_loaded_ids if message_id in messages],
|
||||
'hasMore': has_more,
|
||||
'limit': limit,
|
||||
}
|
||||
return window_chat
|
||||
|
||||
|
||||
def inline_message_images_from_files(messages: list[dict]) -> list[dict]:
|
||||
"""Move attached images into message content and remove storage-only files."""
|
||||
for message in messages:
|
||||
raw_files = message.get('files')
|
||||
files = raw_files if isinstance(raw_files, list) else []
|
||||
image_files = [
|
||||
file
|
||||
for file in files
|
||||
if isinstance(file, dict)
|
||||
and (file.get('type') == 'image' or (file.get('content_type') or '').startswith('image/'))
|
||||
]
|
||||
if message.get('role') == 'user' and image_files:
|
||||
text_content = message.get('content', '')
|
||||
if isinstance(text_content, str):
|
||||
message['content'] = [
|
||||
{'type': 'text', 'text': text_content},
|
||||
*[
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': file['url']},
|
||||
}
|
||||
for file in image_files
|
||||
if file.get('url')
|
||||
],
|
||||
]
|
||||
message.pop('files', None)
|
||||
|
||||
return messages
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { getTimeRange } from '$lib/utils';
|
||||
import {
|
||||
DEFAULT_CHAT_MESSAGE_WINDOW,
|
||||
type ChatHistoryWindowResponse
|
||||
} from '$lib/utils/chatHistoryWindow';
|
||||
|
||||
export const getChatConfig = async (token: string) => {
|
||||
let error = null;
|
||||
|
|
@ -777,6 +781,87 @@ export const getChatById = async (token: string, id: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getChatByIdWindow = async (
|
||||
token: string,
|
||||
id: string,
|
||||
limit: number = DEFAULT_CHAT_MESSAGE_WINDOW,
|
||||
currentId?: string
|
||||
) => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('limit', `${limit}`);
|
||||
if (currentId) {
|
||||
searchParams.set('current_id', currentId);
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/window?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err?.detail ?? err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getChatHistoryWindow = async (
|
||||
token: string,
|
||||
id: string,
|
||||
currentId: string,
|
||||
limit: number = DEFAULT_CHAT_MESSAGE_WINDOW,
|
||||
beforeId?: string
|
||||
): Promise<ChatHistoryWindowResponse> => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('current_id', currentId);
|
||||
searchParams.set('limit', `${limit}`);
|
||||
if (beforeId) {
|
||||
searchParams.set('before_id', beforeId);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/chats/${id}/history/window?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err?.detail ?? err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getChatByShareId = async (token: string, share_id: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
@ -1300,6 +1385,37 @@ export const updateChatById = async (
|
|||
return res;
|
||||
};
|
||||
|
||||
export const updateChatByIdWindow = async (token: string, id: string, chat: object) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/window`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err?.detail ?? err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const compactChatById = async (token: string, id: string, model?: string | null) => {
|
||||
let error = null;
|
||||
|
||||
|
|
@ -1333,10 +1449,44 @@ export const compactChatById = async (token: string, id: string, model?: string
|
|||
return res;
|
||||
};
|
||||
|
||||
export const deleteChatMessageById = async (token: string, id: string, messageId: string) => {
|
||||
export const updateChatMessageById = async (
|
||||
token: string,
|
||||
id: string,
|
||||
messageId: string,
|
||||
message: object
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/messages/${messageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({ message })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err?.detail ?? err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteChatMessageById = async (token: string, id: string, messageId: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/messages/${messageId}?compact=true`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
|
|
|||
|
|
@ -76,11 +76,20 @@
|
|||
deleteChatById,
|
||||
forkChatById,
|
||||
getAllTags,
|
||||
getChatById,
|
||||
getChatByIdWindow,
|
||||
getChatHistoryWindow,
|
||||
getTagsById,
|
||||
updateChatById,
|
||||
updateChatByIdWindow,
|
||||
updateChatFolderIdById
|
||||
} from '$lib/apis/chats';
|
||||
import {
|
||||
DEFAULT_CHAT_MESSAGE_WINDOW,
|
||||
isChatMessageLoaded,
|
||||
mergeChatMessageEvent,
|
||||
mergeChatHistoryWindow,
|
||||
resolveLoadedCurrentMessageId,
|
||||
serializeChatForWindowSave
|
||||
} from '$lib/utils/chatHistoryWindow';
|
||||
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
|
||||
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
|
||||
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
|
||||
|
|
@ -121,6 +130,17 @@
|
|||
import EmbeddedChatHistoryDropdown from './EmbeddedChatHistoryDropdown.svelte';
|
||||
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
|
||||
|
||||
type ChatHistoryState = {
|
||||
currentId: string | null;
|
||||
messages: Record<string, any>;
|
||||
messageWindow?: {
|
||||
loadedIds?: string[];
|
||||
hasMore?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export let chatIdProp = '';
|
||||
export let embedded = false;
|
||||
export let embeddedTitle = '';
|
||||
|
|
@ -138,6 +158,84 @@
|
|||
null;
|
||||
|
||||
let loading = true;
|
||||
let chatDestroyed = false;
|
||||
let loadChatGeneration = 0;
|
||||
const historyWindowRequests = new Map<string, Promise<boolean>>();
|
||||
let showMessageNavigationGeneration = 0;
|
||||
onDestroy(() => {
|
||||
chatDestroyed = true;
|
||||
loadChatGeneration += 1;
|
||||
showMessageNavigationGeneration += 1;
|
||||
historyWindowRequests.clear();
|
||||
});
|
||||
|
||||
const getWindowSavePayload = (payload: any) => {
|
||||
const serialized = serializeChatForWindowSave(payload);
|
||||
|
||||
if (serialized.history && Array.isArray(serialized.messages)) {
|
||||
const loadedMessageIds = new Set(Object.keys(serialized.history.messages ?? {}));
|
||||
serialized.messages = serialized.messages.filter(
|
||||
(message: any) => !message?.id || loadedMessageIds.has(message.id)
|
||||
);
|
||||
}
|
||||
|
||||
return serialized;
|
||||
};
|
||||
|
||||
const updateChatWindow = async (id: string, payload: any) =>
|
||||
await updateChatByIdWindow(localStorage.token, id, getWindowSavePayload(payload));
|
||||
|
||||
const hydrateBranchWindow = async (messageId: string, navigationGeneration: number) => {
|
||||
if (
|
||||
!messageId ||
|
||||
!history?.messageWindow ||
|
||||
$temporaryChatEnabled ||
|
||||
!$chatId ||
|
||||
$chatId.startsWith('local:')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestChatId = $chatId;
|
||||
const requestKey = `${navigationGeneration}:${requestChatId}:${messageId}`;
|
||||
const pendingRequest = historyWindowRequests.get(requestKey);
|
||||
if (pendingRequest) return await pendingRequest;
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const window = await getChatHistoryWindow(
|
||||
localStorage.token,
|
||||
requestChatId,
|
||||
messageId,
|
||||
DEFAULT_CHAT_MESSAGE_WINDOW
|
||||
);
|
||||
if (
|
||||
!window ||
|
||||
chatDestroyed ||
|
||||
$chatId !== requestChatId ||
|
||||
navigationGeneration !== showMessageNavigationGeneration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeCurrentId = history.currentId;
|
||||
const mergedHistory = mergeChatHistoryWindow(history, window) as ChatHistoryState;
|
||||
mergedHistory.currentId = activeCurrentId;
|
||||
history = mergedHistory;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load chat history branch', error);
|
||||
toast.error($i18n.t('Failed to load chat'));
|
||||
return false;
|
||||
} finally {
|
||||
historyWindowRequests.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
historyWindowRequests.set(requestKey, request);
|
||||
return await request;
|
||||
};
|
||||
|
||||
$: chatContainerId = embedded ? 'note-chat-container' : 'chat-container';
|
||||
$: messageInputDropzoneId = embedded ? 'note-chat-input-dropzone' : 'chat-pane';
|
||||
|
||||
|
|
@ -375,7 +473,7 @@
|
|||
|
||||
let chatTasks = [];
|
||||
|
||||
let history = {
|
||||
let history: ChatHistoryState = {
|
||||
messages: {},
|
||||
currentId: null
|
||||
};
|
||||
|
|
@ -853,6 +951,7 @@
|
|||
};
|
||||
|
||||
const showMessage = async (message, scroll = true, save = true) => {
|
||||
const navigationGeneration = ++showMessageNavigationGeneration;
|
||||
const _chatId = JSON.parse(JSON.stringify($chatId));
|
||||
let _messageId = JSON.parse(JSON.stringify(message.id));
|
||||
|
||||
|
|
@ -870,6 +969,9 @@
|
|||
messageChildrenIds = history.messages[_messageId].childrenIds;
|
||||
}
|
||||
|
||||
if (_messageId && !(await hydrateBranchWindow(_messageId, navigationGeneration))) return;
|
||||
if (navigationGeneration !== showMessageNavigationGeneration) return;
|
||||
|
||||
history.currentId = _messageId;
|
||||
|
||||
await tick();
|
||||
|
|
@ -885,8 +987,8 @@
|
|||
await tick();
|
||||
await tick();
|
||||
|
||||
if (save) {
|
||||
saveChatHandler(_chatId, history);
|
||||
if (save && !readOnly) {
|
||||
await saveChatHandler(_chatId, history);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -997,15 +1099,29 @@
|
|||
message.done = true;
|
||||
}
|
||||
} else if (type === 'chat:message:delta' || type === 'message') {
|
||||
message.content += data.content;
|
||||
} else if (type === 'chat:message' || type === 'replace') {
|
||||
message.content = data.content;
|
||||
message.content = `${message.content ?? ''}${data?.content ?? ''}`;
|
||||
} else if (type === 'chat:message') {
|
||||
message = mergeChatMessageEvent(message, data);
|
||||
} else if (type === 'replace') {
|
||||
message = mergeChatMessageEvent(message, data, true);
|
||||
} else if (type === 'chat:message:files' || type === 'files') {
|
||||
message.files = data.files;
|
||||
const files = data?.files ?? [];
|
||||
message.files = data?.replace
|
||||
? files
|
||||
: [...(message.files ?? []), ...files].filter(
|
||||
(item, index, array) =>
|
||||
array.findIndex((candidate) => equal(candidate, item)) === index
|
||||
);
|
||||
} else if (type === 'chat:message:tasks') {
|
||||
chatTasks = data.tasks;
|
||||
} else if (type === 'chat:message:embeds' || type === 'embeds') {
|
||||
message.embeds = data.embeds;
|
||||
const embeds = data?.embeds ?? [];
|
||||
message.embeds = data?.replace
|
||||
? embeds
|
||||
: [...(message.embeds ?? []), ...embeds].filter(
|
||||
(item, index, array) =>
|
||||
array.findIndex((candidate) => equal(candidate, item)) === index
|
||||
);
|
||||
|
||||
// Auto-scroll to the embed once it's rendered in the DOM
|
||||
await tick();
|
||||
|
|
@ -1050,7 +1166,7 @@
|
|||
}
|
||||
await refreshChatList(localStorage.token);
|
||||
} else if (type === 'chat:tags') {
|
||||
chat = await getChatById(localStorage.token, $chatId);
|
||||
chat = await getChatByIdWindow(localStorage.token, $chatId, DEFAULT_CHAT_MESSAGE_WINDOW);
|
||||
allTags.set(await getAllTags(localStorage.token));
|
||||
} else if (type === 'source' || type === 'citation') {
|
||||
if (data?.type === 'code_execution') {
|
||||
|
|
@ -1940,9 +2056,11 @@
|
|||
};
|
||||
|
||||
const loadChat = async () => {
|
||||
const requestGeneration = ++loadChatGeneration;
|
||||
const requestChatId = chatIdProp || $chatId;
|
||||
noteChatDebug('loadChat start');
|
||||
// chatIdProp is empty for chats started from the home page (URL set via replaceState)
|
||||
chatId.set(chatIdProp || $chatId);
|
||||
chatId.set(requestChatId);
|
||||
noteChatDebug('loadChat set active chat id');
|
||||
|
||||
if ($temporaryChatEnabled) {
|
||||
|
|
@ -1950,18 +2068,26 @@
|
|||
temporaryChatEnabled.set(false);
|
||||
}
|
||||
|
||||
chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
|
||||
console.error('[note-chat] getChatById failed', {
|
||||
const loadedChat = await getChatByIdWindow(
|
||||
localStorage.token,
|
||||
requestChatId,
|
||||
DEFAULT_CHAT_MESSAGE_WINDOW
|
||||
).catch(async (error) => {
|
||||
console.error('[note-chat] getChatByIdWindow failed', {
|
||||
chatIdProp,
|
||||
activeChatId: $chatId,
|
||||
error
|
||||
});
|
||||
if (!embedded) {
|
||||
if (!embedded && requestGeneration === loadChatGeneration) {
|
||||
await goto('/');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
noteChatDebug('getChatById completed', {
|
||||
if (chatDestroyed || requestGeneration !== loadChatGeneration || $chatId !== requestChatId) {
|
||||
return false;
|
||||
}
|
||||
chat = loadedChat;
|
||||
noteChatDebug('getChatByIdWindow completed', {
|
||||
found: !!chat,
|
||||
chatId: chat?.id,
|
||||
hasChatPayload: !!chat?.chat,
|
||||
|
|
@ -1969,7 +2095,7 @@
|
|||
});
|
||||
|
||||
if (chat) {
|
||||
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
|
||||
tags = await getTagsById(localStorage.token, requestChatId).catch(async (error) => {
|
||||
console.warn('[note-chat] getTagsById failed; continuing without tags', {
|
||||
chatIdProp,
|
||||
activeChatId: $chatId,
|
||||
|
|
@ -1977,6 +2103,9 @@
|
|||
});
|
||||
return [];
|
||||
});
|
||||
if (chatDestroyed || requestGeneration !== loadChatGeneration || $chatId !== requestChatId) {
|
||||
return false;
|
||||
}
|
||||
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
|
||||
|
||||
const chatContent = chat.chat;
|
||||
|
|
@ -2011,9 +2140,7 @@
|
|||
(chatContent?.history ?? undefined) !== undefined
|
||||
? chatContent.history
|
||||
: convertMessagesToHistory(chatContent.messages);
|
||||
if (chat?.current_message_id && history?.messages?.[chat.current_message_id]) {
|
||||
history.currentId = chat.current_message_id;
|
||||
}
|
||||
history.currentId = resolveLoadedCurrentMessageId(history, chat?.current_message_id);
|
||||
|
||||
// Sanitize history: repair orphaned references and structurally-malformed
|
||||
// nodes from failed regenerations (#24424, #24157, #20474)
|
||||
|
|
@ -2037,6 +2164,7 @@
|
|||
for (const message of Object.values(history.messages)) {
|
||||
if (
|
||||
message &&
|
||||
isChatMessageLoaded(history, message.id) &&
|
||||
message.role === 'assistant' &&
|
||||
message.id !== history.currentId &&
|
||||
message.done !== false
|
||||
|
|
@ -2051,7 +2179,7 @@
|
|||
// work (follow-ups, title gen) that shouldn't block the input.
|
||||
const activeTaskIds = taskIds;
|
||||
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
|
||||
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId)
|
||||
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, requestChatId)
|
||||
.then((res) => res?.task_ids ?? [])
|
||||
.catch((error) => {
|
||||
console.warn('[note-chat] getTaskIdsByChatId failed; continuing without tasks', {
|
||||
|
|
@ -2061,6 +2189,13 @@
|
|||
});
|
||||
return [];
|
||||
});
|
||||
if (
|
||||
chatDestroyed ||
|
||||
requestGeneration !== loadChatGeneration ||
|
||||
$chatId !== requestChatId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
noteChatDebug('task reconciliation completed', {
|
||||
pendingTaskCount: pendingTaskIds.length,
|
||||
hasCurrentMessage: !!currentMessage
|
||||
|
|
@ -2093,7 +2228,7 @@
|
|||
return null;
|
||||
}
|
||||
}
|
||||
console.warn('[note-chat] no chat returned from getChatById', {
|
||||
console.warn('[note-chat] no chat returned from getChatByIdWindow', {
|
||||
chatIdProp,
|
||||
activeChatId: $chatId
|
||||
});
|
||||
|
|
@ -2223,7 +2358,7 @@
|
|||
|
||||
if ($chatId == _chatId) {
|
||||
if (!$temporaryChatEnabled) {
|
||||
chat = await updateChatById(localStorage.token, _chatId, {
|
||||
chat = await updateChatWindow(_chatId, {
|
||||
models: selectedModels,
|
||||
messages: messages,
|
||||
history: history,
|
||||
|
|
@ -2772,14 +2907,14 @@
|
|||
|
||||
const sendMessage = async (
|
||||
_history,
|
||||
parentId: string,
|
||||
parentId: string | null,
|
||||
{
|
||||
messages = null,
|
||||
messagesFromId = null,
|
||||
modelId = null,
|
||||
modelIdx = null,
|
||||
regenerationPrompt = null
|
||||
}: {
|
||||
messages?: any[] | null;
|
||||
messagesFromId?: string | null;
|
||||
modelId?: string | null;
|
||||
modelIdx?: number | null;
|
||||
regenerationPrompt?: string | null;
|
||||
|
|
@ -2790,7 +2925,9 @@
|
|||
}
|
||||
|
||||
let _chatId = JSON.parse(JSON.stringify($chatId));
|
||||
_history = structuredClone(_history);
|
||||
_history = structuredClone(history);
|
||||
const messages =
|
||||
$temporaryChatEnabled && messagesFromId ? createMessagesList(_history, messagesFromId) : null;
|
||||
|
||||
const responseMessageIds: Record<PropertyKey, string> = {};
|
||||
// If modelId is provided, use it, else use selected model
|
||||
|
|
@ -2925,7 +3062,8 @@
|
|||
// regenerations in a duplicate-model chat, which would otherwise lose their
|
||||
// column identity and collapse on reload.
|
||||
messageIdsList: messageIdsList.length > 0 ? messageIdsList : undefined,
|
||||
regenerationPrompt
|
||||
regenerationPrompt,
|
||||
regenerationSourceMessageId: regenerationPrompt ? messagesFromId : null
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
|
|
@ -2980,25 +3118,28 @@
|
|||
{
|
||||
messageIdsList,
|
||||
regenerationPrompt,
|
||||
regenerationSourceMessageId,
|
||||
continueResponse = false
|
||||
}: {
|
||||
messageIdsList?: Array<{ model_id: string; message_id: string }>;
|
||||
regenerationPrompt?: string | null;
|
||||
regenerationSourceMessageId?: string | null;
|
||||
continueResponse?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const responseMessage = _history.messages[responseMessageId];
|
||||
const userMessage = _history.messages[responseMessage.parentId];
|
||||
|
||||
const chatMessageFiles = _messages
|
||||
.filter((message) => message.files)
|
||||
.flatMap((message) => message.files);
|
||||
if ($temporaryChatEnabled) {
|
||||
const chatMessageFiles = _messages
|
||||
.filter((message) => message.files)
|
||||
.flatMap((message) => message.files);
|
||||
|
||||
// Filter chatFiles to only include files that are in the chatMessageFiles
|
||||
chatFiles = chatFiles.filter((item) => {
|
||||
const fileExists = chatMessageFiles.some((messageFile) => messageFile.id === item.id);
|
||||
return fileExists;
|
||||
});
|
||||
// Temporary chats have no server-side branch to recover attachment state from.
|
||||
chatFiles = chatFiles.filter((item) =>
|
||||
chatMessageFiles.some((messageFile) => messageFile.id === item.id)
|
||||
);
|
||||
}
|
||||
|
||||
let files = structuredClone(chatFiles);
|
||||
files.push(
|
||||
|
|
@ -3167,6 +3308,9 @@
|
|||
parent_id: userMessage?.parentId ?? null,
|
||||
user_message: userMessage,
|
||||
...(regenerationPrompt ? { regeneration_prompt: regenerationPrompt } : {}),
|
||||
...(regenerationSourceMessageId
|
||||
? { regeneration_source_message_id: regenerationSourceMessageId }
|
||||
: {}),
|
||||
...(continueResponse ? { assistant_message_id: responseMessageId } : {}),
|
||||
|
||||
background_tasks: {
|
||||
|
|
@ -3244,7 +3388,7 @@
|
|||
// chat completion request. Files are now persisted
|
||||
// by the backend at chat creation time.
|
||||
if (Object.keys(params).length > 0) {
|
||||
await updateChatById(localStorage.token, res.chat_id, {
|
||||
await updateChatWindow(res.chat_id, {
|
||||
params: params
|
||||
});
|
||||
}
|
||||
|
|
@ -3396,7 +3540,7 @@
|
|||
await sendMessage(history, userMessage.id, {
|
||||
...(suggestionPrompt
|
||||
? {
|
||||
messages: createMessagesList(history, message.id),
|
||||
messagesFromId: message.id,
|
||||
regenerationPrompt: suggestionPrompt
|
||||
}
|
||||
: {}),
|
||||
|
|
@ -3541,7 +3685,7 @@
|
|||
const saveChatHandler = async (_chatId, history) => {
|
||||
if ($chatId == _chatId) {
|
||||
if (!$temporaryChatEnabled) {
|
||||
chat = await updateChatById(localStorage.token, _chatId, {
|
||||
chat = await updateChatWindow(_chatId, {
|
||||
models: selectedModels,
|
||||
history: history,
|
||||
messages: createMessagesList(history, history.currentId),
|
||||
|
|
@ -3557,7 +3701,7 @@
|
|||
const loaded = chat?.chat ?? {};
|
||||
if (equal(params, loaded.params ?? {}) && equal(chatFiles, loaded.files ?? [])) return;
|
||||
|
||||
const res = await updateChatById(localStorage.token, $chatId, {
|
||||
const res = await updateChatWindow($chatId, {
|
||||
params,
|
||||
files: chatFiles
|
||||
}).catch((err) => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,20 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { deleteChatMessageById, updateChatById } from '$lib/apis/chats';
|
||||
import {
|
||||
deleteChatMessageById,
|
||||
getChatHistoryWindow,
|
||||
updateChatByIdWindow,
|
||||
updateChatMessageById
|
||||
} from '$lib/apis/chats';
|
||||
import { copyToClipboard, extractCurlyBraceWords } from '$lib/utils';
|
||||
import {
|
||||
createMessagePatch,
|
||||
DEFAULT_CHAT_MESSAGE_WINDOW,
|
||||
isChatMessageLoaded,
|
||||
mergeChatHistoryWindow,
|
||||
serializeChatForWindowSave
|
||||
} from '$lib/utils/chatHistoryWindow';
|
||||
|
||||
import Message from './Messages/Message.svelte';
|
||||
import Loader from '../common/Loader.svelte';
|
||||
|
|
@ -15,7 +27,18 @@
|
|||
|
||||
import ChatPlaceholder from './ChatPlaceholder.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const i18n: any = getContext('i18n');
|
||||
|
||||
type ChatHistoryState = {
|
||||
currentId: string | null;
|
||||
messages: Record<string, any>;
|
||||
messageWindow?: {
|
||||
loadedIds?: string[];
|
||||
hasMore?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export let className = 'h-full flex pt-18';
|
||||
|
||||
|
|
@ -23,11 +46,11 @@
|
|||
export let user = $_user;
|
||||
|
||||
export let prompt;
|
||||
export let history = {};
|
||||
export let history: ChatHistoryState = { messages: {}, currentId: null };
|
||||
export let selectedModels;
|
||||
export let atSelectedModel;
|
||||
|
||||
let messages = [];
|
||||
let messages: any[] = [];
|
||||
|
||||
export let setInputText: Function = () => {};
|
||||
|
||||
|
|
@ -49,45 +72,199 @@
|
|||
|
||||
export let topPadding = false;
|
||||
export let bottomPadding = false;
|
||||
export let autoScroll;
|
||||
export let autoScroll: boolean;
|
||||
export let messagesContainerId = 'messages-container';
|
||||
|
||||
export let onSelect = (e) => {};
|
||||
export let onSelect = (e: any) => {};
|
||||
export let onInsertToNote: ((content: string) => void) | null = null;
|
||||
|
||||
export let messagesCount: number | null = 8;
|
||||
let messagesLoading = false;
|
||||
let destroyed = false;
|
||||
const historyWindowRequests = new Map<string, Promise<boolean>>();
|
||||
const branchHasMoreByCurrentId = new Map<string, boolean>();
|
||||
let pendingRebuild: number | null = null;
|
||||
let lastCurrentId: string | null = null;
|
||||
let branchNavigationGeneration = 0;
|
||||
let historyGeneration = 0;
|
||||
let observedHistory = history;
|
||||
let observedChatId = chatId;
|
||||
|
||||
const getMessagesContainer = () => document.getElementById(messagesContainerId);
|
||||
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(pendingRebuild);
|
||||
});
|
||||
|
||||
const loadMoreMessages = async () => {
|
||||
// scroll slightly down to disable continuous loading
|
||||
const element = getMessagesContainer();
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollTop + 100;
|
||||
}
|
||||
|
||||
messagesLoading = true;
|
||||
messagesCount += 8;
|
||||
|
||||
buildMessages();
|
||||
|
||||
await tick();
|
||||
|
||||
const resetLazyPaginationState = () => {
|
||||
messagesCount = 8;
|
||||
messagesLoading = false;
|
||||
branchNavigationGeneration += 1;
|
||||
historyGeneration += 1;
|
||||
historyWindowRequests.clear();
|
||||
branchHasMoreByCurrentId.clear();
|
||||
lastCurrentId = null;
|
||||
if (pendingRebuild !== null) cancelAnimationFrame(pendingRebuild);
|
||||
pendingRebuild = null;
|
||||
};
|
||||
|
||||
let pendingRebuild = null;
|
||||
let lastCurrentId = null;
|
||||
const setMergedHistory = (nextHistory: ChatHistoryState) => {
|
||||
observedHistory = nextHistory;
|
||||
history = nextHistory;
|
||||
};
|
||||
|
||||
const replaceHistory = (nextHistory: ChatHistoryState) => {
|
||||
resetLazyPaginationState();
|
||||
observedHistory = nextHistory;
|
||||
observedChatId = chatId;
|
||||
history = nextHistory;
|
||||
};
|
||||
|
||||
const observeHistoryReplacement = (nextHistory: ChatHistoryState, nextChatId: string) => {
|
||||
if (nextHistory === observedHistory && nextChatId === observedChatId) return;
|
||||
|
||||
resetLazyPaginationState();
|
||||
observedHistory = nextHistory;
|
||||
observedChatId = nextChatId;
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
branchNavigationGeneration += 1;
|
||||
historyGeneration += 1;
|
||||
historyWindowRequests.clear();
|
||||
branchHasMoreByCurrentId.clear();
|
||||
if (pendingRebuild !== null) cancelAnimationFrame(pendingRebuild);
|
||||
});
|
||||
|
||||
$: observeHistoryReplacement(history, chatId);
|
||||
|
||||
const getBranchLoadState = (currentId = history?.currentId) => {
|
||||
let loadedCount = 0;
|
||||
let earliestLoadedId: string | null = null;
|
||||
let blockedId: string | null = null;
|
||||
let reachedRoot = false;
|
||||
let messageId = currentId;
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (messageId) {
|
||||
if (visited.has(messageId)) {
|
||||
reachedRoot = true;
|
||||
break;
|
||||
}
|
||||
visited.add(messageId);
|
||||
|
||||
const message = history?.messages?.[messageId];
|
||||
if (!message || !isChatMessageLoaded(history, messageId)) {
|
||||
blockedId = messageId;
|
||||
break;
|
||||
}
|
||||
|
||||
loadedCount += 1;
|
||||
earliestLoadedId = messageId;
|
||||
if (message.parentId === null || message.parentId === undefined) {
|
||||
reachedRoot = true;
|
||||
break;
|
||||
}
|
||||
messageId = message.parentId;
|
||||
}
|
||||
|
||||
return { loadedCount, earliestLoadedId, blockedId, reachedRoot };
|
||||
};
|
||||
|
||||
const hydrateHistoryWindow = async (currentId: string, beforeId?: string) => {
|
||||
if (
|
||||
!currentId ||
|
||||
!history?.messageWindow ||
|
||||
$temporaryChatEnabled ||
|
||||
!chatId ||
|
||||
chatId.startsWith('local:')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestChatId = chatId;
|
||||
const requestGeneration = historyGeneration;
|
||||
const requestKey = `${requestGeneration}:${requestChatId}:${currentId}:${beforeId ?? ''}`;
|
||||
const pendingRequest = historyWindowRequests.get(requestKey);
|
||||
if (pendingRequest) return await pendingRequest;
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const historyWindow = await getChatHistoryWindow(
|
||||
localStorage.token,
|
||||
requestChatId,
|
||||
currentId,
|
||||
DEFAULT_CHAT_MESSAGE_WINDOW,
|
||||
beforeId
|
||||
);
|
||||
if (
|
||||
!historyWindow ||
|
||||
destroyed ||
|
||||
chatId !== requestChatId ||
|
||||
historyGeneration !== requestGeneration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeCurrentId = history.currentId;
|
||||
branchHasMoreByCurrentId.set(currentId, historyWindow.hasMore);
|
||||
const mergedHistory = mergeChatHistoryWindow(history, historyWindow) as ChatHistoryState;
|
||||
mergedHistory.currentId = activeCurrentId;
|
||||
if (currentId !== activeCurrentId && mergedHistory.messageWindow) {
|
||||
mergedHistory.messageWindow.hasMore = history.messageWindow?.hasMore;
|
||||
}
|
||||
setMergedHistory(mergedHistory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load chat history window', error);
|
||||
toast.error($i18n.t('Failed to load chat'));
|
||||
return false;
|
||||
} finally {
|
||||
historyWindowRequests.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
historyWindowRequests.set(requestKey, request);
|
||||
return await request;
|
||||
};
|
||||
|
||||
const ensureLoadedAncestors = async (targetCount: number) => {
|
||||
while (true) {
|
||||
const state = getBranchLoadState();
|
||||
if (state.loadedCount >= targetCount || state.reachedRoot) return true;
|
||||
if (!state.earliestLoadedId || history?.messageWindow?.hasMore === false) return false;
|
||||
|
||||
const currentId = history.currentId;
|
||||
if (!currentId) return false;
|
||||
const loaded = await hydrateHistoryWindow(currentId, state.earliestLoadedId);
|
||||
if (!loaded) return false;
|
||||
|
||||
const nextState = getBranchLoadState();
|
||||
if (nextState.loadedCount <= state.loadedCount) return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreMessages = async () => {
|
||||
if (messagesLoading || messagesCount === null) return;
|
||||
|
||||
// scroll slightly down to disable continuous loading
|
||||
const element = getMessagesContainer();
|
||||
if (element) element.scrollTop += 100;
|
||||
|
||||
messagesLoading = true;
|
||||
try {
|
||||
const nextCount = messagesCount + 8;
|
||||
if (!(await ensureLoadedAncestors(nextCount))) return;
|
||||
|
||||
messagesCount = nextCount;
|
||||
buildMessages();
|
||||
await tick();
|
||||
} finally {
|
||||
messagesLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildMessages = () => {
|
||||
let _messages = [];
|
||||
|
||||
let message = history.messages[history.currentId];
|
||||
let message = history.currentId ? history.messages[history.currentId] : null;
|
||||
const visitedMessageIds = new Set();
|
||||
|
||||
while (message && (messagesCount !== null ? _messages.length < messagesCount : true)) {
|
||||
|
|
@ -97,6 +274,7 @@
|
|||
}
|
||||
visitedMessageIds.add(message.id);
|
||||
|
||||
if (!isChatMessageLoaded(history, message.id)) break;
|
||||
_messages.push(message);
|
||||
message = message.parentId !== null ? history.messages[message.parentId] : null;
|
||||
}
|
||||
|
|
@ -106,7 +284,7 @@
|
|||
|
||||
// Throttle message list rebuilds to once per animation frame during streaming.
|
||||
// Structural changes (currentId change) always rebuild immediately.
|
||||
const handleHistoryChange = (currentId, _messages) => {
|
||||
const handleHistoryChange = (currentId: string | null, _messages: Record<string, any>) => {
|
||||
if (!currentId) {
|
||||
messages = [];
|
||||
return;
|
||||
|
|
@ -117,7 +295,7 @@
|
|||
|
||||
if (currentIdChanged) {
|
||||
// Structural change: new chat, navigation, new message — rebuild immediately
|
||||
cancelAnimationFrame(pendingRebuild);
|
||||
if (pendingRebuild !== null) cancelAnimationFrame(pendingRebuild);
|
||||
pendingRebuild = null;
|
||||
buildMessages();
|
||||
} else if (_messages) {
|
||||
|
|
@ -155,14 +333,35 @@
|
|||
};
|
||||
|
||||
export const scrollToTop = async () => {
|
||||
messagesCount = null;
|
||||
buildMessages();
|
||||
await tick();
|
||||
if (messages.length > 0) {
|
||||
const firstMessageEl = document.getElementById(`message-${messages[0].id}`);
|
||||
if (firstMessageEl) {
|
||||
firstMessageEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (messagesLoading) return;
|
||||
|
||||
messagesLoading = true;
|
||||
try {
|
||||
while (true) {
|
||||
const state = getBranchLoadState();
|
||||
if (state.reachedRoot) break;
|
||||
if (!state.earliestLoadedId || history?.messageWindow?.hasMore === false) return;
|
||||
|
||||
const currentId = history.currentId;
|
||||
if (!currentId) return;
|
||||
const loaded = await hydrateHistoryWindow(currentId, state.earliestLoadedId);
|
||||
if (!loaded) return;
|
||||
|
||||
const nextState = getBranchLoadState();
|
||||
if (nextState.loadedCount <= state.loadedCount) return;
|
||||
}
|
||||
|
||||
messagesCount = null;
|
||||
buildMessages();
|
||||
await tick();
|
||||
if (messages.length > 0) {
|
||||
const firstMessageEl = document.getElementById(`message-${messages[0].id}`);
|
||||
if (firstMessageEl) {
|
||||
firstMessageEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
messagesLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -170,181 +369,113 @@
|
|||
if (!$temporaryChatEnabled) {
|
||||
history = history;
|
||||
await tick();
|
||||
const res = await updateChatById(localStorage.token, chatId, {
|
||||
const payload = serializeChatForWindowSave({
|
||||
history: history,
|
||||
messages: messages
|
||||
});
|
||||
|
||||
// Keep local plain-content edits aligned with the saved chat response.
|
||||
if (res?.chat?.history?.messages) {
|
||||
for (const [id, msg] of Object.entries(res.chat.history.messages)) {
|
||||
if (history.messages[id] && (msg as any).content) {
|
||||
history.messages[id].content = (msg as any).content;
|
||||
}
|
||||
}
|
||||
history = history;
|
||||
}
|
||||
await updateChatByIdWindow(localStorage.token, chatId, payload);
|
||||
|
||||
await refreshChatList(localStorage.token);
|
||||
}
|
||||
};
|
||||
|
||||
const gotoMessage = async (message, idx) => {
|
||||
// Determine the correct sibling list (either parent's children or root messages)
|
||||
let siblings;
|
||||
if (message.parentId !== null) {
|
||||
siblings = history.messages[message.parentId].childrenIds;
|
||||
} else {
|
||||
siblings = Object.values(history.messages)
|
||||
.filter((msg) => msg.parentId === null)
|
||||
.map((msg) => msg.id);
|
||||
const getSiblingIds = (message: any): string[] =>
|
||||
message.parentId !== null
|
||||
? (history.messages[message.parentId]?.childrenIds ?? [])
|
||||
: Object.values(history.messages)
|
||||
.filter((candidate) => candidate.parentId === null)
|
||||
.map((candidate) => candidate.id);
|
||||
|
||||
const getDeepestLeafId = (messageId: string) => {
|
||||
const visited = new Set<string>();
|
||||
let leafId = messageId;
|
||||
|
||||
while (leafId && !visited.has(leafId)) {
|
||||
visited.add(leafId);
|
||||
const childrenIds = history.messages[leafId]?.childrenIds ?? [];
|
||||
if (childrenIds.length === 0) break;
|
||||
leafId = childrenIds.at(-1);
|
||||
}
|
||||
|
||||
// Clamp index to a valid range
|
||||
idx = Math.max(0, Math.min(idx, siblings.length - 1));
|
||||
|
||||
let messageId = siblings[idx];
|
||||
|
||||
// If we're navigating to a different message
|
||||
if (message.id !== messageId) {
|
||||
// Drill down to the deepest child of that branch
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
// Optional auto-scroll
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const element = getMessagesContainer();
|
||||
autoScroll = element
|
||||
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
|
||||
: false;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
return leafId;
|
||||
};
|
||||
|
||||
const showPreviousMessage = async (message) => {
|
||||
if (message.parentId !== null) {
|
||||
let messageId =
|
||||
history.messages[message.parentId].childrenIds[
|
||||
Math.max(history.messages[message.parentId].childrenIds.indexOf(message.id) - 1, 0)
|
||||
];
|
||||
|
||||
if (message.id !== messageId) {
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
}
|
||||
} else {
|
||||
let childrenIds = Object.values(history.messages)
|
||||
.filter((message) => message.parentId === null)
|
||||
.map((message) => message.id);
|
||||
let messageId = childrenIds[Math.max(childrenIds.indexOf(message.id) - 1, 0)];
|
||||
|
||||
if (message.id !== messageId) {
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const element = getMessagesContainer();
|
||||
autoScroll = element
|
||||
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
|
||||
: false;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
const ensureMessageLoaded = async (messageId: string) => {
|
||||
if (!messageId || isChatMessageLoaded(history, messageId)) return true;
|
||||
return await hydrateHistoryWindow(messageId);
|
||||
};
|
||||
|
||||
const showNextMessage = async (message) => {
|
||||
if (message.parentId !== null) {
|
||||
let messageId =
|
||||
history.messages[message.parentId].childrenIds[
|
||||
Math.min(
|
||||
history.messages[message.parentId].childrenIds.indexOf(message.id) + 1,
|
||||
history.messages[message.parentId].childrenIds.length - 1
|
||||
)
|
||||
];
|
||||
|
||||
if (message.id !== messageId) {
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
}
|
||||
} else {
|
||||
let childrenIds = Object.values(history.messages)
|
||||
.filter((message) => message.parentId === null)
|
||||
.map((message) => message.id);
|
||||
let messageId =
|
||||
childrenIds[Math.min(childrenIds.indexOf(message.id) + 1, childrenIds.length - 1)];
|
||||
|
||||
if (message.id !== messageId) {
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollAfterBranchChange = async () => {
|
||||
await tick();
|
||||
if (!($settings?.scrollOnBranchChange ?? true)) return;
|
||||
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const element = getMessagesContainer();
|
||||
autoScroll = element
|
||||
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
|
||||
: false;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
const element = getMessagesContainer();
|
||||
if (element) {
|
||||
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const switchToBranchLeaf = async (messageId: string) => {
|
||||
if (!messageId) return false;
|
||||
const navigationGeneration = ++branchNavigationGeneration;
|
||||
if (messageId === history.currentId) return true;
|
||||
if (!(await ensureMessageLoaded(messageId))) return false;
|
||||
if (navigationGeneration !== branchNavigationGeneration) return false;
|
||||
|
||||
messagesCount = 8;
|
||||
history.currentId = messageId;
|
||||
const targetHasMore = branchHasMoreByCurrentId.get(messageId);
|
||||
if (typeof targetHasMore === 'boolean' && history.messageWindow) {
|
||||
history.messageWindow.hasMore = targetHasMore;
|
||||
}
|
||||
history = history;
|
||||
await scrollAfterBranchChange();
|
||||
return true;
|
||||
};
|
||||
|
||||
const gotoMessage = async (message: any, idx: number) => {
|
||||
const siblings = getSiblingIds(message);
|
||||
if (siblings.length === 0) return;
|
||||
|
||||
const siblingIndex = Math.max(0, Math.min(idx, siblings.length - 1));
|
||||
const leafId = getDeepestLeafId(siblings[siblingIndex]);
|
||||
await switchToBranchLeaf(leafId);
|
||||
};
|
||||
|
||||
const showPreviousMessage = async (message: any) => {
|
||||
const siblings = getSiblingIds(message);
|
||||
if (siblings.length === 0) return;
|
||||
|
||||
const siblingIndex = Math.max(siblings.indexOf(message.id) - 1, 0);
|
||||
const leafId = getDeepestLeafId(siblings[siblingIndex]);
|
||||
await switchToBranchLeaf(leafId);
|
||||
};
|
||||
|
||||
const showNextMessage = async (message: any) => {
|
||||
const siblings = getSiblingIds(message);
|
||||
if (siblings.length === 0) return;
|
||||
|
||||
const siblingIndex = Math.min(siblings.indexOf(message.id) + 1, siblings.length - 1);
|
||||
const leafId = getDeepestLeafId(siblings[siblingIndex]);
|
||||
await switchToBranchLeaf(leafId);
|
||||
};
|
||||
|
||||
const rateMessage = async (messageId, rating) => {
|
||||
history.messages[messageId].annotation = {
|
||||
...history.messages[messageId].annotation,
|
||||
rating: rating
|
||||
};
|
||||
|
||||
await updateChat();
|
||||
await saveMessage(messageId, {
|
||||
...history.messages[messageId],
|
||||
annotation: {
|
||||
...history.messages[messageId].annotation,
|
||||
rating: rating
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const editMessage = async (messageId, { content, files, output = undefined }, submit = true) => {
|
||||
if ((selectedModels ?? []).filter((id) => id).length === 0) {
|
||||
if (submit && (selectedModels ?? []).filter((id) => id).length === 0) {
|
||||
toast.error($i18n.t('Model not selected'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -381,9 +512,11 @@
|
|||
await sendMessage(history, userMessageId);
|
||||
} else {
|
||||
// Edit user message
|
||||
history.messages[messageId].content = content;
|
||||
history.messages[messageId].files = files;
|
||||
await updateChat();
|
||||
await saveMessage(messageId, {
|
||||
...history.messages[messageId],
|
||||
content,
|
||||
files
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (submit) {
|
||||
|
|
@ -417,15 +550,16 @@
|
|||
await updateChat();
|
||||
} else {
|
||||
// Edit response message
|
||||
const updatedMessage = { ...history.messages[messageId] };
|
||||
if (content !== undefined) {
|
||||
history.messages[messageId].originalContent = history.messages[messageId].content;
|
||||
history.messages[messageId].content = content;
|
||||
updatedMessage.originalContent = history.messages[messageId].content;
|
||||
updatedMessage.content = content;
|
||||
}
|
||||
if (output !== undefined) {
|
||||
history.messages[messageId].output = output;
|
||||
history.messages[messageId].content = '';
|
||||
updatedMessage.output = output;
|
||||
updatedMessage.content = '';
|
||||
}
|
||||
await updateChat();
|
||||
await saveMessage(messageId, updatedMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -436,15 +570,78 @@
|
|||
|
||||
const saveMessage = async (messageId, message) => {
|
||||
if (!history.messages?.[messageId]) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
history.messages[messageId] = message;
|
||||
await updateChat();
|
||||
const previousMessage = structuredClone(history.messages[messageId]);
|
||||
const messagePatch = createMessagePatch(previousMessage, message);
|
||||
history.messages[messageId] = { ...structuredClone(message), __loaded: true };
|
||||
history = history;
|
||||
await tick();
|
||||
|
||||
if ($temporaryChatEnabled) {
|
||||
return history.messages[messageId];
|
||||
}
|
||||
if (Object.keys(messagePatch).length === 0) {
|
||||
return history.messages[messageId];
|
||||
}
|
||||
|
||||
try {
|
||||
const savedMessage = await updateChatMessageById(
|
||||
localStorage.token,
|
||||
chatId,
|
||||
messageId,
|
||||
messagePatch
|
||||
);
|
||||
|
||||
if (savedMessage) {
|
||||
history.messages[messageId] = {
|
||||
...history.messages[messageId],
|
||||
...savedMessage,
|
||||
__loaded: true
|
||||
};
|
||||
history = history;
|
||||
await tick();
|
||||
}
|
||||
|
||||
void refreshChatList(localStorage.token);
|
||||
return savedMessage;
|
||||
} catch (error) {
|
||||
history.messages[messageId] = previousMessage;
|
||||
history = history;
|
||||
await tick();
|
||||
toast.error(`${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMessage = async (messageId) => {
|
||||
const messageToDelete = history.messages[messageId];
|
||||
if (!messageToDelete) return;
|
||||
|
||||
if (!$temporaryChatEnabled) {
|
||||
try {
|
||||
const response = await deleteChatMessageById(localStorage.token, chatId, messageId);
|
||||
let nextHistory = response?.chat?.history as ChatHistoryState | undefined;
|
||||
|
||||
if (nextHistory?.currentId && !isChatMessageLoaded(nextHistory, nextHistory.currentId)) {
|
||||
const historyWindow = await getChatHistoryWindow(
|
||||
localStorage.token,
|
||||
chatId,
|
||||
nextHistory.currentId,
|
||||
DEFAULT_CHAT_MESSAGE_WINDOW
|
||||
);
|
||||
nextHistory = mergeChatHistoryWindow(nextHistory, historyWindow) as ChatHistoryState;
|
||||
}
|
||||
|
||||
if (nextHistory) replaceHistory(nextHistory);
|
||||
void refreshChatList(localStorage.token);
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parentMessageId = messageToDelete.parentId;
|
||||
const childMessageIds = messageToDelete.childrenIds ?? [];
|
||||
|
||||
|
|
@ -484,15 +681,6 @@
|
|||
}
|
||||
history.currentId = nextMessageId;
|
||||
history = history;
|
||||
|
||||
if (!$temporaryChatEnabled) {
|
||||
const res = await deleteChatMessageById(localStorage.token, chatId, messageId);
|
||||
if (res?.chat?.history) {
|
||||
history = res.chat.history;
|
||||
}
|
||||
|
||||
await refreshChatList(localStorage.token);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerScroll = () => {
|
||||
|
|
@ -544,6 +732,8 @@
|
|||
{gotoMessage}
|
||||
{showPreviousMessage}
|
||||
{showNextMessage}
|
||||
navigateToMessageId={switchToBranchLeaf}
|
||||
{ensureMessageLoaded}
|
||||
{updateChat}
|
||||
{editMessage}
|
||||
{deleteMessage}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
export let gotoMessage;
|
||||
export let showPreviousMessage;
|
||||
export let showNextMessage;
|
||||
export let navigateToMessageId;
|
||||
export let ensureMessageLoaded;
|
||||
export let updateChat;
|
||||
|
||||
export let editMessage;
|
||||
|
|
@ -120,6 +122,8 @@
|
|||
{chatId}
|
||||
{messageId}
|
||||
{selectedModels}
|
||||
{navigateToMessageId}
|
||||
{ensureMessageLoaded}
|
||||
isLastMessage={messageId === history?.currentId}
|
||||
{setInputText}
|
||||
{updateChat}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick, getContext } from 'svelte';
|
||||
import { onDestroy, onMount, tick, getContext } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { mobile, models, settings } from '$lib/stores';
|
||||
|
||||
import { generateMoACompletion } from '$lib/apis';
|
||||
import { updateChatById } from '$lib/apis/chats';
|
||||
import { createOpenAITextStream } from '$lib/apis/streaming';
|
||||
import { groupMessageIdsByModel, isChatMessageLoaded } from '$lib/utils/chatHistoryWindow';
|
||||
|
||||
import ResponseMessage from './ResponseMessage.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
export let history;
|
||||
export let messageId;
|
||||
export let selectedModels = [];
|
||||
export let navigateToMessageId: (messageId: string) => Promise<boolean>;
|
||||
export let ensureMessageLoaded: (messageId: string) => Promise<boolean>;
|
||||
|
||||
export let isLastMessage;
|
||||
export let readOnly = false;
|
||||
|
|
@ -62,6 +64,10 @@
|
|||
let groupedMessageIdsIdx = {};
|
||||
|
||||
let selectedModelIdx = null;
|
||||
let destroyed = false;
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
|
||||
let message = structuredClone(history.messages[messageId]);
|
||||
$: if (history.messages) {
|
||||
|
|
@ -75,77 +81,42 @@
|
|||
}
|
||||
}
|
||||
|
||||
const gotoMessage = async (modelIdx, messageIdx) => {
|
||||
// Clamp messageIdx to ensure it's within valid range
|
||||
groupedMessageIdsIdx[modelIdx] = Math.max(
|
||||
0,
|
||||
Math.min(messageIdx, groupedMessageIds[modelIdx].messageIds.length - 1)
|
||||
);
|
||||
const getDeepestLeafId = (rootMessageId: string) => {
|
||||
let leafId = rootMessageId;
|
||||
const visited = new Set<string>();
|
||||
|
||||
// Get the messageId at the specified index
|
||||
let messageId = groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]];
|
||||
console.log(messageId);
|
||||
|
||||
// Traverse the branch to find the deepest child message
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
while (leafId && !visited.has(leafId)) {
|
||||
visited.add(leafId);
|
||||
const childrenIds = history.messages[leafId]?.childrenIds ?? [];
|
||||
if (childrenIds.length === 0) break;
|
||||
leafId = childrenIds.at(-1);
|
||||
}
|
||||
|
||||
// Update the current message ID in history
|
||||
history.currentId = messageId;
|
||||
|
||||
// Await UI updates
|
||||
await tick();
|
||||
await updateChat();
|
||||
|
||||
// Trigger scrolling after navigation
|
||||
triggerScroll();
|
||||
return leafId;
|
||||
};
|
||||
|
||||
const showPreviousMessage = async (modelIdx) => {
|
||||
groupedMessageIdsIdx[modelIdx] = Math.max(0, groupedMessageIdsIdx[modelIdx] - 1);
|
||||
const navigateToGroupedMessage = async (modelIdx, messageIdx) => {
|
||||
const messageIds = groupedMessageIds[modelIdx]?.messageIds ?? [];
|
||||
if (messageIds.length === 0) return false;
|
||||
|
||||
let messageId = groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]];
|
||||
console.log(messageId);
|
||||
const nextMessageIdx = Math.max(0, Math.min(messageIdx, messageIds.length - 1));
|
||||
const leafId = getDeepestLeafId(messageIds[nextMessageIdx]);
|
||||
if (!(await navigateToMessageId(leafId))) return false;
|
||||
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
|
||||
await tick();
|
||||
await updateChat();
|
||||
groupedMessageIdsIdx[modelIdx] = nextMessageIdx;
|
||||
selectedModelIdx = Number(modelIdx);
|
||||
triggerScroll();
|
||||
return true;
|
||||
};
|
||||
|
||||
const showNextMessage = async (modelIdx) => {
|
||||
groupedMessageIdsIdx[modelIdx] = Math.min(
|
||||
groupedMessageIds[modelIdx].messageIds.length - 1,
|
||||
groupedMessageIdsIdx[modelIdx] + 1
|
||||
);
|
||||
const gotoMessage = async (modelIdx, messageIdx) =>
|
||||
await navigateToGroupedMessage(modelIdx, messageIdx);
|
||||
|
||||
let messageId = groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]];
|
||||
console.log(messageId);
|
||||
const showPreviousMessage = async (modelIdx) =>
|
||||
await navigateToGroupedMessage(modelIdx, groupedMessageIdsIdx[modelIdx] - 1);
|
||||
|
||||
let messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
messageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[messageId].childrenIds;
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
|
||||
await tick();
|
||||
await updateChat();
|
||||
triggerScroll();
|
||||
};
|
||||
const showNextMessage = async (modelIdx) =>
|
||||
await navigateToGroupedMessage(modelIdx, groupedMessageIdsIdx[modelIdx] + 1);
|
||||
|
||||
const initHandler = async () => {
|
||||
console.log('multiresponse:initHandler');
|
||||
|
|
@ -156,32 +127,17 @@
|
|||
? history.messages[history.messages[messageId].parentId]
|
||||
: null;
|
||||
|
||||
groupedMessageIds = parentMessage?.models.reduce((a, model, modelIdx) => {
|
||||
// Find all messages that are children of the parent message and have the same model
|
||||
let modelMessageIds = parentMessage?.childrenIds
|
||||
.map((id) => history.messages[id])
|
||||
.filter((m) => m?.modelIdx === modelIdx)
|
||||
.map((m) => m.id);
|
||||
const unloadedSiblingIds = (parentMessage?.childrenIds ?? []).filter(
|
||||
(id) => !isChatMessageLoaded(history, id)
|
||||
);
|
||||
await Promise.all(unloadedSiblingIds.map((id) => ensureMessageLoaded(id)));
|
||||
if (destroyed) return;
|
||||
await tick();
|
||||
|
||||
// Legacy support for messages that don't have a modelIdx
|
||||
// Find all messages that are children of the parent message and have the same model
|
||||
if (modelMessageIds.length === 0) {
|
||||
let modelMessages = parentMessage?.childrenIds
|
||||
.map((id) => history.messages[id])
|
||||
.filter((m) => m?.model === model);
|
||||
|
||||
modelMessages.forEach((m) => {
|
||||
m.modelIdx = modelIdx;
|
||||
});
|
||||
|
||||
modelMessageIds = modelMessages.map((m) => m.id);
|
||||
}
|
||||
|
||||
return {
|
||||
...a,
|
||||
[modelIdx]: { messageIds: modelMessageIds }
|
||||
};
|
||||
}, {});
|
||||
parentMessage = history.messages[messageId].parentId
|
||||
? history.messages[history.messages[messageId].parentId]
|
||||
: null;
|
||||
groupedMessageIds = groupMessageIdsByModel(parentMessage, history.messages);
|
||||
|
||||
groupedMessageIdsIdx = parentMessage?.models.reduce((a, model, modelIdx) => {
|
||||
const idx = groupedMessageIds[modelIdx].messageIds.findIndex((id) => id === messageId);
|
||||
|
|
@ -198,7 +154,13 @@
|
|||
}
|
||||
}, {});
|
||||
|
||||
selectedModelIdx = history.messages[messageId]?.modelIdx;
|
||||
selectedModelIdx =
|
||||
history.messages[messageId]?.modelIdx ??
|
||||
Number(
|
||||
Object.keys(groupedMessageIds).find((modelIdx) =>
|
||||
groupedMessageIds[modelIdx].messageIds.includes(messageId)
|
||||
) ?? 0
|
||||
);
|
||||
|
||||
console.log(groupedMessageIds, groupedMessageIdsIdx);
|
||||
|
||||
|
|
@ -207,18 +169,8 @@
|
|||
|
||||
const onGroupClick = async (_messageId, modelIdx) => {
|
||||
if (messageId != _messageId) {
|
||||
let currentMessageId = _messageId;
|
||||
let messageChildrenIds = history.messages[currentMessageId].childrenIds;
|
||||
while (messageChildrenIds.length !== 0) {
|
||||
currentMessageId = messageChildrenIds.at(-1);
|
||||
messageChildrenIds = history.messages[currentMessageId].childrenIds;
|
||||
}
|
||||
history.currentId = currentMessageId;
|
||||
selectedModelIdx = modelIdx;
|
||||
|
||||
// await tick();
|
||||
// await updateChat();
|
||||
// triggerScroll();
|
||||
const messageIdx = groupedMessageIds[modelIdx]?.messageIds.indexOf(_messageId) ?? -1;
|
||||
if (messageIdx !== -1) await navigateToGroupedMessage(modelIdx, messageIdx);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -272,7 +224,9 @@
|
|||
{@const _messageId =
|
||||
groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]]}
|
||||
|
||||
{@const model = $models.find((m) => m.id === history.messages[_messageId]?.model)}
|
||||
{@const modelId =
|
||||
history.messages[_messageId]?.model ?? parentMessage.models?.[Number(modelIdx)]}
|
||||
{@const model = $models.find((m) => m.id === modelId)}
|
||||
|
||||
<button
|
||||
class="min-w-fit {selectedModelIdx == modelIdx
|
||||
|
|
@ -283,12 +237,12 @@
|
|||
selectedModelIdx = modelIdx;
|
||||
}
|
||||
|
||||
onGroupClick(_messageId, modelIdx);
|
||||
await onGroupClick(_messageId, modelIdx);
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="-translate-y-[1px]">
|
||||
{model ? `${model.name}` : history.messages[_messageId]?.model}
|
||||
{model?.name ?? history.messages[_messageId]?.modelName ?? modelId}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -357,7 +311,7 @@
|
|||
}`
|
||||
}`}"
|
||||
on:click={async () => {
|
||||
onGroupClick(_messageId, modelIdx);
|
||||
await onGroupClick(_messageId, modelIdx);
|
||||
}}
|
||||
>
|
||||
{#key history.currentId}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
import { createNewFeedback, getFeedbackById, updateFeedbackById } from '$lib/apis/evaluations';
|
||||
import { getChatById } from '$lib/apis/chats';
|
||||
import { getChatByIdWindow } from '$lib/apis/chats';
|
||||
import { generateTags } from '$lib/apis';
|
||||
|
||||
import {
|
||||
|
|
@ -410,11 +410,11 @@
|
|||
|
||||
const editMessageConfirmHandler = async () => {
|
||||
if (editedOutput) {
|
||||
editMessage(message.id, { output: editedOutput }, false);
|
||||
await editMessage(message.id, { output: editedOutput }, false);
|
||||
} else {
|
||||
// Legacy text edit
|
||||
const messageContent = postprocessAfterEditing(editedContent ?? '');
|
||||
editMessage(message.id, { content: messageContent }, false);
|
||||
await editMessage(message.id, { content: messageContent }, false);
|
||||
}
|
||||
|
||||
edit = false;
|
||||
|
|
@ -426,10 +426,10 @@
|
|||
|
||||
const saveAsCopyHandler = async () => {
|
||||
if (editedOutput) {
|
||||
editMessage(message.id, { output: editedOutput });
|
||||
await editMessage(message.id, { output: editedOutput });
|
||||
} else {
|
||||
const messageContent = postprocessAfterEditing(editedContent ?? '');
|
||||
editMessage(message.id, { content: messageContent });
|
||||
await editMessage(message.id, { content: messageContent });
|
||||
}
|
||||
|
||||
edit = false;
|
||||
|
|
@ -447,127 +447,130 @@
|
|||
};
|
||||
|
||||
let feedbackLoading = false;
|
||||
let feedbackGeneration = 0;
|
||||
|
||||
const feedbackHandler = async (rating: number | null = null, details: object | null = null) => {
|
||||
const requestGeneration = ++feedbackGeneration;
|
||||
feedbackLoading = true;
|
||||
console.log('Feedback', rating, details);
|
||||
|
||||
const updatedMessage = {
|
||||
...message,
|
||||
annotation: {
|
||||
...(message?.annotation ?? {}),
|
||||
...(rating !== null ? { rating: rating } : {}),
|
||||
...(details ? details : {})
|
||||
}
|
||||
};
|
||||
try {
|
||||
const updatedMessage = {
|
||||
...message,
|
||||
annotation: {
|
||||
...(message?.annotation ?? {}),
|
||||
...(rating !== null ? { rating: rating } : {}),
|
||||
...(details ? details : {})
|
||||
}
|
||||
};
|
||||
|
||||
const chat = await getChatById(localStorage.token, chatId).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
const chat = await getChatByIdWindow(localStorage.token, chatId, 32, message.id);
|
||||
if (!chat) return;
|
||||
|
||||
const messages = createMessagesList(history, message.id);
|
||||
const snapshotHistory = chat?.chat?.history ?? history;
|
||||
const messages = createMessagesList(snapshotHistory, message.id);
|
||||
const siblingIds = history.messages[message.parentId]?.childrenIds ?? [];
|
||||
|
||||
let feedbackItem = {
|
||||
type: 'rating',
|
||||
data: {
|
||||
...(updatedMessage?.annotation ? updatedMessage.annotation : {}),
|
||||
model_id: message?.selectedModelId ?? message.model,
|
||||
...(history.messages[message.parentId].childrenIds.length > 1
|
||||
? {
|
||||
sibling_model_ids: history.messages[message.parentId].childrenIds
|
||||
.filter((id) => id !== message.id)
|
||||
.map((id) => history.messages[id]?.selectedModelId ?? history.messages[id].model)
|
||||
}
|
||||
: {})
|
||||
},
|
||||
meta: {
|
||||
arena: message ? message.arena : false,
|
||||
model_id: message.model,
|
||||
message_id: message.id,
|
||||
message_index: messages.length,
|
||||
chat_id: chatId
|
||||
},
|
||||
snapshot: {
|
||||
chat: chat
|
||||
}
|
||||
};
|
||||
let feedbackItem = {
|
||||
type: 'rating',
|
||||
data: {
|
||||
...(updatedMessage?.annotation ? updatedMessage.annotation : {}),
|
||||
model_id: message?.selectedModelId ?? message.model,
|
||||
...(siblingIds.length > 1
|
||||
? {
|
||||
sibling_model_ids: siblingIds
|
||||
.filter((id) => id !== message.id)
|
||||
.map((id) => history.messages[id]?.selectedModelId ?? history.messages[id]?.model)
|
||||
.filter(Boolean)
|
||||
}
|
||||
: {})
|
||||
},
|
||||
meta: {
|
||||
arena: message ? message.arena : false,
|
||||
model_id: message.model,
|
||||
message_id: message.id,
|
||||
message_index: messages.length,
|
||||
chat_id: chatId
|
||||
},
|
||||
snapshot: {
|
||||
chat
|
||||
}
|
||||
};
|
||||
|
||||
const baseModels = [
|
||||
feedbackItem.data.model_id,
|
||||
...(feedbackItem.data.sibling_model_ids ?? [])
|
||||
].reduce((acc, modelId) => {
|
||||
const model = $models.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
acc[model.id] = model?.info?.base_model_id ?? null;
|
||||
const baseModels = [
|
||||
feedbackItem.data.model_id,
|
||||
...(feedbackItem.data.sibling_model_ids ?? [])
|
||||
].reduce((acc, modelId) => {
|
||||
const model = $models.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
acc[model.id] = model?.info?.base_model_id ?? null;
|
||||
} else {
|
||||
console.warn(`Model with ID ${modelId} not found`);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
feedbackItem.meta.base_models = baseModels;
|
||||
|
||||
let feedback = null;
|
||||
if (message?.feedbackId) {
|
||||
feedback = await updateFeedbackById(localStorage.token, message.feedbackId, feedbackItem);
|
||||
} else {
|
||||
// Log or handle cases where corresponding model is not found
|
||||
console.warn(`Model with ID ${modelId} not found`);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
feedbackItem.meta.base_models = baseModels;
|
||||
|
||||
let feedback = null;
|
||||
if (message?.feedbackId) {
|
||||
feedback = await updateFeedbackById(
|
||||
localStorage.token,
|
||||
message.feedbackId,
|
||||
feedbackItem
|
||||
).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
} else {
|
||||
feedback = await createNewFeedback(localStorage.token, feedbackItem).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (feedback) {
|
||||
updatedMessage.feedbackId = feedback.id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(updatedMessage);
|
||||
saveMessage(message.id, updatedMessage);
|
||||
|
||||
await tick();
|
||||
|
||||
if (!details) {
|
||||
showRateComment = true;
|
||||
|
||||
if (!updatedMessage.annotation?.tags && (message?.content ?? '') !== '') {
|
||||
// attempt to generate tags
|
||||
const tags = await generateTags(localStorage.token, message.model, messages, chatId).catch(
|
||||
(error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
console.log(tags);
|
||||
|
||||
if (tags) {
|
||||
updatedMessage.annotation.tags = tags;
|
||||
feedbackItem.data.tags = tags;
|
||||
|
||||
saveMessage(message.id, updatedMessage);
|
||||
await updateFeedbackById(
|
||||
localStorage.token,
|
||||
updatedMessage.feedbackId,
|
||||
feedbackItem
|
||||
).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
feedback = await createNewFeedback(localStorage.token, feedbackItem);
|
||||
if (feedback) {
|
||||
updatedMessage.feedbackId = feedback.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feedbackLoading = false;
|
||||
if (!feedback) return;
|
||||
|
||||
await saveMessage(message.id, updatedMessage);
|
||||
await tick();
|
||||
|
||||
if (!details) {
|
||||
showRateComment = true;
|
||||
if (requestGeneration === feedbackGeneration) feedbackLoading = false;
|
||||
|
||||
if (!updatedMessage.annotation?.tags && (message?.content ?? '') !== '') {
|
||||
const tags = await generateTags(
|
||||
localStorage.token,
|
||||
message.model,
|
||||
messages,
|
||||
chatId
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
if (requestGeneration !== feedbackGeneration) return;
|
||||
|
||||
if (tags) {
|
||||
const currentMessage = history.messages[message.id] ?? updatedMessage;
|
||||
const taggedMessage = {
|
||||
...currentMessage,
|
||||
annotation: {
|
||||
...(currentMessage?.annotation ?? {}),
|
||||
tags
|
||||
}
|
||||
};
|
||||
feedbackItem.data = {
|
||||
...feedbackItem.data,
|
||||
...taggedMessage.annotation
|
||||
};
|
||||
await saveMessage(message.id, taggedMessage);
|
||||
if (requestGeneration !== feedbackGeneration) return;
|
||||
await updateFeedbackById(localStorage.token, taggedMessage.feedbackId, feedbackItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
} finally {
|
||||
if (requestGeneration === feedbackGeneration) feedbackLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMessageHandler = async () => {
|
||||
deleteMessage(message.id);
|
||||
await deleteMessage(message.id);
|
||||
};
|
||||
|
||||
$: if (!edit) {
|
||||
|
|
@ -847,8 +850,8 @@
|
|||
onSetInputText={(text) => {
|
||||
setInputText(text);
|
||||
}}
|
||||
onSave={({ raw, oldContent, newContent }) => {
|
||||
const sourceMessage = history.messages[message.id];
|
||||
onSave={async ({ raw, oldContent, newContent }) => {
|
||||
const sourceMessage = structuredClone(history.messages[message.id]);
|
||||
if (sourceMessage.output?.length) {
|
||||
const updatedOutput = replaceOutputMessageText(
|
||||
sourceMessage.output,
|
||||
|
|
@ -870,7 +873,7 @@
|
|||
);
|
||||
}
|
||||
|
||||
updateChat();
|
||||
await saveMessage(message.id, sourceMessage);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@
|
|||
const editMessageHandler = async () => {
|
||||
edit = true;
|
||||
editedContent = message?.content ?? '';
|
||||
editedFiles = message.files;
|
||||
editedFiles = structuredClone(message.files ?? []);
|
||||
|
||||
await tick();
|
||||
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
editMessage(message.id, { content: editedContent, files: editedFiles }, submit);
|
||||
await editMessage(message.id, { content: editedContent, files: editedFiles }, submit);
|
||||
|
||||
edit = false;
|
||||
editedContent = '';
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
};
|
||||
|
||||
const deleteMessageHandler = async () => {
|
||||
deleteMessage(message.id);
|
||||
await deleteMessage(message.id);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
|
|
|
|||
277
src/lib/utils/chatHistoryWindow.ts
Normal file
277
src/lib/utils/chatHistoryWindow.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import equal from 'fast-deep-equal';
|
||||
|
||||
export type ChatHistoryMessage = {
|
||||
id?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ChatHistoryMessageMap = Record<string, ChatHistoryMessage>;
|
||||
|
||||
export type ChatMessageWindowState = {
|
||||
loadedIds?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type WindowedChatHistory = {
|
||||
currentId?: string | null;
|
||||
messages?: ChatHistoryMessageMap;
|
||||
messageWindow?: ChatMessageWindowState;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ChatHistoryWindowResponse = {
|
||||
messages: ChatHistoryMessageMap | ChatHistoryMessage[];
|
||||
loadedIds: string[];
|
||||
hasMore: boolean;
|
||||
currentId: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_CHAT_MESSAGE_WINDOW = 32;
|
||||
|
||||
type WindowedChat = {
|
||||
history?: WindowedChatHistory;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const uniqueIds = (ids: unknown[]): string[] => [
|
||||
...new Set(ids.filter((id): id is string => typeof id === 'string'))
|
||||
];
|
||||
|
||||
const normalizeMessages = (
|
||||
messages: ChatHistoryWindowResponse['messages']
|
||||
): ChatHistoryMessageMap => {
|
||||
if (Array.isArray(messages)) {
|
||||
return Object.fromEntries(
|
||||
messages
|
||||
.filter((message): message is ChatHistoryMessage & { id: string } => {
|
||||
return typeof message?.id === 'string';
|
||||
})
|
||||
.map((message) => [message.id, message])
|
||||
);
|
||||
}
|
||||
|
||||
return messages ?? {};
|
||||
};
|
||||
|
||||
export const getLoadedMessageIds = (history?: WindowedChatHistory | null): string[] => {
|
||||
if (Array.isArray(history?.messageWindow?.loadedIds)) {
|
||||
return uniqueIds(history.messageWindow.loadedIds);
|
||||
}
|
||||
|
||||
return Object.keys(history?.messages ?? {});
|
||||
};
|
||||
|
||||
export const isChatMessageLoaded = (
|
||||
history: WindowedChatHistory | null | undefined,
|
||||
messageId: string
|
||||
): boolean => {
|
||||
const message = history?.messages?.[messageId];
|
||||
return message !== undefined && message.__loaded !== false;
|
||||
};
|
||||
|
||||
export const resolveLoadedCurrentMessageId = (
|
||||
history: WindowedChatHistory | null | undefined,
|
||||
persistedCurrentId?: string | null
|
||||
): string | null => {
|
||||
const windowCurrentId = history?.currentId;
|
||||
if (windowCurrentId && isChatMessageLoaded(history, windowCurrentId)) {
|
||||
return windowCurrentId;
|
||||
}
|
||||
|
||||
if (persistedCurrentId && isChatMessageLoaded(history, persistedCurrentId)) {
|
||||
return persistedCurrentId;
|
||||
}
|
||||
|
||||
return windowCurrentId ?? persistedCurrentId ?? null;
|
||||
};
|
||||
|
||||
export const mergeChatMessageEvent = (
|
||||
message: ChatHistoryMessage,
|
||||
data: unknown,
|
||||
replace = false
|
||||
): ChatHistoryMessage => {
|
||||
const eventData =
|
||||
data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? (data as ChatHistoryMessage)
|
||||
: {};
|
||||
const patch = { ...eventData };
|
||||
delete patch.chat_id;
|
||||
delete patch.message_id;
|
||||
|
||||
if (replace) {
|
||||
return {
|
||||
...message,
|
||||
...patch,
|
||||
content: patch.content
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
...patch
|
||||
};
|
||||
};
|
||||
|
||||
export const groupMessageIdsByModel = (
|
||||
parentMessage: ChatHistoryMessage | null | undefined,
|
||||
messages: ChatHistoryMessageMap
|
||||
): Record<number, { messageIds: string[] }> => {
|
||||
const modelIds = Array.isArray(parentMessage?.models)
|
||||
? parentMessage.models.filter((modelId): modelId is string => typeof modelId === 'string')
|
||||
: [];
|
||||
const groups = Object.fromEntries(
|
||||
modelIds.map((_, modelIdx) => [modelIdx, { messageIds: [] as string[] }])
|
||||
) as Record<number, { messageIds: string[] }>;
|
||||
|
||||
if (modelIds.length === 0) return groups;
|
||||
|
||||
const childIds = Array.isArray(parentMessage?.childrenIds)
|
||||
? parentMessage.childrenIds.filter(
|
||||
(messageId): messageId is string => typeof messageId === 'string'
|
||||
)
|
||||
: [];
|
||||
|
||||
for (const [childIndex, messageId] of childIds.entries()) {
|
||||
const message = messages[messageId];
|
||||
const declaredModelIdx = Number(message?.modelIdx);
|
||||
let modelIdx =
|
||||
Number.isInteger(declaredModelIdx) &&
|
||||
declaredModelIdx >= 0 &&
|
||||
declaredModelIdx < modelIds.length
|
||||
? declaredModelIdx
|
||||
: -1;
|
||||
|
||||
if (modelIdx === -1 && typeof message?.model === 'string') {
|
||||
const matchingModelIndexes = modelIds
|
||||
.map((modelId, index) => (modelId === message.model ? index : -1))
|
||||
.filter((index) => index !== -1);
|
||||
|
||||
if (matchingModelIndexes.length > 0) {
|
||||
modelIdx = matchingModelIndexes.reduce((leastUsedIndex, candidateIndex) =>
|
||||
groups[candidateIndex].messageIds.length < groups[leastUsedIndex].messageIds.length
|
||||
? candidateIndex
|
||||
: leastUsedIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Topology stubs intentionally omit model data. Child order follows the
|
||||
// original multi-model request order, so keep every stub navigable until hydrated.
|
||||
if (modelIdx === -1) modelIdx = childIndex % modelIds.length;
|
||||
groups[modelIdx].messageIds.push(messageId);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
export const mergeChatHistoryWindow = (
|
||||
history: WindowedChatHistory,
|
||||
window: ChatHistoryWindowResponse
|
||||
): WindowedChatHistory => {
|
||||
const incomingMessages = normalizeMessages(window.messages);
|
||||
const messages: ChatHistoryMessageMap = Object.fromEntries(
|
||||
Object.entries(history.messages ?? {}).map(([id, message]) => [id, { ...message }])
|
||||
);
|
||||
|
||||
for (const [id, message] of Object.entries(incomingMessages)) {
|
||||
messages[id] = {
|
||||
...(messages[id] ?? {}),
|
||||
...message,
|
||||
id,
|
||||
__loaded: true
|
||||
};
|
||||
}
|
||||
|
||||
const loadedIds = uniqueIds([
|
||||
...getLoadedMessageIds(history),
|
||||
...window.loadedIds,
|
||||
...Object.keys(incomingMessages)
|
||||
]);
|
||||
|
||||
return {
|
||||
...history,
|
||||
currentId: window.currentId,
|
||||
messages,
|
||||
messageWindow: {
|
||||
...(history.messageWindow ?? {}),
|
||||
loadedIds,
|
||||
hasMore: window.hasMore,
|
||||
currentId: window.currentId
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const serializeChatForWindowSave = <T extends WindowedChat>(chat: T): T => {
|
||||
if (!chat.history) {
|
||||
return { ...chat };
|
||||
}
|
||||
|
||||
const loadedIds = getLoadedMessageIds(chat.history);
|
||||
const messageEntries = Object.entries(chat.history.messages ?? {});
|
||||
const messageIdsToSerialize = new Set([
|
||||
...loadedIds,
|
||||
...messageEntries
|
||||
.filter(([, message]) => message.__loaded !== false)
|
||||
.map(([messageId]) => messageId)
|
||||
]);
|
||||
const messages = Object.fromEntries(
|
||||
messageEntries
|
||||
.filter(([id, message]) => messageIdsToSerialize.has(id) && message.__loaded !== false)
|
||||
.map(([id, message]) => {
|
||||
const messageCopy = { ...message };
|
||||
delete messageCopy.__loaded;
|
||||
return [id, messageCopy];
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...chat,
|
||||
history: {
|
||||
...chat.history,
|
||||
currentId: chat.history.currentId,
|
||||
messages,
|
||||
...(chat.history.messageWindow
|
||||
? {
|
||||
messageWindow: {
|
||||
...chat.history.messageWindow,
|
||||
loadedIds
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
} as T;
|
||||
};
|
||||
|
||||
export const serializeMessageForPatch = (message: ChatHistoryMessage): ChatHistoryMessage => {
|
||||
const messageCopy = { ...message };
|
||||
delete messageCopy.__loaded;
|
||||
delete messageCopy.childrenIds;
|
||||
delete messageCopy.id;
|
||||
delete messageCopy.parentId;
|
||||
delete messageCopy.role;
|
||||
delete messageCopy.timestamp;
|
||||
return messageCopy;
|
||||
};
|
||||
|
||||
export const createMessagePatch = (
|
||||
previousMessage: ChatHistoryMessage,
|
||||
nextMessage: ChatHistoryMessage
|
||||
): ChatHistoryMessage => {
|
||||
const previous = serializeMessageForPatch(previousMessage);
|
||||
const next = serializeMessageForPatch(nextMessage);
|
||||
const patch: ChatHistoryMessage = {};
|
||||
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (!equal(previous[key], value)) {
|
||||
patch[key] = value === undefined ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(previous)) {
|
||||
if (!(key in next)) {
|
||||
patch[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return patch;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue