refactor: replace focusedIndex with activeChildId
- Replace focusedIndex integer with activeChildId string in messages schema - Add migration to convert existing data and drop focused_index column - Add hooks for providers.updatedAt and messageParts.lastUpdatedAt - Add file size column to files table - Update message navigation to use child ID-based lookup - Simplify delete logic by removing focusedIndex math - Fix file delete endpoint filename ([id[ -> [id])
This commit is contained in:
@@ -8,7 +8,7 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl">
|
||||
<MarkdownRenderer :finished="true" :content="message.content!" :id="message.id" />
|
||||
<MarkdownRenderer v-if="message.content" :finished="true" :content="message.content" :id="message.id" />
|
||||
<div v-if="message.attachments && message.attachments.length > 0" class="flex flex-col gap-2">
|
||||
<div v-for="attachment in message.attachments" :key="attachment.id"
|
||||
class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -16,66 +16,56 @@ const emit = defineEmits<{
|
||||
|
||||
let reqAbortController: AbortController | null = null;
|
||||
|
||||
watch(() => message.focusedIndex, async (newValue) => {
|
||||
if (newValue !== undefined) {
|
||||
if (reqAbortController) {
|
||||
reqAbortController.abort();
|
||||
reqAbortController = null;
|
||||
}
|
||||
|
||||
reqAbortController = new AbortController();
|
||||
|
||||
await $fetch(`/api/messages/${message.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
focusedIndex: newValue
|
||||
},
|
||||
signal: reqAbortController.signal,
|
||||
});
|
||||
watch(() => message.activeChildId, async (newValue) => {
|
||||
if (reqAbortController) {
|
||||
reqAbortController.abort();
|
||||
reqAbortController = null;
|
||||
}
|
||||
|
||||
reqAbortController = new AbortController();
|
||||
|
||||
await $fetch(`/api/messages/${message.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
activeChildId: newValue
|
||||
},
|
||||
signal: reqAbortController.signal,
|
||||
});
|
||||
});
|
||||
|
||||
watch(() => message.children.length, (newCount, oldCount) => {
|
||||
if (newCount === oldCount) return;
|
||||
|
||||
// if we are deleting children, only move the focus index if its no longer valid
|
||||
// e.g. if we we have 4 children, and are focused on the 2nd, if we delete it,
|
||||
// we want to keep the focus on the 2nd index. It just makes me feel better
|
||||
if (newCount === 0) {
|
||||
emit('patch', { activeChildId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldCount > newCount) {
|
||||
if (message.deleted === true && (message.focusedIndex || 0) === newCount) {
|
||||
console.log('focusedIndex Math.max(0, newCount - 1)');
|
||||
message.focusedIndex = Math.max(0, newCount - 1);
|
||||
return;
|
||||
const activeChild = message.children.find(c => c.id === message.activeChildId);
|
||||
if (!activeChild) {
|
||||
emit('patch', { activeChildId: message.children[newCount - 1]?.id ?? null });
|
||||
}
|
||||
|
||||
if ((message.focusedIndex || 0) > newCount) {
|
||||
console.log('focusedIndex newCount');
|
||||
message.focusedIndex = newCount;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.deleted === true) {
|
||||
console.log('focusedIndex newCount - 1');
|
||||
message.focusedIndex = newCount - 1;
|
||||
return;
|
||||
const newChild = message.children[newCount - 1];
|
||||
if (newChild) {
|
||||
emit('patch', { activeChildId: newChild.id });
|
||||
}
|
||||
|
||||
console.log('focusedIndex newCount');
|
||||
message.focusedIndex = newCount;
|
||||
});
|
||||
|
||||
const activeMessage = computed(() => {
|
||||
if (message.children.length === 0 || ((message.focusedIndex || 0) === 0 && !message.deleted)) {
|
||||
return message;
|
||||
if (message.activeChildId && message.children.length > 0) {
|
||||
const child = message.children.find(c => c.id === message.activeChildId);
|
||||
if (child) return child;
|
||||
}
|
||||
|
||||
if (message.deleted === true) {
|
||||
return message.children[Math.min((message.focusedIndex || 0), message.children.length - 1)];
|
||||
if (message.deleted === true && message.children.length > 0) {
|
||||
return message.children[0];
|
||||
}
|
||||
|
||||
return message.children[message.focusedIndex! - 1];
|
||||
return message;
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
@@ -137,10 +127,6 @@ const handleEdit = async () => {
|
||||
};
|
||||
|
||||
const deleteMessage = () => {
|
||||
// if (focusedIndex.value !== 0 && focusedIndex.value === message.children.length) {
|
||||
// focusedIndex.value = Math.max(0, focusedIndex.value - 1);
|
||||
// }
|
||||
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
@@ -151,6 +137,27 @@ const messageCount = computed(() => {
|
||||
|
||||
return message.children.length + 1;
|
||||
});
|
||||
|
||||
const currentChildIndex = computed(() => {
|
||||
if (!message.activeChildId) return 0;
|
||||
const idx = message.children.findIndex(c => c.id === message.activeChildId);
|
||||
return idx >= 0 ? idx + 1 : 0;
|
||||
});
|
||||
|
||||
const navigateChild = (direction: -1 | 1) => {
|
||||
const allItems = message.deleted === true
|
||||
? message.children
|
||||
: [null, ...message.children];
|
||||
|
||||
const currentIdx = allItems.findIndex(item =>
|
||||
item === null ? !message.activeChildId : item.id === message.activeChildId
|
||||
);
|
||||
|
||||
const newIdx = Math.max(0, Math.min(allItems.length - 1, currentIdx + direction));
|
||||
const newItem = allItems[newIdx];
|
||||
|
||||
emit('patch', { activeChildId: newItem?.id ?? null });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,21 +169,21 @@ const messageCount = computed(() => {
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div v-if="messageCount > 1" class="flex gap-1">
|
||||
<Tooltip :inert="message.focusedIndex === 0" :hotkey="['alt', '[']">
|
||||
<button @click="emit('patch', { focusedIndex: message.focusedIndex! - 1 })"
|
||||
<Tooltip :inert="currentChildIndex === 0" :hotkey="['alt', '[']">
|
||||
<button @click="navigateChild(-1)"
|
||||
class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-chevron-left w-4 h-4 text-[var(--text-secondary)]"
|
||||
:class="message.focusedIndex === 0 ? 'opacity-0' : ''"></span>
|
||||
:class="currentChildIndex === 0 ? 'opacity-0' : ''"></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span class="text-xs text-[var(--text-secondary)]">
|
||||
{{ (message.focusedIndex || 0) + 1 }} / {{ messageCount }}
|
||||
{{ currentChildIndex + 1 }} / {{ messageCount }}
|
||||
</span>
|
||||
<Tooltip :inert="(message.focusedIndex || 0) + 1 === messageCount" :hotkey="['alt', ']']">
|
||||
<button @click="emit('patch', { focusedIndex: (message.focusedIndex || 0) + 1 })"
|
||||
<Tooltip :inert="currentChildIndex + 1 === messageCount" :hotkey="['alt', ']']">
|
||||
<button @click="navigateChild(1)"
|
||||
class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-chevron-right w-4 h-4 text-[var(--text-secondary)]"
|
||||
:class="(message.focusedIndex || 0) + 1 === messageCount ? 'opacity-0' : ''"></span>
|
||||
:class="currentChildIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,10 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
|
||||
let sse: EventSource | undefined;
|
||||
|
||||
const textDeltaBuffer: Map<string, Map<string, string>> = new Map();
|
||||
const textDeltaBuffer: Map<string, Map<string, {
|
||||
content: string;
|
||||
lastUpdatedAt: string;
|
||||
}>> = new Map();
|
||||
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let flushCbs: Set<() => void> = new Set();
|
||||
|
||||
@@ -54,7 +57,8 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
for (const [partId, content] of parts) {
|
||||
const part = msg.parts?.find(p => p.id === partId);
|
||||
if (part) {
|
||||
part.content += content;
|
||||
part.content += content.content;
|
||||
part.lastUpdatedAt = new Date(content.lastUpdatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,8 +136,8 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
textDeltaBuffer.set(payload.messageId, new Map());
|
||||
}
|
||||
const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
|
||||
const existing = messageBuffer.get(payload.partId) || '';
|
||||
messageBuffer.set(payload.partId, existing + payload.content);
|
||||
const existing = messageBuffer.get(payload.partId)?.content || '';
|
||||
messageBuffer.set(payload.partId, { content: existing + payload.content, lastUpdatedAt: payload.lastUpdatedAt });
|
||||
|
||||
scheduleFlush();
|
||||
break;
|
||||
@@ -145,6 +149,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
if (part) {
|
||||
nextFlush(() => {
|
||||
part.content = payload.content;
|
||||
part.lastUpdatedAt = payload.lastUpdatedAt;
|
||||
part.finished = true;
|
||||
});
|
||||
}
|
||||
@@ -328,7 +333,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
generation: null,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
focusedIndex: null,
|
||||
activeChildId: null,
|
||||
deleted: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -457,11 +462,46 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
);
|
||||
}
|
||||
|
||||
const deleteMessagesFocusedDescendant = async (messageId: string): Promise<Result<void, Error>> => {
|
||||
if (!data.value) return Err(new Error('No data'));
|
||||
|
||||
const msg = data.value.messages.find(m => m.id === messageId);
|
||||
if (!msg) return Err(new Error('Message not found'));
|
||||
|
||||
const previousMessages = data.value!.messages;
|
||||
|
||||
const res = await $fetch(`/api/messages/${messageId}`, {
|
||||
method: 'DELETE',
|
||||
onRequest() {
|
||||
if (!msg.activeChildId && (msg.parentMessageId === null || msg.parentMessageId === undefined)) {
|
||||
msg.deleted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = data.value!.messages.find(m => m.id === msg.parentMessageId);
|
||||
data.value!.messages = data.value!.messages.filter(m => m.id !== msg.id);
|
||||
if (parent?.deleted) {
|
||||
data.value!.messages = data.value!.messages.filter(m => m.id !== parent.id);
|
||||
}
|
||||
},
|
||||
onResponseError() {
|
||||
data.value!.messages = previousMessages;
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return Err(new Error('Failed to delete message'));
|
||||
}
|
||||
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
topic,
|
||||
sendMessage,
|
||||
startGeneration,
|
||||
regenerateMessage,
|
||||
patchMessageLocally
|
||||
patchMessageLocally,
|
||||
deleteMessagesFocusedDescendant
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
|
||||
}
|
||||
|
||||
console.log("handleSubmit", message);
|
||||
if (!message.content) return;
|
||||
|
||||
pendingMessage.value = {
|
||||
id: '',
|
||||
@@ -43,7 +42,7 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
|
||||
parentMessageId: null,
|
||||
children: [],
|
||||
generationId: null,
|
||||
focusedIndex: null,
|
||||
activeChildId: null,
|
||||
attachments: [],
|
||||
deleted: false,
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -16,7 +16,7 @@ const { addShortcut } = useKeyboardShortcuts();
|
||||
const agent = getAgent(route.params.id as string);
|
||||
|
||||
const topicId = computed(() => route.params.topicId as string);
|
||||
const { topic, sendMessage, startGeneration, regenerateMessage, patchMessageLocally } = await useChat(topicId);
|
||||
const { topic, sendMessage, startGeneration, regenerateMessage, patchMessageLocally, deleteMessagesFocusedDescendant } = await useChat(topicId);
|
||||
|
||||
watch(() => topic.value?.name, (newTopicName) => {
|
||||
if (newTopicName !== undefined) {
|
||||
@@ -61,12 +61,8 @@ const handleRegenerate = async (message: Message) => {
|
||||
}
|
||||
|
||||
let messageId: string;
|
||||
if (
|
||||
(message.focusedIndex !== undefined && message.focusedIndex !== null)
|
||||
&& message.focusedIndex > 0
|
||||
&& message.children.length > 0
|
||||
) {
|
||||
messageId = message.children[message.focusedIndex - 1]!.id;
|
||||
if (message.activeChildId && message.children.length > 0) {
|
||||
messageId = message.activeChildId;
|
||||
} else {
|
||||
messageId = message.id;
|
||||
}
|
||||
@@ -98,9 +94,18 @@ const flatMessages = computed(() => {
|
||||
});
|
||||
|
||||
const handleDelete = async (rootMessage: Message) => {
|
||||
await $fetch(`/api/messages/${rootMessage.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
let messageId: string;
|
||||
if (rootMessage.activeChildId && rootMessage.children.length > 0) {
|
||||
messageId = rootMessage.activeChildId;
|
||||
} else {
|
||||
messageId = rootMessage.id;
|
||||
}
|
||||
|
||||
const result = await deleteMessagesFocusedDescendant(messageId);
|
||||
if (!result.ok) {
|
||||
console.error('Failed to delete message:', result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flatMessages.value.length === 0) {
|
||||
await navigateTo(`/agent/${route.params.id}`);
|
||||
@@ -108,6 +113,20 @@ const handleDelete = async (rootMessage: Message) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
const el = chatPaneWrapper.value;
|
||||
if (!el) return;
|
||||
|
||||
const atBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) <= 80;
|
||||
if (!atBottom) return;
|
||||
|
||||
nextTick().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const activeGeneration = computed(() => {
|
||||
if (topic.value === null) return null;
|
||||
const generations = flatMessages.value.flatMap(message => message.generation);
|
||||
@@ -155,8 +174,11 @@ addShortcut(['alt', '['], async (event) => {
|
||||
|
||||
const lastMessage = topic.value?.messages?.at(-1);
|
||||
if (lastMessage && lastMessage.children.length > 0) {
|
||||
const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
|
||||
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
const prevIdx = Math.max(0, currentIdx);
|
||||
if (currentIdx > 0) {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[prevIdx - 1]!.id });
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -165,10 +187,20 @@ addShortcut(['alt', ']'], async (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const lastMessage = topic.value?.messages?.at(-1);
|
||||
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
|
||||
if (lastMessage && lastMessage.children.length > 0) {
|
||||
const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
|
||||
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length;
|
||||
const nextIdx = Math.min(maxIdx, currentIdx + 1);
|
||||
|
||||
if (lastMessage.deleted) {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null });
|
||||
} else {
|
||||
if (nextIdx === 0) {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: null });
|
||||
} else {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -178,20 +210,32 @@ addShortcut(['ctrl', 'alt', 'arrowleft'], async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
|
||||
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
if (currentIdx > 0) {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[currentIdx - 1]!.id });
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => {
|
||||
const lastMessage = topic.value?.messages?.at(-1);
|
||||
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
|
||||
if (lastMessage && lastMessage.children.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
|
||||
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length;
|
||||
const nextIdx = Math.min(maxIdx, currentIdx + 1);
|
||||
|
||||
if (lastMessage.deleted) {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null });
|
||||
} else {
|
||||
if (nextIdx === 0) {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: null });
|
||||
} else {
|
||||
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -249,7 +293,8 @@ console.log("full page render took", Date.now() - rootStart);
|
||||
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
||||
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:loading="activeGeneration !== null" :agent="agent"
|
||||
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @cancel="handleCancel" />
|
||||
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @cancel="handleCancel"
|
||||
@resize="handleResize" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user