feat: improve conversation data previews (#10108)

This commit is contained in:
Soulter
2026-09-16 15:40:49 +08:00
committed by GitHub
parent 763a5ee971
commit e00442647b
6 changed files with 678 additions and 96 deletions
@@ -0,0 +1,588 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ChevronRight, Minus, Plus } from "@lucide/vue";
import MarkdownIt from "markdown-it";
import DOMPurify from "dompurify";
import { useModuleI18n } from "@/i18n/composables";
const props = defineProps<{ messages: unknown[] }>();
const { tm } = useModuleI18n("features/conversation");
const markdownEnabled = ref(true);
const fontSize = ref(13);
const settingsKey = "conversation.preview.reading";
try {
const saved = JSON.parse(localStorage.getItem(settingsKey) || "null");
if (typeof saved?.markdown === "boolean")
markdownEnabled.value = saved.markdown;
if (
Number.isInteger(saved?.fontSize) &&
saved.fontSize >= 12 &&
saved.fontSize <= 18
) {
fontSize.value = saved.fontSize;
}
} catch {
// Reading preferences are optional when storage is unavailable or invalid.
}
watch([markdownEnabled, fontSize], () => {
try {
localStorage.setItem(
settingsKey,
JSON.stringify({
markdown: markdownEnabled.value,
fontSize: fontSize.value,
}),
);
} catch {
// Keep the current preferences usable when storage is unavailable.
}
});
const markdown = new MarkdownIt({ html: false, breaks: true, linkify: true });
const knownRoles = [
"user",
"assistant",
"system",
"developer",
"tool",
"function",
];
type PreviewPart = {
kind: "text" | "image" | "data";
text: string;
html?: string;
label?: string;
};
const records = computed(() =>
props.messages
.filter(
(entry) =>
!entry ||
typeof entry !== "object" ||
!("role" in entry) ||
entry.role !== "_checkpoint",
)
.map((entry) => {
const message =
entry && typeof entry === "object" && !Array.isArray(entry)
? (entry as Record<string, unknown>)
: { content: entry };
const role = typeof message.role === "string" ? message.role : "unknown";
const parts: PreviewPart[] = [];
const content = Array.isArray(message.content)
? message.content
: [message.content];
// Preserve unsupported content as data instead of silently dropping it.
for (const item of content) {
if (item == null) continue;
if (
typeof item === "string" ||
(item?.type === "text" && typeof item.text === "string")
) {
const text = typeof item === "string" ? item : item.text;
if (text.length) {
parts.push({
kind: "text",
text,
html: markdownEnabled.value
? DOMPurify.sanitize(markdown.render(text), {
USE_PROFILES: { html: true },
})
: undefined,
});
}
} else if (
item?.type === "image_url" &&
typeof item.image_url?.url === "string" &&
/^(https?:\/\/|data:image\/(png|jpeg|jpg|gif|webp);base64,)/i.test(
item.image_url.url,
)
) {
parts.push({ kind: "image", text: item.image_url.url });
} else {
parts.push({ kind: "data", text: JSON.stringify(item, null, 2) });
}
}
if (
typeof message.reasoning_content === "string" &&
message.reasoning_content
) {
parts.push({
kind: "data",
label: tm("workspace.preview.reasoning"),
text: message.reasoning_content,
});
}
if (Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
let data = call;
const fn = call?.function;
if (fn && typeof fn.arguments === "string") {
try {
data = {
...call,
function: { ...fn, arguments: JSON.parse(fn.arguments) },
};
} catch {
// Incomplete or non-JSON arguments remain visible exactly as recorded.
}
}
parts.push({
kind: "data",
label: `${tm("workspace.preview.toolCall")}${
typeof fn?.name === "string" ? ` · ${fn.name}` : ""
}`,
text: JSON.stringify(data, null, 2),
});
}
}
if (
!parts.length &&
!Object.prototype.hasOwnProperty.call(message, "content")
) {
parts.push({ kind: "data", text: JSON.stringify(entry, null, 2) });
}
return {
role,
label: knownRoles.includes(role)
? tm(`workspace.preview.roles.${role}`)
: role === "unknown"
? tm("status.unknown")
: role,
name: typeof message.name === "string" ? message.name : "",
toolCallId:
typeof message.tool_call_id === "string" ? message.tool_call_id : "",
parts,
};
}),
);
</script>
<template>
<div
class="history-preview"
:style="{ '--preview-font-size': `${fontSize}px` }"
>
<div class="reading-toolbar">
<span class="record-count">{{
tm("workspace.preview.messageCount", { count: records.length })
}}</span>
<div class="reading-controls">
<button
type="button"
class="markdown-toggle"
:class="{ 'markdown-toggle--active': markdownEnabled }"
:aria-pressed="markdownEnabled"
:aria-label="tm('workspace.preview.markdown')"
:title="tm('workspace.preview.markdown')"
@click="markdownEnabled = !markdownEnabled"
>
<span class="toggle-indicator" aria-hidden="true" />
Markdown
</button>
<div
class="font-controls"
role="group"
:aria-label="tm('workspace.preview.fontSize')"
>
<button
type="button"
:disabled="fontSize <= 12"
:aria-label="tm('workspace.preview.smallerText')"
:title="tm('workspace.preview.smallerText')"
@click="fontSize--"
>
<Minus :size="13" aria-hidden="true" />
</button>
<output
:aria-label="tm('workspace.preview.fontSize')"
aria-live="polite"
>{{ fontSize }}px</output
>
<button
type="button"
:disabled="fontSize >= 18"
:aria-label="tm('workspace.preview.largerText')"
:title="tm('workspace.preview.largerText')"
@click="fontSize++"
>
<Plus :size="13" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div
class="history-records"
tabindex="0"
role="region"
:aria-label="tm('workspace.preview.title')"
>
<div v-if="!records.length" class="history-empty">
{{ tm("workspace.preview.empty") }}
</div>
<article
v-for="(record, index) in records"
:key="index"
class="history-record"
:class="{ 'history-record--user': record.role === 'user' }"
>
<header class="record-header">
<span class="record-index">{{
String(index + 1).padStart(2, "0")
}}</span>
<span
class="record-role"
:class="{ 'record-role--assistant': record.role === 'assistant' }"
>{{ record.label }}</span
>
<span v-if="record.name" class="record-name">{{ record.name }}</span>
<span
v-if="record.toolCallId"
class="record-call-id"
:title="record.toolCallId"
>{{ record.toolCallId }}</span
>
</header>
<div class="record-body">
<span v-if="!record.parts.length" class="empty-content">{{
tm("status.emptyContent")
}}</span>
<template v-for="(part, partIndex) in record.parts" :key="partIndex">
<div
v-if="part.kind === 'text' && markdownEnabled"
class="record-markdown"
v-html="part.html"
/>
<pre v-else-if="part.kind === 'text'" class="record-text">{{
part.text
}}</pre>
<img
v-else-if="part.kind === 'image'"
class="record-image"
:src="part.text"
:alt="tm('workspace.preview.image')"
loading="lazy"
referrerpolicy="no-referrer"
/>
<details v-else class="record-data">
<summary>
<ChevronRight :size="13" aria-hidden="true" />{{
part.label || tm("workspace.preview.structuredData")
}}
</summary>
<pre>{{ part.text }}</pre>
</details>
</template>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.history-preview {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
color: rgb(var(--v-theme-on-surface));
}
.reading-toolbar,
.reading-controls,
.font-controls,
.markdown-toggle,
.record-header {
display: flex;
align-items: center;
}
.reading-toolbar {
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
padding: 8px 16px;
border-block: 1px solid rgba(var(--v-theme-on-surface), 0.07);
font-size: 11px;
}
.record-count,
.record-index,
.record-call-id,
.empty-content,
.history-empty {
color: rgba(var(--v-theme-on-surface), 0.5);
}
.reading-controls {
gap: 10px;
}
.markdown-toggle {
gap: 6px;
min-height: 30px;
padding: 0 7px;
border-radius: 6px;
color: rgba(var(--v-theme-on-surface), 0.6);
}
.markdown-toggle--active {
color: rgb(var(--v-theme-primary));
}
.toggle-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
}
.markdown-toggle--active .toggle-indicator {
opacity: 1;
}
.font-controls {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 6px;
overflow: hidden;
}
.font-controls button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
}
.font-controls button:disabled {
opacity: 0.3;
cursor: default;
}
.font-controls output {
min-width: 38px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.reading-controls button:hover:not(:disabled) {
background: rgba(var(--v-theme-on-surface), 0.05);
}
.reading-controls button:focus-visible,
.record-data summary:focus-visible {
outline: 2px solid rgb(var(--v-theme-primary));
outline-offset: -2px;
}
.history-records {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.history-empty {
padding: 48px 16px;
text-align: center;
font-size: 13px;
}
.history-record {
padding: 16px 18px;
}
.history-record--user {
background: rgba(var(--v-theme-on-surface), 0.055);
}
.history-record + .history-record {
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.07);
}
.record-header {
gap: 8px;
margin-bottom: 9px;
font-size: 11px;
min-width: 0;
}
.record-index {
font-variant-numeric: tabular-nums;
font-size: 10px;
}
.record-role {
font-weight: 650;
flex-shrink: 0;
}
.record-role--assistant {
color: rgb(var(--v-theme-primary));
}
.record-name,
.record-call-id {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-call-id {
margin-left: auto;
font-family: monospace;
font-size: 10px;
}
.record-body {
font-size: var(--preview-font-size);
line-height: 1.65;
overflow-wrap: anywhere;
}
.record-body > * + * {
margin-top: 10px;
}
.record-text {
white-space: pre-wrap;
font: inherit;
margin: 0;
}
.record-image,
.record-markdown :deep(img) {
display: inline-block;
max-width: min(100%, 144px);
max-height: 104px;
margin: 4px 6px 4px 0;
object-fit: contain;
vertical-align: top;
border-radius: 6px;
}
.record-data {
border: 1px solid rgba(var(--v-theme-on-surface), 0.09);
border-radius: 7px;
overflow: hidden;
}
.record-data summary {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 9px;
background: rgba(var(--v-theme-on-surface), 0.025);
cursor: pointer;
font-size: 0.92em;
list-style: none;
}
.record-data summary::-webkit-details-marker {
display: none;
}
.record-data summary svg {
flex-shrink: 0;
transition: transform 0.15s;
}
.record-data[open] summary svg {
transform: rotate(90deg);
}
.record-data pre {
margin: 0;
padding: 10px;
overflow: auto;
max-height: 360px;
white-space: pre-wrap;
}
.record-data pre,
.record-markdown :deep(pre),
.record-markdown :deep(code) {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.92em;
}
.record-markdown {
overflow: hidden;
}
.record-markdown :deep(p) {
margin: 0 0 0.7em;
}
.record-markdown :deep(> :last-child) {
margin-bottom: 0;
}
.record-markdown :deep(h1),
.record-markdown :deep(h2),
.record-markdown :deep(h3),
.record-markdown :deep(h4),
.record-markdown :deep(h5),
.record-markdown :deep(h6) {
font-size: 1.08em;
font-weight: 650;
line-height: 1.5;
margin: 1em 0 0.5em;
}
.record-markdown :deep(> :first-child) {
margin-top: 0;
}
.record-markdown :deep(ul),
.record-markdown :deep(ol) {
padding-left: 1.6em;
margin-bottom: 0.7em;
}
.record-markdown :deep(li + li) {
margin-top: 0.2em;
}
.record-markdown :deep(pre) {
overflow-x: auto;
padding: 10px 12px;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.045);
margin-bottom: 0.7em;
}
.record-markdown :deep(code) {
padding: 0.1em 0.3em;
border-radius: 3px;
background: rgba(var(--v-theme-on-surface), 0.055);
}
.record-markdown :deep(pre code) {
padding: 0;
background: none;
font-size: inherit;
}
.record-markdown :deep(blockquote) {
margin: 0 0 0.7em;
padding: 2px 12px;
border-left: 3px solid rgba(var(--v-theme-on-surface), 0.18);
color: rgba(var(--v-theme-on-surface), 0.68);
}
.record-markdown :deep(table) {
display: block;
overflow-x: auto;
border-collapse: collapse;
margin-bottom: 0.7em;
}
.record-markdown :deep(th),
.record-markdown :deep(td) {
padding: 5px 9px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
}
.record-markdown :deep(th) {
background: rgba(var(--v-theme-on-surface), 0.04);
font-weight: 600;
}
.record-markdown :deep(a) {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
}
.record-markdown :deep(hr) {
border: 0;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
margin: 1em 0;
}
@media (max-width: 600px) {
.history-record {
padding: 14px;
}
.reading-toolbar {
padding-inline: 12px;
}
}
</style>
@@ -147,7 +147,24 @@
"empty": "This conversation has no messages",
"copyUmo": "Copy UMO",
"rawData": "Raw data",
"copyRawData": "Copy raw data"
"copyRawData": "Copy raw data",
"messageCount": "{count} messages",
"markdown": "Render Markdown",
"fontSize": "Text size",
"smallerText": "Decrease text size",
"largerText": "Increase text size",
"structuredData": "Structured content",
"toolCall": "Tool call",
"reasoning": "Reasoning",
"image": "Message image",
"roles": {
"user": "User",
"assistant": "Assistant",
"system": "System",
"developer": "Developer",
"tool": "Tool result",
"function": "Function result"
}
},
"actions": {
"editTitle": "Edit title",
@@ -147,7 +147,24 @@
"empty": "この会話にはまだメッセージがありません",
"copyUmo": "UMO をコピー",
"rawData": "生データ",
"copyRawData": "生データをコピー"
"copyRawData": "生データをコピー",
"messageCount": "{count} 件のメッセージ",
"markdown": "Markdown を表示",
"fontSize": "文字サイズ",
"smallerText": "文字を小さくする",
"largerText": "文字を大きくする",
"structuredData": "構造化コンテンツ",
"toolCall": "ツール呼び出し",
"reasoning": "思考過程",
"image": "メッセージ画像",
"roles": {
"user": "ユーザー",
"assistant": "アシスタント",
"system": "システム",
"developer": "開発者",
"tool": "ツール結果",
"function": "関数結果"
}
},
"actions": {
"editTitle": "タイトルを編集",
@@ -147,7 +147,24 @@
"empty": "В этом диалоге нет сообщений",
"copyUmo": "Копировать UMO",
"rawData": "Исходные данные",
"copyRawData": "Копировать исходные данные"
"copyRawData": "Копировать исходные данные",
"messageCount": "Сообщений: {count}",
"markdown": "Отображать Markdown",
"fontSize": "Размер текста",
"smallerText": "Уменьшить текст",
"largerText": "Увеличить текст",
"structuredData": "Структурированные данные",
"toolCall": "Вызов инструмента",
"reasoning": "Рассуждения",
"image": "Изображение в сообщении",
"roles": {
"user": "Пользователь",
"assistant": "Ассистент",
"system": "Система",
"developer": "Разработчик",
"tool": "Результат инструмента",
"function": "Результат функции"
}
},
"actions": {
"editTitle": "Изменить заголовок",
@@ -147,7 +147,24 @@
"empty": "这个对话还没有消息",
"copyUmo": "复制 UMO",
"rawData": "原始数据",
"copyRawData": "复制原始数据"
"copyRawData": "复制原始数据",
"messageCount": "{count} 条消息",
"markdown": "渲染 Markdown",
"fontSize": "正文字号",
"smallerText": "缩小字号",
"largerText": "放大字号",
"structuredData": "结构化内容",
"toolCall": "工具调用",
"reasoning": "思考过程",
"image": "消息图片",
"roles": {
"user": "用户",
"assistant": "助手",
"system": "系统",
"developer": "开发者",
"tool": "工具结果",
"function": "函数结果"
}
},
"actions": {
"editTitle": "编辑标题",
@@ -27,7 +27,7 @@ import {
X,
} from "@lucide/vue";
import { conversationApi } from "@/api/v1";
import MessageList from "@/components/chat/MessageList.vue";
import ConversationHistoryPreview from "@/components/conversation/ConversationHistoryPreview.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { copyToClipboard } from "@/utils/clipboard";
@@ -113,11 +113,10 @@ const listRequestId = ref(0);
const selectedByKey = ref<Record<string, Conversation>>({});
const activeConversation = ref<Conversation | null>(null);
const conversationHistory = ref<any[]>([]);
const conversationHistory = ref<unknown[]>([]);
const previewLoading = ref(false);
const previewRequestId = ref(0);
const previewPageScroll = ref(0);
const previewMessagesRef = ref<HTMLElement | null>(null);
const rawDataDialog = ref(false);
const rawHistoryText = ref("");
@@ -220,65 +219,6 @@ const messageTypes = computed(() => [
{ label: tm("messageTypes.group"), value: "GroupMessage" },
]);
const formattedMessages = computed(() => {
const toolResultsById: Record<string, unknown> = {};
for (const message of conversationHistory.value) {
if (message?.role === "tool" && message.tool_call_id) {
toolResultsById[message.tool_call_id] = message.content;
}
}
return conversationHistory.value
.filter(
(message) => message?.role === "user" || message?.role === "assistant",
)
.map((message) => {
const parts: any[] = [];
const content = message.content;
if (typeof content === "string" && content.trim()) {
parts.push({ type: "plain", text: content });
} else if (Array.isArray(content)) {
for (const item of content) {
if (item?.type === "text" && item.text) {
parts.push({ type: "plain", text: item.text });
} else if (item?.type === "image_url" && item.image_url?.url) {
parts.push({ type: "image", embedded_url: item.image_url.url });
}
}
} else if (content && typeof content === "object") {
const text = Object.values(content)
.filter((value) => typeof value === "string" && value.trim())
.join("\n");
if (text) parts.push({ type: "plain", text });
}
if (
message.role === "assistant" &&
Array.isArray(message.tool_calls) &&
message.tool_calls.length
) {
parts.push({
type: "tool_call",
tool_calls: message.tool_calls.map((toolCall: any) => ({
id: toolCall.id,
name: toolCall.function?.name || toolCall.name,
args: toolCall.function?.arguments ?? toolCall.arguments,
result: toolResultsById[toolCall.id],
ts: 0,
finished_ts: 1,
})),
});
}
return {
content: {
type: message.role === "user" ? "user" : "bot",
message: parts.length ? parts : [{ type: "plain", text: "" }],
},
};
});
});
watch([keyword, umoQuery], () => {
listAbortController.value?.abort();
scheduleFetch();
@@ -543,9 +483,12 @@ async function openConversation(item: Conversation) {
const detail = response.data.data || {};
activeConversation.value = { ...item, ...detail };
const history = detail.history || [];
conversationHistory.value = Array.isArray(history)
? history
: JSON.parse(history || "[]");
const parsedHistory =
typeof history === "string" ? JSON.parse(history) : history;
if (!Array.isArray(parsedHistory)) {
throw new Error(tm("messages.historyError"));
}
conversationHistory.value = parsedHistory;
} catch (error: any) {
if (requestId !== previewRequestId.value) return;
conversationHistory.value = [];
@@ -558,11 +501,6 @@ async function openConversation(item: Conversation) {
} finally {
if (requestId === previewRequestId.value) {
previewLoading.value = false;
await nextTick();
if (requestId === previewRequestId.value && previewMessagesRef.value) {
previewMessagesRef.value.scrollTop =
previewMessagesRef.value.scrollHeight;
}
}
}
}
@@ -1332,19 +1270,17 @@ function changePage(nextPage: number) {
</v-btn>
</div>
<div ref="previewMessagesRef" class="preview-messages">
<div v-if="previewLoading" class="panel-state">
<div v-if="previewLoading" class="preview-messages">
<div class="panel-state">
<v-progress-circular indeterminate size="28" width="3" />
<span>{{ tm("workspace.preview.loading") }}</span>
</div>
<div
v-else-if="!formattedMessages.length"
class="panel-state panel-state--empty"
>
<span>{{ tm("workspace.preview.empty") }}</span>
</div>
<MessageList v-else :messages="formattedMessages" :is-dark="isDark" />
</div>
<ConversationHistoryPreview
v-else
:key="conversationKey(activeConversation)"
:messages="conversationHistory"
/>
</section>
</main>
@@ -1581,7 +1517,9 @@ function changePage(nextPage: number) {
height: 28px;
justify-content: center;
padding: 0;
transition: background-color 0.16s ease, color 0.16s ease;
transition:
background-color 0.16s ease,
color 0.16s ease;
width: 28px;
}
@@ -2042,18 +1980,6 @@ function changePage(nextPage: number) {
padding: 2px 6px 10px;
}
.preview-messages :deep(.messages-list) {
padding: 12px 8px 20px;
}
.preview-messages :deep(.message-row) {
margin-bottom: 14px;
}
.preview-messages :deep(.bot-avatar) {
display: none;
}
.raw-data-card {
max-height: min(82dvh, 760px);
}