Adjust outline user item background (#836)

* feat:新增大纲功能

* Adjust outline user item background

---------

Co-authored-by: chenxusheng <chenxusheng@haizhi.com>
This commit is contained in:
ekko
2026-05-19 08:25:01 +08:00
committed by GitHub
parent bbfd818106
commit d2cbce2f13
14 changed files with 452 additions and 3 deletions
@@ -24,6 +24,7 @@ import ConversationMonitorPane from "./ConversationMonitorPane.vue";
import MessageList from "./MessageList.vue";
import SessionListItem from "./SessionListItem.vue";
import DrawerPanel from "./DrawerPanel.vue";
import OutlinePanel from "./OutlinePanel.vue";
const chatStore = useChatStore();
const appStore = useAppStore();
@@ -33,6 +34,7 @@ const { t } = useI18n();
const showDrawer = ref(false);
const drawerActiveTab = ref<"terminal" | "files">("files");
const showOutline = ref(false);
const currentMode = ref<"chat" | "live">("chat");
@@ -988,6 +990,30 @@ async function handleSessionModelCustomSubmit() {
<div class="header-actions">
<!-- chat/live mode toggle hidden -->
<template v-if="currentMode === 'chat'">
<NTooltip trigger="hover">
<template #trigger>
<NButton
quaternary
size="small"
@click="showOutline = !showOutline"
circle
>
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</template>
</NButton>
</template>
{{ t("chat.outlineTitle") }}
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton
@@ -1042,7 +1068,12 @@ async function handleSessionModelCustomSubmit() {
</header>
<template v-if="currentMode === 'chat'">
<MessageList />
<div class="chat-content-wrapper">
<div class="chat-main-content">
<MessageList />
</div>
<OutlinePanel v-if="showOutline" :messages="chatStore.messages" />
</div>
<div v-if="visibleApproval" class="approval-bar">
<div class="approval-icon" aria-hidden="true">
<svg
@@ -1637,6 +1668,21 @@ async function handleSessionModelCustomSubmit() {
min-width: 0;
}
.chat-content-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.chat-main-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-header {
display: flex;
align-items: center;
@@ -19,8 +19,10 @@ import { downloadFile, getDownloadUrl } from '@/api/hermes/download'
const props = withDefaults(defineProps<{
content: string
mentionNames?: string[]
headingIdPrefix?: string
}>(), {
mentionNames: () => [],
headingIdPrefix: '',
})
const { t } = useI18n()
@@ -68,6 +70,27 @@ function normalizeLocalFilePath(path: string): string {
const renderedHtml = computed(() => {
let html = md.render(repairNestedMarkdownFences(props.content))
// Add IDs to headings for anchor links
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
let headingCounter = 0
// Match any h1-h6 tags, with or without attributes
html = html.replace(/<(h[1-6])([^>]*)>/g, (match, tag, attrs) => {
headingCounter++
const id = `${prefix}heading-${headingCounter}`
// Check if id attribute already exists
if (attrs.includes('id=')) {
// Replace existing id
return match.replace(/id="[^"]*"/, `id="${id}"`).replace(/id='[^']*'/, `id="${id}"`)
}
// Add new id
if (attrs.trim() === '') {
return `<${tag} id="${id}">`
}
return `<${tag} ${attrs.trim()} id="${id}">`
})
// Replace image src paths with download URLs
html = html.replace(/\bsrc=(["'])([^"']+)\1/g, (match, quote, path) => {
if (!isLocalFilePath(path)) return match
@@ -27,11 +27,13 @@ const JSON_MAX_KEYS_PER_OBJECT = 50;
const JSON_MAX_ITEMS_PER_ARRAY = 50;
const JSON_TRUNCATED_KEY = "__truncated__";
const props = defineProps<{ message: Message; highlight?: boolean }>();
const props = defineProps<{ message: Message; highlight?: boolean; headingIdPrefix?: string }>();
const { t } = useI18n();
const toast = useMessage();
const isSystem = computed(() => props.message.role === "system");
const effectiveHeadingIdPrefix = computed(() => props.headingIdPrefix || `msg-${props.message.id}`);
const isCommandMessage = computed(() => props.message.role === "command" || props.message.systemType === "command");
const isCommandError = computed(() => props.message.role === "command" && props.message.systemType === "error");
const isStatusCommand = computed(() => isCommandMessage.value && props.message.commandAction === "status");
@@ -868,6 +870,7 @@ onBeforeUnmount(() => {
<MarkdownRenderer
v-if="parsedThinking.body && message.role === 'assistant'"
:content="parsedThinking.body"
:heading-id-prefix="effectiveHeadingIdPrefix"
/>
<!-- Render user message content -->
@@ -915,6 +918,7 @@ onBeforeUnmount(() => {
<MarkdownRenderer
v-if="message.role === 'assistant' && message.content && !parsedThinking.body"
:content="message.content"
:heading-id-prefix="effectiveHeadingIdPrefix"
/>
<!-- Render system message content -->
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Message } from '@/stores/hermes/chat'
interface OutlineItem {
id: string
type: 'user' | 'outline'
content: string
messageId: string
level: number
anchorId: string
}
const props = defineProps<{
messages: Message[]
}>()
const { t } = useI18n()
function extractAllHeadings(text: string, messageId: string): OutlineItem[] {
const items: OutlineItem[] = []
let cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
const lines = cleanedText.split('\n')
let headingIndex = 0
for (const line of lines) {
const trimmed = line.trim()
const h1Match = trimmed.match(/^#\s+(.+)/)
const h2Match = trimmed.match(/^##\s+(.+)/)
const h3Match = trimmed.match(/^###\s+(.+)/)
if (h1Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h1Match[1].trim(),
messageId,
level: 1,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
} else if (h2Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h2Match[1].trim(),
messageId,
level: 2,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
} else if (h3Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h3Match[1].trim(),
messageId,
level: 3,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
}
}
return items
}
function extractUserQuestion(text: string): string {
const cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
const firstLine = cleanedText.split('\n')[0] || ''
if (firstLine.length > 50) {
return firstLine.slice(0, 50) + '...'
}
return firstLine || t('chat.outlineUserQuestion')
}
const outlineItems = computed<OutlineItem[]>(() => {
const items: OutlineItem[] = []
let i = 0
const filteredMessages = props.messages.filter(m => m.role === 'user' || m.role === 'assistant')
while (i < filteredMessages.length) {
const msg = filteredMessages[i]
if (msg.role === 'user') {
items.push({
id: `user-${msg.id}`,
type: 'user',
content: extractUserQuestion(msg.content || ''),
messageId: msg.id,
level: 0,
anchorId: `message-${msg.id}`
})
i++
while (i < filteredMessages.length && filteredMessages[i].role !== 'assistant') {
i++
}
if (i < filteredMessages.length) {
const assistantMsg = filteredMessages[i]
const headings = extractAllHeadings(assistantMsg.content || '', assistantMsg.id)
items.push(...headings)
}
}
i++
}
return items
})
function scrollToTarget(anchorId: string) {
console.log('Attempting to scroll to anchor:', anchorId)
nextTick(() => {
const el = document.getElementById(anchorId)
console.log('Found element:', el)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
// Debug: log all heading elements with IDs
console.log('All heading elements on page:')
document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
console.log(' -', el.id, ':', el.textContent?.slice(0, 50))
})
}
})
}
</script>
<template>
<div class="outline-panel">
<div class="outline-header">
<span class="outline-title">{{ t('chat.outlineTitle') }}</span>
</div>
<div class="outline-content">
<template v-if="outlineItems.length > 0">
<template v-for="item in outlineItems" :key="item.id">
<div
v-if="item.type === 'user'"
class="outline-item user-item"
@click="scrollToTarget(item.anchorId)"
>
<div class="user-question">
<span class="q-label">Q:</span>
<span class="q-text">{{ item.content }}</span>
</div>
</div>
<div
v-else
class="outline-item outline-heading-item"
:class="`level-${item.level}`"
@click="scrollToTarget(item.anchorId)"
>
<div class="heading-item">
<span class="heading-text">{{ item.content }}</span>
</div>
</div>
</template>
</template>
<div v-else class="outline-empty">{{ t('chat.outlineEmpty') }}</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.outline-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: $bg-card;
border-left: 1px solid $border-color;
width: 280px;
flex-shrink: 0;
@media (max-width: $breakpoint-mobile) {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(280px, 86vw);
z-index: 8;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
}
}
.outline-header {
padding: 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.outline-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.outline-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.outline-item {
margin-bottom: 4px;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.user-item {
margin-bottom: 6px;
}
.user-question {
background-color: $bg-secondary;
color: $text-primary;
padding: 8px 12px;
border-radius: 8px;
display: flex;
align-items: flex-start;
gap: 6px;
.dark & {
background-color: $bg-input;
}
.q-label {
font-weight: 600;
flex-shrink: 0;
font-size: 13px;
line-height: 1.4;
}
.q-text {
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
}
.outline-heading-item {
&.level-1 {
padding-left: 0;
}
&.level-2 {
padding-left: 12px;
}
&.level-3 {
padding-left: 24px;
}
}
.heading-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
.dark & {
background-color: rgba(255, 255, 255, 0.06);
}
}
.level-1 & {
.heading-marker {
color: $text-primary;
font-weight: 600;
}
.heading-text {
color: $text-primary;
font-weight: 500;
}
}
.level-2 & {
.heading-marker {
color: $text-secondary;
}
.heading-text {
color: $text-secondary;
}
}
.level-3 & {
.heading-marker {
color: $text-muted;
}
.heading-text {
color: $text-muted;
font-size: 12px;
}
}
}
.heading-text {
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
.outline-empty {
text-align: center;
color: $text-muted;
font-size: 13px;
padding: 20px 0;
}
</style>
+3
View File
@@ -120,6 +120,9 @@ export default {
contextEditSuccess: 'Kontextlänge aktualisiert',
contextEditFailed: 'Aktualisierung fehlgeschlagen',
emptyState: 'Starten Sie eine Konversation mit Hermes Agent',
outlineTitle: 'Konversationsübersicht',
outlineEmpty: 'Kein Konversationsinhalt',
outlineUserQuestion: 'Benutzerfrage',
inputPlaceholder: 'Nachricht eingeben... (Enter zum Senden, Shift+Enter fur neue Zeile)',
slashCommandArgs: {
message: '<Nachricht>',
+3
View File
@@ -133,6 +133,9 @@ export default {
contextEditFailed: 'Update failed',
emptyState: 'Start a conversation with Hermes Agent',
cliEmptyState: 'Start a CLI chat session',
outlineTitle: 'Conversation Outline',
outlineEmpty: 'No conversation content',
outlineUserQuestion: 'User question',
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
slashCommandArgs: {
message: '<message>',
+3
View File
@@ -120,6 +120,9 @@ export default {
contextEditSuccess: 'Longitud del contexto actualizada',
contextEditFailed: 'Error en la actualización',
emptyState: 'Inicia una conversacion con Hermes Agent',
outlineTitle: 'Esquema de la conversación',
outlineEmpty: 'Sin contenido de conversación',
outlineUserQuestion: 'Pregunta del usuario',
inputPlaceholder: 'Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva linea)',
slashCommandArgs: {
message: '<mensaje>',
+3
View File
@@ -120,6 +120,9 @@ export default {
contextEditSuccess: 'Longueur du contexte mise à jour',
contextEditFailed: 'Échec de la mise à jour',
emptyState: 'Demarrer une conversation avec Hermes Agent',
outlineTitle: 'Plan de la conversation',
outlineEmpty: 'Aucun contenu de conversation',
outlineUserQuestion: 'Question utilisateur',
inputPlaceholder: 'Tapez un message... (Entree pour envoyer, Shift+Entree pour un saut de ligne)',
slashCommandArgs: {
message: '<message>',
+3
View File
@@ -120,6 +120,9 @@ export default {
contextEditSuccess: 'コンテキスト長を更新しました',
contextEditFailed: '更新に失敗しました',
emptyState: 'Hermes Agent と会話を開始しましょう',
outlineTitle: '会話アウトライン',
outlineEmpty: '会話内容はありません',
outlineUserQuestion: 'ユーザーの質問',
inputPlaceholder: 'メッセージを入力... (Enter で送信、Shift+Enter で改行)',
slashCommandArgs: {
message: '<メッセージ>',
+3
View File
@@ -120,6 +120,9 @@ export default {
contextEditSuccess: '컨텍스트 길이가 업데이트되었습니다',
contextEditFailed: '업데이트 실패',
emptyState: 'Hermes Agent와 대화를 시작하세요',
outlineTitle: '대화 개요',
outlineEmpty: '대화 내용이 없습니다',
outlineUserQuestion: '사용자 질문',
inputPlaceholder: '메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)',
slashCommandArgs: {
message: '<메시지>',
+3
View File
@@ -120,6 +120,9 @@ export default {
contextEditSuccess: 'Tamanho do contexto atualizado',
contextEditFailed: 'Falha na atualização',
emptyState: 'Inicie uma conversa com o Hermes Agent',
outlineTitle: 'Esboço da conversa',
outlineEmpty: 'Nenhum conteúdo da conversa',
outlineUserQuestion: 'Pergunta do usuário',
inputPlaceholder: 'Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)',
slashCommandArgs: {
message: '<mensagem>',
@@ -132,6 +132,9 @@ export default {
contextEditSuccess: '上下文長度已更新',
contextEditFailed: '更新失敗',
emptyState: '開始與 Hermes Agent 對話',
outlineTitle: '會話大綱',
outlineEmpty: '暫無會話內容',
outlineUserQuestion: '使用者問題',
inputPlaceholder: '輸入訊息... (Enter 發送,Shift+Enter 換行)',
slashCommandArgs: {
message: '<訊息>',
+3
View File
@@ -133,6 +133,9 @@ export default {
contextEditFailed: '更新失败',
emptyState: '开始与 Hermes Agent 对话',
cliEmptyState: '开始 CLI 对话',
outlineTitle: '会话大纲',
outlineEmpty: '暂无会话内容',
outlineUserQuestion: '用户问题',
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
slashCommandArgs: {
message: '<消息>',
@@ -10,6 +10,7 @@ import { getSourceLabel } from '@/shared/session-display'
import { copyToClipboard } from '@/utils/clipboard'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
import { fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
const chatStore = useChatStore()
@@ -26,6 +27,7 @@ const hermesSessionsLoaded = ref(false)
// History page's own selected session (independent from chatStore)
const historySessionId = ref<string | null>(null)
const historySession = ref<Session | null>(null)
const showOutline = ref(false)
async function loadHermesSessions() {
if (hermesSessionsLoading.value) return
@@ -340,6 +342,16 @@ async function copySessionId(id?: string) {
<span v-if="historySession?.workspace" class="workspace-badge" :title="historySession.workspace">📁 {{ historySession.workspace.split('/').pop() || historySession.workspace }}</span>
</div>
<div class="header-actions">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="small" @click="showOutline = !showOutline" circle>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
</template>
</NButton>
</template>
{{ t('chat.outlineTitle') }}
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="small" @click="copySessionId()" circle>
@@ -353,7 +365,12 @@ async function copySessionId(id?: string) {
</div>
</header>
<HistoryMessageList :session="historySession" />
<div class="history-content-wrapper">
<div class="history-main-content">
<HistoryMessageList :session="historySession" />
</div>
<OutlinePanel v-if="showOutline && historySession" :messages="historySession.messages || []" />
</div>
</div>
</div>
</template>
@@ -367,6 +384,21 @@ async function copySessionId(id?: string) {
position: relative;
}
.history-content-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.history-main-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
}
.session-list {
width: 220px;
border-right: 1px solid $border-color;