Compare commits
2 Commits
49e30296b9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| be6641de24 | |||
| 72b3a0902a |
@@ -16,10 +16,23 @@ type IncomingChatMessage = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sameMessage(a: IncomingChatMessage, b: IncomingChatMessage) {
|
function sameMessage(
|
||||||
|
a: { role: string; content: string; name?: string | null },
|
||||||
|
b: { role: string; content: string; name?: string | null }
|
||||||
|
) {
|
||||||
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
|
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isToolCallLogMetadata(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return record.kind === "tool_call";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
||||||
|
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
@@ -27,13 +40,13 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
|||||||
const existing = await prisma.message.findMany({
|
const existing = await prisma.message.findMany({
|
||||||
where: { chatId },
|
where: { chatId },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { role: true, content: true, name: true },
|
select: { role: true, content: true, name: true, metadata: true },
|
||||||
});
|
});
|
||||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant");
|
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||||
|
|
||||||
let sharedPrefix = 0;
|
let sharedPrefix = 0;
|
||||||
const max = Math.min(existingNonAssistant.length, incoming.length);
|
const max = Math.min(existingNonAssistant.length, incoming.length);
|
||||||
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix] as IncomingChatMessage, incoming[sharedPrefix])) {
|
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix], incoming[sharedPrefix])) {
|
||||||
sharedPrefix += 1;
|
sharedPrefix += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Compatibility aliases:
|
|||||||
- `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer
|
- `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer
|
||||||
- `Esc` (in composer): exit input mode and focus sidebar
|
- `Esc` (in composer): exit input mode and focus sidebar
|
||||||
- `Up` / `Down` (in sidebar): move highlight
|
- `Up` / `Down` (in sidebar): move highlight
|
||||||
|
- `Page Up` / `Page Down` (in transcript): scroll the transcript by one page
|
||||||
- `Enter` in sidebar: load highlighted conversation/search
|
- `Enter` in sidebar: load highlighted conversation/search
|
||||||
- `Enter` in composer: send message/search
|
- `Enter` in composer: send message/search
|
||||||
- `n`: new chat draft
|
- `n`: new chat draft
|
||||||
|
|||||||
@@ -323,6 +323,35 @@ async function main() {
|
|||||||
|
|
||||||
const focusables = [sidebar, transcript, composer] as const;
|
const focusables = [sidebar, transcript, composer] as const;
|
||||||
|
|
||||||
|
function getTranscriptViewportHeight() {
|
||||||
|
const lpos = transcript.lpos;
|
||||||
|
if (lpos) {
|
||||||
|
return Math.max(1, lpos.yl - lpos.yi - Number(transcript.iheight ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Number(transcript.height ?? 1) - Number(transcript.iheight ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTranscriptNearBottom() {
|
||||||
|
const viewportHeight = getTranscriptViewportHeight();
|
||||||
|
const maxScroll = Math.max(0, transcript.getScrollHeight() - viewportHeight);
|
||||||
|
if (maxScroll === 0) return true;
|
||||||
|
return maxScroll - transcript.getScroll() <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueTranscriptScrollToBottomIfFollowing() {
|
||||||
|
if (isTranscriptNearBottom()) {
|
||||||
|
forceScrollToBottom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollTranscriptByPage(direction: 1 | -1) {
|
||||||
|
forceScrollToBottom = false;
|
||||||
|
const pageSize = Math.max(1, getTranscriptViewportHeight() - 1);
|
||||||
|
transcript.scroll(direction * pageSize);
|
||||||
|
screen.render();
|
||||||
|
}
|
||||||
|
|
||||||
function withSuppressedSidebarSelectEvents(fn: () => void) {
|
function withSuppressedSidebarSelectEvents(fn: () => void) {
|
||||||
suppressedSidebarSelectEvents += 1;
|
suppressedSidebarSelectEvents += 1;
|
||||||
try {
|
try {
|
||||||
@@ -684,7 +713,7 @@ async function main() {
|
|||||||
return items.some((item) => item.kind === selection.kind && item.id === selection.id);
|
return items.some((item) => item.kind === selection.kind && item.id === selection.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSelection(selection: SidebarSelection | null) {
|
async function loadSelection(selection: SidebarSelection | null, options?: { scrollToBottom?: boolean | undefined }) {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
selectedChat = null;
|
selectedChat = null;
|
||||||
selectedSearch = null;
|
selectedSearch = null;
|
||||||
@@ -714,12 +743,18 @@ async function main() {
|
|||||||
if (selectionKey(selectedItem) === requestedSelectionKey) {
|
if (selectionKey(selectedItem) === requestedSelectionKey) {
|
||||||
isLoadingSelection = false;
|
isLoadingSelection = false;
|
||||||
}
|
}
|
||||||
forceScrollToBottom = true;
|
if (options?.scrollToBottom) {
|
||||||
|
forceScrollToBottom = true;
|
||||||
|
}
|
||||||
updateUI();
|
updateUI();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCollections(options?: { preferredSelection?: SidebarSelection; loadSelection?: boolean }) {
|
async function refreshCollections(options?: {
|
||||||
|
preferredSelection?: SidebarSelection;
|
||||||
|
loadSelection?: boolean;
|
||||||
|
scrollToBottomOnLoad?: boolean | undefined;
|
||||||
|
}) {
|
||||||
isLoadingCollections = true;
|
isLoadingCollections = true;
|
||||||
updateUI();
|
updateUI();
|
||||||
|
|
||||||
@@ -748,7 +783,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.loadSelection) {
|
if (options?.loadSelection) {
|
||||||
await loadSelection(selectedItem);
|
await loadSelection(selectedItem, { scrollToBottom: options?.scrollToBottomOnLoad });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +832,7 @@ async function main() {
|
|||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
forceScrollToBottom = true;
|
forceScrollToBottom = true;
|
||||||
updateUI();
|
updateUI();
|
||||||
await loadSelection(selectedItem);
|
await loadSelection(selectedItem, { scrollToBottom: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateChat() {
|
function handleCreateChat() {
|
||||||
@@ -966,7 +1001,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
forceScrollToBottom = true;
|
queueTranscriptScrollToBottomIfFollowing();
|
||||||
updateUI();
|
updateUI();
|
||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
@@ -982,7 +1017,7 @@ async function main() {
|
|||||||
|
|
||||||
if (!updated) return;
|
if (!updated) return;
|
||||||
pendingChatState = { ...pendingChatState, messages: nextMessages };
|
pendingChatState = { ...pendingChatState, messages: nextMessages };
|
||||||
forceScrollToBottom = true;
|
queueTranscriptScrollToBottomIfFollowing();
|
||||||
updateUI();
|
updateUI();
|
||||||
},
|
},
|
||||||
onDone: (payload) => {
|
onDone: (payload) => {
|
||||||
@@ -998,7 +1033,7 @@ async function main() {
|
|||||||
|
|
||||||
if (!updated) return;
|
if (!updated) return;
|
||||||
pendingChatState = { ...pendingChatState, messages: nextMessages };
|
pendingChatState = { ...pendingChatState, messages: nextMessages };
|
||||||
forceScrollToBottom = true;
|
queueTranscriptScrollToBottomIfFollowing();
|
||||||
updateUI();
|
updateUI();
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -1011,15 +1046,17 @@ async function main() {
|
|||||||
throw new Error(streamErrorMessage);
|
throw new Error(streamErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldFollowTranscript = isTranscriptNearBottom();
|
||||||
|
|
||||||
await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false });
|
await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false });
|
||||||
|
|
||||||
const currentSelection = selectedItem;
|
const currentSelection = selectedItem;
|
||||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||||
await loadSelection(currentSelection);
|
await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingChatState = null;
|
pendingChatState = null;
|
||||||
forceScrollToBottom = true;
|
forceScrollToBottom = shouldFollowTranscript;
|
||||||
updateUI();
|
updateUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,7 +1140,7 @@ async function main() {
|
|||||||
error: null,
|
error: null,
|
||||||
results: payload.results,
|
results: payload.results,
|
||||||
};
|
};
|
||||||
forceScrollToBottom = true;
|
queueTranscriptScrollToBottomIfFollowing();
|
||||||
updateUI();
|
updateUI();
|
||||||
},
|
},
|
||||||
onSearchError: (payload) => {
|
onSearchError: (payload) => {
|
||||||
@@ -1122,7 +1159,7 @@ async function main() {
|
|||||||
answerCitations: payload.answerCitations,
|
answerCitations: payload.answerCitations,
|
||||||
answerError: null,
|
answerError: null,
|
||||||
};
|
};
|
||||||
forceScrollToBottom = true;
|
queueTranscriptScrollToBottomIfFollowing();
|
||||||
updateUI();
|
updateUI();
|
||||||
},
|
},
|
||||||
onAnswerError: (payload) => {
|
onAnswerError: (payload) => {
|
||||||
@@ -1135,7 +1172,7 @@ async function main() {
|
|||||||
if (runId !== searchRunCounter) return;
|
if (runId !== searchRunCounter) return;
|
||||||
selectedSearch = payload.search;
|
selectedSearch = payload.search;
|
||||||
selectedChat = null;
|
selectedChat = null;
|
||||||
forceScrollToBottom = true;
|
queueTranscriptScrollToBottomIfFollowing();
|
||||||
updateUI();
|
updateUI();
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -1154,11 +1191,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldFollowTranscript = isTranscriptNearBottom();
|
||||||
|
|
||||||
await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false });
|
await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false });
|
||||||
|
|
||||||
const currentSelection = selectedItem;
|
const currentSelection = selectedItem;
|
||||||
if (currentSelection?.kind === "search" && currentSelection.id === searchId) {
|
if (currentSelection?.kind === "search" && currentSelection.id === searchId) {
|
||||||
await loadSelection(currentSelection);
|
await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,7 +1225,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
await loadSelection(selectedItem);
|
await loadSelection(selectedItem, { scrollToBottom: true });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
@@ -1214,7 +1253,7 @@ async function main() {
|
|||||||
selectedSearch = null;
|
selectedSearch = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshCollections({ loadSelection: true });
|
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleProvider() {
|
function cycleProvider() {
|
||||||
@@ -1262,6 +1301,14 @@ async function main() {
|
|||||||
updateUI();
|
updateUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
transcript.key(["pageup"], () => {
|
||||||
|
scrollTranscriptByPage(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
transcript.key(["pagedown"], () => {
|
||||||
|
scrollTranscriptByPage(1);
|
||||||
|
});
|
||||||
|
|
||||||
sidebar.on("select item", (_item, index) => {
|
sidebar.on("select item", (_item, index) => {
|
||||||
if (suppressedSidebarSelectEvents > 0) return;
|
if (suppressedSidebarSelectEvents > 0) return;
|
||||||
const highlightedItem = getSidebarItems()[index];
|
const highlightedItem = getSidebarItems()[index];
|
||||||
@@ -1389,7 +1436,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await Promise.all([refreshCollections({ loadSelection: true }), refreshModels()]);
|
await Promise.all([refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }), refreshModels()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
focusComposer();
|
focusComposer();
|
||||||
|
|||||||
Reference in New Issue
Block a user