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>