feat(chat): collapse log messages
This commit is contained in:
1 parent
5c7a106413
commit
4e7be79926
2 files changed
+215
-3
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import * as backend from '../backend';
|
import * as backend from '../backend';
|
||||||
|
import * as iconoir from '@iconoir/vue';
|
||||||
marked.use({
|
marked.use({
|
||||||
renderer: {
|
renderer: {
|
||||||
code ({lang, text}) {
|
code ({lang, text}) {
|
||||||
@@ -33,6 +34,12 @@ const props = defineProps({
|
|||||||
logExpanded: {
|
logExpanded: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
|
},
|
||||||
|
// Toggle set of expanded message ids, shared with the parent view.
|
||||||
|
// Used by log-groups to expand/collapse the previewed log.
|
||||||
|
expandedLogs: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +52,8 @@ function chatEntryClass(msg) {
|
|||||||
return ['resource', 'resource-' + (msg.subkind || 'generic')];
|
return ['resource', 'resource-' + (msg.subkind || 'generic')];
|
||||||
} else if (msg.type == 'log') {
|
} else if (msg.type == 'log') {
|
||||||
return ['log'];
|
return ['log'];
|
||||||
|
} else if (msg.type == 'log-group') {
|
||||||
|
return ['log-group'];
|
||||||
} else if (msg.type == 'thread-header') {
|
} else if (msg.type == 'thread-header') {
|
||||||
let cls = ['thread'];
|
let cls = ['thread'];
|
||||||
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed')
|
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed')
|
||||||
@@ -108,10 +117,106 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
|||||||
}
|
}
|
||||||
}, {immediate: true})
|
}, {immediate: true})
|
||||||
|
|
||||||
|
|
||||||
|
// === Log groups === //
|
||||||
|
// A log-group collects consecutive log messages. Each one is shown as a square
|
||||||
|
// icon (which selects it as the preview); the selected log is previewed below.
|
||||||
|
|
||||||
|
const logs = computed(() =>
|
||||||
|
props.message.type == 'log-group' ? (props.message.logs || []) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Icon used for a log's square button, chosen by log subkind
|
||||||
|
function logIcon(subkind) {
|
||||||
|
if (subkind == 'reasoning') return iconoir.Brain;
|
||||||
|
if (subkind == 'tool-call') return iconoir.Wrench;
|
||||||
|
return iconoir.Terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null means "default to the last log in the group"
|
||||||
|
const selectedLogId = ref(null);
|
||||||
|
|
||||||
|
const selectedLog = computed(() => {
|
||||||
|
const arr = logs.value;
|
||||||
|
if (!arr.length) return null;
|
||||||
|
|
||||||
|
if (selectedLogId.value != null) {
|
||||||
|
const found = arr.find(l => l.id == selectedLogId.value);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr[arr.length - 1];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Streamed logs resolve their content through a shared per-stream cache, so
|
||||||
|
// switching the preview between logs still shows up-to-date content.
|
||||||
|
const boundStreams = new Set();
|
||||||
|
const streamContents = ref({});
|
||||||
|
|
||||||
|
function bindLogStreams() {
|
||||||
|
for (const log of logs.value) {
|
||||||
|
if (log.format != 'stream') continue;
|
||||||
|
|
||||||
|
const streamId = parseInt(log.content);
|
||||||
|
if (boundStreams.has(streamId)) continue;
|
||||||
|
boundStreams.add(streamId);
|
||||||
|
|
||||||
|
const stream = backend.readStream(streamId);
|
||||||
|
const update = () => {
|
||||||
|
streamContents.value = { ...streamContents.value, [streamId]: stream.partialContent };
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
stream.on('data', update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(logs, bindLogStreams, { immediate: true });
|
||||||
|
|
||||||
|
const selectedLogText = computed(() => {
|
||||||
|
const log = selectedLog.value;
|
||||||
|
if (!log) return '';
|
||||||
|
if (log.format == 'stream') return streamContents.value[parseInt(log.content)] ?? '';
|
||||||
|
return log.content ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupLogLong = computed(() => {
|
||||||
|
if (!selectedLog.value) return false;
|
||||||
|
const lines = String(selectedLogText.value ?? '').split('\n');
|
||||||
|
return lines.length > LOG_MAX_LINES || lines.some(line => line.length > LOG_MAX_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupExpanded = computed(() =>
|
||||||
|
selectedLog.value != null && !!props.expandedLogs?.has(selectedLog.value.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collapsed preview keeps the *last* N characters (the tail), not the head;
|
||||||
|
// CSS then clips the top so only the bottom lines show.
|
||||||
|
const groupLogContent = computed(() => {
|
||||||
|
const text = String(selectedLogText.value ?? '');
|
||||||
|
if (groupLogLong.value && !groupExpanded.value) {
|
||||||
|
return text.slice(-(LOG_MAX_LENGTH * LOG_MAX_LINES));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupLogMeta = computed(() => groupLogLong.value ? {expanded: groupExpanded.value} : null);
|
||||||
|
|
||||||
|
function toggleGroupLog() {
|
||||||
|
if (selectedLog.value) props.expandedLogs?.toggle(selectedLog.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenu() {
|
||||||
|
if (props.message.type == 'log-group') {
|
||||||
|
if (selectedLog.value) emit('open-menu', selectedLog.value, groupLogMeta.value);
|
||||||
|
} else {
|
||||||
|
emit('open-menu', props.message, logMeta.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="chatEntryClass(message)" @contextmenu.prevent="$emit('open-menu', message, logMeta)" @click="handleClick(message)">
|
<div :class="chatEntryClass(message)" @contextmenu.prevent="onContextMenu" @click="handleClick(message)">
|
||||||
<template v-if="message.type == 'message' || message.type == null">
|
<template v-if="message.type == 'message' || message.type == null">
|
||||||
<div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div>
|
<div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div>
|
||||||
|
|
||||||
@@ -135,6 +240,28 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
|||||||
<a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
|
<a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="message.type == 'log-group'">
|
||||||
|
<div class="log-icons">
|
||||||
|
<button
|
||||||
|
v-for="log in logs"
|
||||||
|
:key="log.id"
|
||||||
|
type="button"
|
||||||
|
class="icon"
|
||||||
|
:class="{ highlight: selectedLog && log.id == selectedLog.id }"
|
||||||
|
:title="(log.sender || '') + (log.subkind ? ' [' + log.subkind + ']' : '')"
|
||||||
|
@click="selectedLogId = log.id"
|
||||||
|
>
|
||||||
|
<component :is="logIcon(log.subkind)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedLog" class="log-preview">
|
||||||
|
<div class="log-sender">{{selectedLog.sender}}<span v-if="selectedLog.subkind"> [{{selectedLog.subkind}}]</span></div>
|
||||||
|
<div class="content" :class="{ clipped: !groupExpanded }">{{groupLogContent}}</div>
|
||||||
|
<a v-if="groupLogLong" class="log-toggle" @click.prevent="toggleGroupLog()">{{groupExpanded ? '(collapse)' : '(expand)'}}</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="message.type == 'thread-header'">
|
<template v-if="message.type == 'thread-header'">
|
||||||
<div class="thread-title">
|
<div class="thread-title">
|
||||||
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
|
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
|
||||||
@@ -219,6 +346,67 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Log groups === */
|
||||||
|
|
||||||
|
/* Completely transparent container; stacks icons above the preview and
|
||||||
|
stretches to the full width of the chat */
|
||||||
|
.log-group {
|
||||||
|
align-self: stretch;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6em 1.4em;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Square buttons (sized by the global `button.icon`) that wrap to the width,
|
||||||
|
each line centered within the container */
|
||||||
|
.log-icons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-icons .highlight {
|
||||||
|
color: var(--color-highlight);
|
||||||
|
border-color: var(--color-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-preview {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 80%;
|
||||||
|
max-width: 70em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-preview .log-sender {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-preview .content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: anywhere;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 70em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clipping shows only the bottom lines: the content is bottom-aligned, so any
|
||||||
|
vertical overflow is clipped off the top. ~6 lines at line-height 1.3. */
|
||||||
|
.log-preview .content.clipped {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
max-height: 7.8em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-preview .log-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.resource {
|
.resource {
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
border: 1px dashed var(--color-border);
|
border: 1px dashed var(--color-border);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
ref, watch,
|
ref, computed, watch,
|
||||||
onMounted, onUnmounted,
|
onMounted, onUnmounted,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
|
|
||||||
@@ -30,6 +30,29 @@
|
|||||||
creatingThread,
|
creatingThread,
|
||||||
} = session;
|
} = session;
|
||||||
|
|
||||||
|
// Consecutive log messages collapse into a single "log-group" entry, which
|
||||||
|
// Message.vue renders as a container of icons plus a preview.
|
||||||
|
const displayEntries = computed(() => {
|
||||||
|
const entries = chatEntries.value || [];
|
||||||
|
const out = [];
|
||||||
|
let group = null;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.type == 'log') {
|
||||||
|
if (!group) {
|
||||||
|
group = { id: 'log-group-' + entry.id, type: 'log-group', logs: [] };
|
||||||
|
out.push(group);
|
||||||
|
}
|
||||||
|
group.logs.push(entry);
|
||||||
|
} else {
|
||||||
|
group = null;
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
// UI state coupled to the document itself
|
// UI state coupled to the document itself
|
||||||
|
|
||||||
// Message ids of logs currently expanded
|
// Message ids of logs currently expanded
|
||||||
@@ -216,7 +239,7 @@
|
|||||||
<div class="row" style="overflow: auto;">
|
<div class="row" style="overflow: auto;">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="messages-container" ref="chatContainerElem">
|
<div class="messages-container" ref="chatContainerElem">
|
||||||
<div class="message-container" v-for="msg in chatEntries">
|
<div class="message-container" v-for="msg in displayEntries">
|
||||||
<Message
|
<Message
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
:message="msg"
|
:message="msg"
|
||||||
@@ -225,6 +248,7 @@
|
|||||||
|
|
||||||
:log-expanded="expandedLogs.has(msg.id)"
|
:log-expanded="expandedLogs.has(msg.id)"
|
||||||
@toggle-log="expandedLogs.toggle(msg.id)"
|
@toggle-log="expandedLogs.toggle(msg.id)"
|
||||||
|
:expanded-logs="expandedLogs"
|
||||||
|
|
||||||
:cancellable="messageRunMap[msg.id]"
|
:cancellable="messageRunMap[msg.id]"
|
||||||
@cancel="session.cancelMessageRun(msg.id)"
|
@cancel="session.cancelMessageRun(msg.id)"
|
||||||
|
|||||||
Reference in new issue
Block a user