Adjust outline user item background (#836)
* feat:新增大纲功能 * Adjust outline user item background --------- Co-authored-by: chenxusheng <chenxusheng@haizhi.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user