322 lines
7.5 KiB
Vue
322 lines
7.5 KiB
Vue
<script setup>
|
|
import { ref, computed, watch } from 'vue';
|
|
import { marked } from "marked";
|
|
import * as backend from '../backend';
|
|
marked.use({
|
|
renderer: {
|
|
code ({lang, text}) {
|
|
let langElem = lang ?
|
|
`<div class="code-lang">${lang}</div>` : "";
|
|
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
const escaped = div.innerHTML;
|
|
|
|
return `<div class="code">
|
|
${langElem}
|
|
<div class="code-content">${escaped}</div>
|
|
</div>`;
|
|
}
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['open-thread', 'open-menu', 'toggle-log', 'cancel'])
|
|
const props = defineProps({
|
|
message: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
cancellable: {
|
|
type: Boolean,
|
|
required: false,
|
|
},
|
|
logExpanded: {
|
|
type: Boolean,
|
|
required: false,
|
|
}
|
|
});
|
|
|
|
function chatEntryClass(msg) {
|
|
if (msg.type == 'note') {
|
|
return ['note'];
|
|
} else if (msg.type == 'message') {
|
|
return ['message', msg.sender == 'user' ? 'sent' : 'received'];
|
|
} else if (msg.type == 'resource') {
|
|
return ['resource', 'resource-' + (msg.subkind || 'generic')];
|
|
} else if (msg.type == 'log') {
|
|
return ['log'];
|
|
} else if (msg.type == 'thread-header') {
|
|
let cls = ['thread'];
|
|
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed')
|
|
return cls;
|
|
} else {
|
|
return ['info'];
|
|
}
|
|
}
|
|
|
|
function resourceLabel(msg) {
|
|
if (msg.subkind == 'remote') return 'Remote';
|
|
return msg.subkind || 'Resource';
|
|
}
|
|
|
|
function handleClick(msg) {
|
|
if (msg.type == 'thread-header' || msg.type == 'fork-header') {
|
|
if (!msg.active && msg.threadId != null) {
|
|
emit('open-thread', msg.threadId)
|
|
}
|
|
}
|
|
}
|
|
|
|
const format = ref(null);
|
|
const content = ref("");
|
|
|
|
// Log messages longer than LOG_MAX_LINES lines get expand/collapse controls
|
|
const LOG_MAX_LINES = 6;
|
|
const LOG_MAX_LENGTH = 64; // Single line size considered "too tall" to display
|
|
|
|
const logLines = computed(() => String(content.value ?? '').split('\n'));
|
|
const logLong = computed(() => props.message.type == 'log' && (
|
|
logLines.value.length > LOG_MAX_LINES || logLines.value.some(line => line.length > LOG_MAX_LENGTH)
|
|
));
|
|
const logContent = computed(() =>
|
|
logLong.value && !props.logExpanded
|
|
? logLines.value.slice(0, LOG_MAX_LINES).join('\n').slice(0, LOG_MAX_LENGTH*LOG_MAX_LINES)
|
|
: content.value
|
|
);
|
|
|
|
// Given to the context menu; the expand/collapse entry is offered only when non-null
|
|
const logMeta = computed(() => logLong.value ? {expanded: !!props.logExpanded} : null);
|
|
|
|
let currentStreamId = null;
|
|
|
|
watch(() => [props.message.format, props.message.content], ([newFormat, newContent]) => {
|
|
// TODO: if format.startsWith("stream:")
|
|
if (newFormat == "stream") {
|
|
format.value = null;
|
|
currentStreamId = parseInt(newContent);
|
|
|
|
let stream = backend.readStream(currentStreamId);
|
|
stream.on('data', (chunk) => {
|
|
if (stream.id == currentStreamId) {
|
|
content.value = stream.partialContent;
|
|
}
|
|
});
|
|
} else {
|
|
format.value = newFormat;
|
|
content.value = newContent;
|
|
currentStreamId = null;
|
|
}
|
|
}, {immediate: true})
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div :class="chatEntryClass(message)" @contextmenu.prevent="$emit('open-menu', message, logMeta)" @click="handleClick(message)">
|
|
<template v-if="message.type == 'message' || message.type == null">
|
|
<div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div>
|
|
|
|
<div v-if="format == 'markdown'" v-html="marked.parse(message.content)" class="md-content" style="white-space: collapse;"></div>
|
|
<div v-else class="content">{{content}}</div>
|
|
</template>
|
|
|
|
<template v-if="message.type == 'note'">
|
|
<div class="sender">{{message.sender}}</div>
|
|
<div class="content">{{content}}</div>
|
|
</template>
|
|
|
|
<template v-if="message.type == 'resource'">
|
|
<div class="resource-label">{{ resourceLabel(message) }}</div>
|
|
<div class="content">{{content}}</div>
|
|
</template>
|
|
|
|
<template v-if="message.type == 'log'">
|
|
<div class="log-sender">{{message.sender}}<span v-if="message.subkind"> [{{message.subkind}}]</span></div>
|
|
<div class="content">{{logContent}}</div>
|
|
<a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
|
|
</template>
|
|
|
|
<template v-if="message.type == 'thread-header'">
|
|
<div class="thread-title">
|
|
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
|
|
<span v-if="message.messageCount" class="thread-count">({{ message.messageCount }} messages)</span>
|
|
</div>
|
|
<div v-if="message.preview" class="content">
|
|
<span class="sender">{{ message.preview.sender }}:</span>
|
|
{{ message.preview.content }}
|
|
</div>
|
|
</template>
|
|
|
|
<button v-if="cancellable" @click="$emit('cancel')">Cancel</button>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.message {
|
|
width: fit-content;
|
|
max-width: 100%;
|
|
padding: 1em;
|
|
border-radius: 8px;
|
|
background-color: var(--color-background-soft);
|
|
}
|
|
|
|
/* non-messages all share basics */
|
|
.note, .log, .info, .thread, .resource {
|
|
padding: 0.6em 1.4em;
|
|
align-self: center;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* solid entries have horizontal margin */
|
|
.note, .thread-fork.thread-open, .resource {
|
|
margin-left: 2em;
|
|
margin-right: 2em;
|
|
}
|
|
|
|
/* content by default fits in the entry */
|
|
.content {
|
|
white-space: preserve;
|
|
width: fit-content;
|
|
word-wrap: anywhere;
|
|
max-width: 50em;
|
|
}
|
|
|
|
.note {
|
|
background-color: var(--color-background-soft);
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
.message.sent {
|
|
align-self: end;
|
|
margin-left: 2em;
|
|
}
|
|
.message.received {
|
|
margin-right: 2em;
|
|
}
|
|
|
|
.message .sender,
|
|
.note .sender {
|
|
font-weight: bold;
|
|
font-size: 100%;
|
|
margin-top: -5px; /* half message padding */
|
|
width: fit-content;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.log .content {
|
|
font-family: monospace;
|
|
font-size: 80%;
|
|
max-width: 70em;
|
|
}
|
|
.log .log-sender {
|
|
font-family: monospace;
|
|
font-weight: bold;
|
|
font-size: 80%;
|
|
}
|
|
.log .log-toggle {
|
|
font-family: monospace;
|
|
font-size: 80%;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.resource {
|
|
border-radius: var(--border-radius);
|
|
border: 1px dashed var(--color-border);
|
|
background-color: var(--color-background-soft);
|
|
font-size: 85%;
|
|
display: flex;
|
|
gap: 0.5em;
|
|
align-items: baseline;
|
|
}
|
|
|
|
.resource .resource-label {
|
|
font-weight: bold;
|
|
opacity: 60%;
|
|
}
|
|
|
|
.resource .content {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.thread {
|
|
width: 100%;
|
|
text-align: center;
|
|
user-select: none;
|
|
}
|
|
|
|
.thread:hover {
|
|
border-color: 1px solid var(--color-text);
|
|
}
|
|
|
|
.thread-closed {
|
|
border-bottom: 1px solid var(--color-border);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.thread-fork {
|
|
width: fit-content;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.thread-title {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.thread-count {
|
|
font-weight: normal;
|
|
opacity: 80%;
|
|
}
|
|
|
|
.thread .content {
|
|
justify-self: center;
|
|
font-style: italic;
|
|
opacity: 80%;
|
|
white-space: normal;
|
|
}
|
|
|
|
.thread:hover .content {
|
|
opacity: 100%;
|
|
}
|
|
|
|
.thread .sender {
|
|
font-style: normal;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.md-content {
|
|
white-space: collapse;
|
|
width: fit-content;
|
|
max-width: 50em;
|
|
line-height: normal;
|
|
word-wrap: anywhere;
|
|
}
|
|
|
|
</style>
|
|
|
|
<style>
|
|
/* Code blocks are generated at runtime, these styles cannot be scoped */
|
|
|
|
.code {
|
|
background-color: var(--color-surface-input);
|
|
border-radius: var(--border-radius);
|
|
margin: .5em 0;
|
|
padding: .5em;
|
|
}
|
|
|
|
.code-lang {
|
|
padding-bottom: .5em;
|
|
margin-top: -.5em;
|
|
font-weight: bold;
|
|
user-select: none;
|
|
}
|
|
|
|
.code-content {
|
|
white-space: pre;
|
|
font-family: monospace;
|
|
overflow-x: auto;
|
|
line-height: 1.6rem;
|
|
}
|
|
</style>
|