Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions src/renderer/src/components/markdown/MarkdownRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,22 @@ import { useUiSettingsStore } from '@/stores/uiSettingsStore'
const props = defineProps<{
content: string
debug?: boolean
messageId?: string
threadId?: string
}>()
const themeStore = useThemeStore()
const uiSettingsStore = useUiSettingsStore()
// 组件映射表
const artifactStore = useArtifactStore()
// 生成唯一的 message ID 和 thread ID,用于 MarkdownRenderer
const messageId = `artifact-msg-${nanoid()}`
const threadId = `artifact-thread-${nanoid()}`
const fallbackMessageId = `artifact-msg-${nanoid()}`
const fallbackThreadId = `artifact-thread-${nanoid()}`
const referenceStore = useReferenceStore()
const newAgentPresenter = usePresenter('newAgentPresenter')
const referenceNode = ref<HTMLElement | null>(null)
const debouncedContent = ref(props.content)
const effectiveMessageId = computed(() => props.messageId ?? fallbackMessageId)
const effectiveThreadId = computed(() => props.threadId ?? fallbackThreadId)
const codeBlockMonacoOption = computed(() => ({
fontFamily: uiSettingsStore.formattedCodeFontFamily
}))
Expand All @@ -63,11 +67,11 @@ setCustomComponents({
reference: (_props) =>
h(ReferenceNode, {
..._props,
messageId,
threadId,
messageId: effectiveMessageId.value,
threadId: effectiveThreadId.value,
onClick() {
// TODO: remove this temporary fallback after search result loading is fully unified.
newAgentPresenter.getSearchResults(_props.messageId ?? '').then((results) => {
newAgentPresenter.getSearchResults(effectiveMessageId.value).then((results) => {
const index = parseInt(_props.node.id)
if (index < results.length) {
window.open(results[index - 1].url, '_blank', 'noopener,noreferrer')
Expand All @@ -77,7 +81,7 @@ setCustomComponents({
onMouseEnter() {
console.log('Mouse entered')
referenceStore.hideReference()
newAgentPresenter.getSearchResults(_props.messageId ?? '').then((results) => {
newAgentPresenter.getSearchResults(effectiveMessageId.value).then((results) => {
const index = parseInt(_props.node.id)
if (index - 1 < results.length && referenceNode.value) {
referenceStore.showReference(
Expand Down Expand Up @@ -120,8 +124,8 @@ setCustomComponents({
content: v.node.code,
status: 'loaded'
},
messageId,
threadId,
effectiveMessageId.value,
effectiveThreadId.value,
{ force: true }
)
}
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/src/components/message/MessageBlockContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
<template>
<template v-for="(part, index) in processedContent" :key="index">
<!-- 使用结构化渲染器替代 v-html -->
<MarkdownRenderer v-if="part.type === 'text'" :content="part.content" :loading="part.loading" />
<MarkdownRenderer
v-if="part.type === 'text'"
:content="part.content"
:loading="part.loading"
:message-id="messageId"
:thread-id="threadId"
/>

<ArtifactThinking v-else-if="part.type === 'thinking' && part.loading" />
<div v-else-if="part.type === 'artifact' && part.artifact" class="my-1">
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/src/components/sidepanel/WorkspacePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -262,14 +262,19 @@ watch(
return
}

const exists = items.some(
const existsInArtifactItems = items.some(
(item) =>
item.threadId === context.threadId &&
item.messageId === context.messageId &&
item.artifactId === context.artifactId
)

if (!exists) {
const matchesCurrentArtifact =
artifactStore.currentArtifact?.id === context.artifactId &&
artifactStore.currentMessageId === context.messageId &&
artifactStore.currentThreadId === context.threadId

if (!existsInArtifactItems && !matchesCurrentArtifact) {
sidepanelStore.clearArtifact(props.sessionId)
}
},
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/components/sidepanel/WorkspaceViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@

<WorkspacePreviewPane
v-else-if="paneKind === 'preview' && previewKind"
:session-id="props.sessionId"
:preview-kind="previewKind"
:artifact="previewArtifact"
:file-preview="previewFilePreview"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
data-testid="workspace-preview-markdown"
>
<div class="min-h-full px-4 py-4">
<MarkdownRenderer :content="resolvedContent" />
<MarkdownRenderer
:content="resolvedContent"
:message-id="previewSourceId"
:thread-id="props.sessionId"
/>
</div>
</div>

Expand Down Expand Up @@ -91,6 +95,7 @@ import MermaidArtifact from '@/components/artifacts/MermaidArtifact.vue'
import ReactArtifact from '@/components/artifacts/ReactArtifact.vue'

const props = defineProps<{
sessionId?: string
previewKind: WorkspacePreviewKind
artifact?: ArtifactState | null
filePreview?: WorkspaceFilePreview | null
Expand Down Expand Up @@ -137,6 +142,7 @@ const fileBlock = computed(() => {

const resolvedBlock = computed(() => artifactBlock.value ?? fileBlock.value)
const resolvedContent = computed(() => props.artifact?.content ?? props.filePreview?.content ?? '')
const previewSourceId = computed(() => props.artifact?.id ?? props.filePreview?.path)
const resolvedTitle = computed(
() => props.artifact?.title ?? props.filePreview?.name ?? t('artifacts.preview')
)
Expand Down
178 changes: 178 additions & 0 deletions test/renderer/components/MarkdownRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { showArtifactMock, getSearchResultsMock, hideReferenceMock, showReferenceMock, nanoidMock } =
vi.hoisted(() => ({
showArtifactMock: vi.fn(),
getSearchResultsMock: vi.fn().mockResolvedValue([]),
hideReferenceMock: vi.fn(),
showReferenceMock: vi.fn(),
nanoidMock: vi.fn()
}))

const setup = async (props: Record<string, unknown> = {}) => {
vi.resetModules()

let customComponents: Record<string, (...args: any[]) => any> = {}

vi.doMock('nanoid', () => ({
nanoid: nanoidMock
}))

vi.doMock('@/stores/artifact', () => ({
useArtifactStore: () => ({
showArtifact: showArtifactMock
})
}))

vi.doMock('@/stores/reference', () => ({
useReferenceStore: () => ({
hideReference: hideReferenceMock,
showReference: showReferenceMock
})
}))

vi.doMock('@/stores/theme', () => ({
useThemeStore: () => ({
isDark: false
})
}))

vi.doMock('@/stores/uiSettingsStore', () => ({
useUiSettingsStore: () => ({
formattedCodeFontFamily: 'monospace'
})
}))

vi.doMock('@/composables/usePresenter', () => ({
usePresenter: () => ({
getSearchResults: getSearchResultsMock
})
}))

vi.doMock('markstream-vue', () => {
const previewPayload = {
id: 'preview-artifact',
artifactType: 'text/html',
artifactTitle: 'HTML Preview',
language: 'html',
node: {
code: '<h1>Hello</h1>'
}
}

const NodeRenderer = defineComponent({
name: 'NodeRenderer',
setup() {
return () =>
customComponents.code_block?.({
node: {
language: 'html',
code: '<h1>Hello</h1>',
raw: '<h1>Hello</h1>'
}
}) ?? h('div')
}
})

const CodeBlockNode = defineComponent({
name: 'CodeBlockNode',
emits: ['previewCode'],
mounted() {
this.$emit('previewCode', previewPayload)
},
render() {
return h('div', { 'data-testid': 'code-block-node' })
}
})

const ReferenceNode = defineComponent({
name: 'ReferenceNode',
render() {
return h('div')
}
})

const MermaidBlockNode = defineComponent({
name: 'MermaidBlockNode',
render() {
return h('div')
}
})

return {
default: NodeRenderer,
NodeRenderer,
CodeBlockNode,
ReferenceNode,
MermaidBlockNode,
setCustomComponents: (components: Record<string, (...args: any[]) => any>) => {
customComponents = components
}
}
})

const MarkdownRenderer = (await import('@/components/markdown/MarkdownRenderer.vue')).default
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```html\n<h1>Hello</h1>\n```',
...props
}
})

await flushPromises()

return { wrapper }
}

describe('MarkdownRenderer', () => {
beforeEach(() => {
showArtifactMock.mockReset()
getSearchResultsMock.mockReset()
getSearchResultsMock.mockResolvedValue([])
hideReferenceMock.mockReset()
showReferenceMock.mockReset()
nanoidMock.mockReset()
nanoidMock.mockReturnValueOnce('fallback-message').mockReturnValueOnce('fallback-thread')
})

it('uses the provided message and thread ids for HTML preview artifacts', async () => {
await setup({
messageId: 'message-1',
threadId: 'thread-1'
})

expect(showArtifactMock).toHaveBeenCalledWith(
{
id: 'preview-artifact',
type: 'text/html',
title: 'HTML Preview',
language: 'html',
content: '<h1>Hello</h1>',
status: 'loaded'
},
'message-1',
'thread-1',
{ force: true }
)
})

it('falls back to local ids when no message or thread ids are provided', async () => {
await setup()

expect(showArtifactMock).toHaveBeenCalledWith(
{
id: 'preview-artifact',
type: 'text/html',
title: 'HTML Preview',
language: 'html',
content: '<h1>Hello</h1>',
status: 'loaded'
},
'artifact-msg-fallback-message',
'artifact-thread-fallback-thread',
{ force: true }
)
})
})
33 changes: 33 additions & 0 deletions test/renderer/components/WorkspacePanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ describe('WorkspacePanel', () => {
sessionState.sections.files = true
sessionState.sections.git = true
sessionState.sections.artifacts = true
artifactStore.currentArtifact = null
artifactStore.currentMessageId = null
artifactStore.currentThreadId = null

showArtifactMock.mockReset()
toggleSectionMock.mockReset()
Expand Down Expand Up @@ -484,4 +487,34 @@ describe('WorkspacePanel', () => {

wrapper.unmount()
})

it('keeps the current temporary artifact selection when it is not part of artifact items', async () => {
sessionState.selectedArtifactContext = {
threadId: 's1',
messageId: 'C:/repo/README.md',
artifactId: 'temp-html-preview'
}
artifactStore.currentArtifact = {
id: 'temp-html-preview',
type: 'text/html',
title: 'HTML Preview',
content: '<h1>Hello</h1>',
status: 'loaded'
}
artifactStore.currentMessageId = 'C:/repo/README.md'
artifactStore.currentThreadId = 's1'

const wrapper = mount(WorkspacePanel, {
props: {
sessionId: 's1',
workspacePath: 'C:/repo'
}
})

await flushPromises()

expect(clearArtifactMock).not.toHaveBeenCalled()

wrapper.unmount()
})
})
Loading
Loading