echoctl 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/.vitepress/config.mts +38 -0
- package/docs/.vitepress/echo-sidebar.mts +52 -0
- package/docs/.vitepress/theme/Layout.vue +28 -0
- package/docs/.vitepress/theme/components/EchoArticleActions.vue +235 -0
- package/docs/.vitepress/theme/components/EchoChatBubbles.vue +153 -0
- package/docs/.vitepress/theme/components/EchoClaudeImportBanner.vue +335 -0
- package/docs/.vitepress/theme/components/EchoCommentChain.vue +79 -0
- package/docs/.vitepress/theme/components/EchoCommentNode.vue +126 -0
- package/docs/.vitepress/theme/components/EchoCommentReplies.vue +95 -0
- package/docs/.vitepress/theme/components/EchoGlobalControls.vue +91 -0
- package/docs/.vitepress/theme/components/EchoLegacyRecovery.vue +178 -0
- package/docs/.vitepress/theme/components/EchoLiveSession.vue +82 -0
- package/docs/.vitepress/theme/components/EchoProjectTabs.vue +132 -0
- package/docs/.vitepress/theme/components/EchoSearchLanding.vue +215 -0
- package/docs/.vitepress/theme/components/EchoSelectionComment.vue +129 -0
- package/docs/.vitepress/theme/components/EchoTagsPage.vue +301 -0
- package/docs/.vitepress/theme/custom.css +964 -0
- package/docs/.vitepress/theme/index.ts +36 -0
- package/docs/.vitepress/theme/lib/echo-api.ts +298 -0
- package/docs/.vitepress/theme/lib/echo-heartbeat.ts +34 -0
- package/docs/.vitepress/theme/lib/useEchoStatus.ts +59 -0
- package/docs/.vitepress/theme/lib/useProjectFilter.ts +42 -0
- package/docs/index.md +89 -0
- package/package.json +13 -4
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defineConfig } from 'vitepress'
|
|
2
|
+
import { articleSidebar } from './echo-sidebar.mts'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
title: 'Echo 知识库',
|
|
6
|
+
description: '本地优先的 AI 对话知识论坛',
|
|
7
|
+
|
|
8
|
+
themeConfig: {
|
|
9
|
+
nav: [
|
|
10
|
+
{ text: '首页', link: '/' },
|
|
11
|
+
{ text: '文章', link: '/articles/', activeMatch: '^/articles/' },
|
|
12
|
+
{ text: '标签', link: '/tags/', activeMatch: '^/tags/' },
|
|
13
|
+
],
|
|
14
|
+
|
|
15
|
+
sidebar: {
|
|
16
|
+
'/articles/': articleSidebar,
|
|
17
|
+
'/live/': articleSidebar,
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
socialLinks: [],
|
|
21
|
+
|
|
22
|
+
search: {
|
|
23
|
+
provider: 'local',
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
footer: {
|
|
27
|
+
message: '基于 Echo 管线生成',
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
lastUpdated: {
|
|
31
|
+
text: '最后更新',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
markdown: {
|
|
36
|
+
// Preserve HTML comments (turn markers) in output for custom theme processing
|
|
37
|
+
},
|
|
38
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const articleSidebar = [
|
|
2
|
+
{
|
|
3
|
+
text: '文章列表',
|
|
4
|
+
items: [
|
|
5
|
+
{ text: '全部文章', link: '/articles/' },
|
|
6
|
+
{
|
|
7
|
+
text: '最近文章',
|
|
8
|
+
collapsed: true,
|
|
9
|
+
items: [
|
|
10
|
+
{ text: "/claude-mem:learn-codebase", link: '/articles/generated/session-2026-05-25' },
|
|
11
|
+
{ text: "/gstack 做得不错,不过你都测试和review了吗?", link: '/articles/generated/session-2026-05-20' },
|
|
12
|
+
{ text: "/office-hours 针对 @ECHO_STATUS.md 中的“产品表层设计讨论 (2026-05-24)...", link: '/articles/generated/session-2026-05-24' },
|
|
13
|
+
{ text: "/superpowers:using-superpowers 我们继续 @ECHO_STATUS.md 中的“进行...", link: '/articles/generated/session-2026-05-21' },
|
|
14
|
+
{ text: "/understand-anything:understand --language zh", link: '/articles/generated/session-2026-05-22' },
|
|
15
|
+
{ text: "我在另一个会话中,让DeepSeek TUI带着DeepSeek,正在实现mcp server的事情。我们这个会话...", link: '/articles/generated/session-2026-05-23' },
|
|
16
|
+
{ text: "我先说我的想法:1/当前我和ai的对话,有很多都是精妙绝伦的,但是我让ai处理之后他们总是会经过压缩,这样精妙的地...", link: '/articles/generated/session-d65985f1' },
|
|
17
|
+
{ text: "把gstack叫出来,我们理解好当前项目之后。就继续干活吧。", link: '/articles/generated/session-e5239574' },
|
|
18
|
+
{ text: "hello,早上好。我发现了一个小问题,你看 ~/.echo-buffer/session-2026-05-19....", link: '/articles/generated/session-66a8dd03' },
|
|
19
|
+
{ text: "ok,而且我希望你能够加上 ~/.gstack/projects/echo-prototype/vincenthu...", link: '/articles/generated/session-b1c78bb2' }
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
text: '项目',
|
|
24
|
+
collapsed: false,
|
|
25
|
+
items: [
|
|
26
|
+
{
|
|
27
|
+
text: "mynote (16)",
|
|
28
|
+
collapsed: false,
|
|
29
|
+
items: [
|
|
30
|
+
{ text: "/claude-mem:learn-codebase", link: '/articles/generated/session-2026-05-25' },
|
|
31
|
+
{ text: "/gstack 做得不错,不过你都测试和review了吗?", link: '/articles/generated/session-2026-05-20' },
|
|
32
|
+
{ text: "/office-hours 针对 @ECHO_STATUS.md 中的“产品表层设计讨论 (2026-05-24)...", link: '/articles/generated/session-2026-05-24' },
|
|
33
|
+
{ text: "/superpowers:using-superpowers 我们继续 @ECHO_STATUS.md 中的“进行...", link: '/articles/generated/session-2026-05-21' },
|
|
34
|
+
{ text: "/understand-anything:understand --language zh", link: '/articles/generated/session-2026-05-22' },
|
|
35
|
+
{ text: "我在另一个会话中,让DeepSeek TUI带着DeepSeek,正在实现mcp server的事情。我们这个会话...", link: '/articles/generated/session-2026-05-23' },
|
|
36
|
+
{ text: "我先说我的想法:1/当前我和ai的对话,有很多都是精妙绝伦的,但是我让ai处理之后他们总是会经过压缩,这样精妙的地...", link: '/articles/generated/session-d65985f1' },
|
|
37
|
+
{ text: "把gstack叫出来,我们理解好当前项目之后。就继续干活吧。", link: '/articles/generated/session-e5239574' },
|
|
38
|
+
{ text: "hello,早上好。我发现了一个小问题,你看 ~/.echo-buffer/session-2026-05-19....", link: '/articles/generated/session-66a8dd03' },
|
|
39
|
+
{ text: "ok,而且我希望你能够加上 ~/.gstack/projects/echo-prototype/vincenthu...", link: '/articles/generated/session-b1c78bb2' },
|
|
40
|
+
{ text: "恢复上次的 office-hours 会话,我们在设计 Echo 知识论坛.", link: '/articles/generated/session-130a0fec' },
|
|
41
|
+
{ text: "我们当前项目的任务是:1/完整记录每一个和ai对话的会话内容;2/方便人类有了新的想法之后,查找并且快速的评论,还...", link: '/articles/generated/session-3b0917ca' },
|
|
42
|
+
{ text: "发一条测试消息,看看 hook 有没有触发", link: '/articles/generated/session-2026-05-19' },
|
|
43
|
+
{ text: "从零设计 Echo 知识论坛——多 AI 协作实录", link: '/articles/generated/echo-design-session-v2' },
|
|
44
|
+
{ text: "从零设计 Echo 知识论坛——一次真正的 AI 协作实录", link: '/articles/generated/echo-design-session' },
|
|
45
|
+
{ text: "为什么你应该把 AI 对话存下来", link: '/articles/generated/article-ai-dialogue' }
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import DefaultTheme from 'vitepress/theme'
|
|
3
|
+
import { useData } from 'vitepress'
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
|
|
6
|
+
const { frontmatter } = useData()
|
|
7
|
+
const echoInteractive = computed(() => !!(frontmatter.value as any)?.echo?.interactive)
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<EchoSearchLanding />
|
|
12
|
+
<EchoChatBubbles />
|
|
13
|
+
<EchoLegacyRecovery />
|
|
14
|
+
<DefaultTheme.Layout>
|
|
15
|
+
<template #nav-bar-content-after>
|
|
16
|
+
<EchoGlobalControls />
|
|
17
|
+
</template>
|
|
18
|
+
<template #doc-bottom>
|
|
19
|
+
<div v-if="echoInteractive" class="echo-doc-bottom">
|
|
20
|
+
<div class="echo-doc-bottom-inner">
|
|
21
|
+
<EchoArticleActions />
|
|
22
|
+
<EchoCommentChain />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<EchoSelectionComment v-if="echoInteractive" />
|
|
26
|
+
</template>
|
|
27
|
+
</DefaultTheme.Layout>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="state === 'unavailable'" class="echo-serve-notice">
|
|
3
|
+
Echo API 未运行 — 运行 <code>echoctl serve</code> 以启用评论和标记
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="echo-tag-strip">
|
|
7
|
+
<span class="echo-tag-strip-label">标记</span>
|
|
8
|
+
<div class="echo-existing-tags" v-if="existingTags.length">
|
|
9
|
+
<span v-for="tag in existingTags" :key="tag" class="echo-tag-chip">
|
|
10
|
+
{{ tag }}
|
|
11
|
+
<button
|
|
12
|
+
class="echo-tag-remove"
|
|
13
|
+
:disabled="state !== 'ready' || removingTags.has(tag)"
|
|
14
|
+
title="删除标记"
|
|
15
|
+
@click="removeTag(tag)"
|
|
16
|
+
>×</button>
|
|
17
|
+
</span>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="echo-tag-form">
|
|
20
|
+
<input
|
|
21
|
+
v-model="tagText"
|
|
22
|
+
placeholder="添加文章标记"
|
|
23
|
+
:disabled="state !== 'ready'"
|
|
24
|
+
@keydown.enter.prevent="submitTag"
|
|
25
|
+
/>
|
|
26
|
+
<button
|
|
27
|
+
class="echo-btn"
|
|
28
|
+
:disabled="state !== 'ready' || !tagText.trim() || tagging"
|
|
29
|
+
@click="submitTag"
|
|
30
|
+
>
|
|
31
|
+
{{ tagging ? '添加中...' : '添加' }}
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
<span v-if="tagError" class="echo-inline-error">{{ tagError }}</span>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="echo-summary-strip">
|
|
38
|
+
<span class="echo-summary-strip-label">摘要</span>
|
|
39
|
+
<textarea
|
|
40
|
+
v-model="summaryText"
|
|
41
|
+
placeholder="文章摘要..."
|
|
42
|
+
rows="2"
|
|
43
|
+
:disabled="state !== 'ready' || savingSummary"
|
|
44
|
+
></textarea>
|
|
45
|
+
<div class="echo-summary-actions">
|
|
46
|
+
<button
|
|
47
|
+
class="echo-btn"
|
|
48
|
+
:disabled="state !== 'ready' || savingSummary || summaryText === originalSummary"
|
|
49
|
+
@click="saveSummary"
|
|
50
|
+
>
|
|
51
|
+
{{ savingSummary ? '保存中...' : '保存摘要' }}
|
|
52
|
+
</button>
|
|
53
|
+
<span v-if="summaryError" class="echo-inline-error">{{ summaryError }}</span>
|
|
54
|
+
<span v-if="summarySaved" class="echo-inline-ok">{{ summarySaved }}</span>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div v-if="canPublish" class="echo-publish-strip">
|
|
59
|
+
<button
|
|
60
|
+
class="echo-btn"
|
|
61
|
+
:disabled="state !== 'ready' || publishing"
|
|
62
|
+
@click="publishLatest"
|
|
63
|
+
>
|
|
64
|
+
{{ publishing ? '发布中...' : '发布最新快照' }}
|
|
65
|
+
</button>
|
|
66
|
+
<span v-if="publishMessage" class="echo-inline-ok">{{ publishMessage }}</span>
|
|
67
|
+
<span v-if="publishError" class="echo-inline-error">{{ publishError }}</span>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="echo-comment-box">
|
|
71
|
+
<h3>发表评论</h3>
|
|
72
|
+
<textarea
|
|
73
|
+
v-model="commentText"
|
|
74
|
+
placeholder="对整篇文章的评论..."
|
|
75
|
+
rows="3"
|
|
76
|
+
:disabled="state !== 'ready'"
|
|
77
|
+
></textarea>
|
|
78
|
+
<button
|
|
79
|
+
class="echo-btn"
|
|
80
|
+
:disabled="state !== 'ready' || !commentText.trim() || submitting"
|
|
81
|
+
@click="submitComment"
|
|
82
|
+
>
|
|
83
|
+
{{ submitting ? '提交中...' : '提交评论' }}
|
|
84
|
+
</button>
|
|
85
|
+
<span v-if="submitError" style="color:var(--vp-c-danger-1);font-size:13px;">{{ submitError }}</span>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<script setup lang="ts">
|
|
90
|
+
import { ref, computed, watch } from 'vue'
|
|
91
|
+
import { useData } from 'vitepress'
|
|
92
|
+
import { useEchoStatus } from '../lib/useEchoStatus'
|
|
93
|
+
import { EchoApiError, postComment, postPublish, postTag, removeTags, updateSummary } from '../lib/echo-api'
|
|
94
|
+
|
|
95
|
+
const { frontmatter } = useData()
|
|
96
|
+
const articleId = computed(() => (frontmatter.value as any)?.echo?.articleId as string | undefined)
|
|
97
|
+
const projectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
|
|
98
|
+
|
|
99
|
+
const { state, status } = useEchoStatus(articleId)
|
|
100
|
+
|
|
101
|
+
const commentText = ref('')
|
|
102
|
+
const submitting = ref(false)
|
|
103
|
+
const submitError = ref('')
|
|
104
|
+
const existingTags = computed(() => {
|
|
105
|
+
const tags = (frontmatter.value as any)?.tags
|
|
106
|
+
return Array.isArray(tags) ? tags : []
|
|
107
|
+
})
|
|
108
|
+
const removingTags = ref<Set<string>>(new Set())
|
|
109
|
+
const tagText = ref('')
|
|
110
|
+
const tagging = ref(false)
|
|
111
|
+
const tagError = ref('')
|
|
112
|
+
const summaryText = ref('')
|
|
113
|
+
const originalSummary = ref('')
|
|
114
|
+
const savingSummary = ref(false)
|
|
115
|
+
const summaryError = ref('')
|
|
116
|
+
const summarySaved = ref('')
|
|
117
|
+
const publishing = ref(false)
|
|
118
|
+
const publishMessage = ref('')
|
|
119
|
+
const publishError = ref('')
|
|
120
|
+
const canPublish = computed(() => !!articleId.value?.startsWith('session-'))
|
|
121
|
+
|
|
122
|
+
watch(articleId, () => {
|
|
123
|
+
tagError.value = ''
|
|
124
|
+
submitError.value = ''
|
|
125
|
+
publishMessage.value = ''
|
|
126
|
+
publishError.value = ''
|
|
127
|
+
tagText.value = ''
|
|
128
|
+
commentText.value = ''
|
|
129
|
+
summaryError.value = ''
|
|
130
|
+
summarySaved.value = ''
|
|
131
|
+
const s = (frontmatter.value as any)?.summary || ''
|
|
132
|
+
summaryText.value = s
|
|
133
|
+
originalSummary.value = s
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
async function submitComment() {
|
|
137
|
+
if (!articleId.value || !commentText.value.trim()) return
|
|
138
|
+
submitting.value = true
|
|
139
|
+
submitError.value = ''
|
|
140
|
+
try {
|
|
141
|
+
await postComment({
|
|
142
|
+
articleId: articleId.value,
|
|
143
|
+
comment: commentText.value.trim(),
|
|
144
|
+
scope: 'article',
|
|
145
|
+
author: status.value?.author,
|
|
146
|
+
projectId: projectId.value ?? null,
|
|
147
|
+
})
|
|
148
|
+
commentText.value = ''
|
|
149
|
+
submitError.value = '评论已提交,即将刷新...'
|
|
150
|
+
setTimeout(() => location.reload(), 1200)
|
|
151
|
+
} catch (err: any) {
|
|
152
|
+
submitError.value = err.message || '提交失败'
|
|
153
|
+
} finally {
|
|
154
|
+
submitting.value = false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function submitTag() {
|
|
159
|
+
if (!articleId.value || !tagText.value.trim()) return
|
|
160
|
+
tagging.value = true
|
|
161
|
+
tagError.value = ''
|
|
162
|
+
try {
|
|
163
|
+
await postTag({
|
|
164
|
+
articleId: articleId.value,
|
|
165
|
+
tag: tagText.value.trim(),
|
|
166
|
+
projectId: projectId.value ?? null,
|
|
167
|
+
})
|
|
168
|
+
tagText.value = ''
|
|
169
|
+
location.reload()
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
tagError.value = err.message || '创建失败'
|
|
172
|
+
} finally {
|
|
173
|
+
tagging.value = false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function removeTag(tag: string) {
|
|
178
|
+
if (!articleId.value) return
|
|
179
|
+
removingTags.value = new Set([...removingTags.value, tag])
|
|
180
|
+
tagError.value = ''
|
|
181
|
+
try {
|
|
182
|
+
await removeTags({
|
|
183
|
+
articleId: articleId.value,
|
|
184
|
+
tags: [tag],
|
|
185
|
+
projectId: projectId.value ?? null,
|
|
186
|
+
})
|
|
187
|
+
location.reload()
|
|
188
|
+
} catch (err: any) {
|
|
189
|
+
tagError.value = err.message || '删除失败'
|
|
190
|
+
removingTags.value = new Set([...removingTags.value].filter(t => t !== tag))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function saveSummary() {
|
|
195
|
+
if (!articleId.value) return
|
|
196
|
+
savingSummary.value = true
|
|
197
|
+
summaryError.value = ''
|
|
198
|
+
summarySaved.value = ''
|
|
199
|
+
try {
|
|
200
|
+
await updateSummary({
|
|
201
|
+
articleId: articleId.value,
|
|
202
|
+
summary: summaryText.value.trim(),
|
|
203
|
+
projectId: projectId.value ?? null,
|
|
204
|
+
})
|
|
205
|
+
originalSummary.value = summaryText.value.trim()
|
|
206
|
+
summarySaved.value = '摘要已保存'
|
|
207
|
+
setTimeout(() => { summarySaved.value = '' }, 3000)
|
|
208
|
+
} catch (err: any) {
|
|
209
|
+
summaryError.value = err.message || '保存失败'
|
|
210
|
+
} finally {
|
|
211
|
+
savingSummary.value = false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function publishLatest() {
|
|
216
|
+
if (!articleId.value) return
|
|
217
|
+
publishing.value = true
|
|
218
|
+
publishMessage.value = ''
|
|
219
|
+
publishError.value = ''
|
|
220
|
+
try {
|
|
221
|
+
const result = await postPublish({
|
|
222
|
+
sessionId: articleId.value,
|
|
223
|
+
projectId: projectId.value ?? null,
|
|
224
|
+
})
|
|
225
|
+
publishMessage.value = '发布成功,即将跳转...'
|
|
226
|
+
setTimeout(() => { window.location.href = `/articles/generated/${result.slug}` }, 1000)
|
|
227
|
+
} catch (err: any) {
|
|
228
|
+
publishError.value = err instanceof EchoApiError && err.status === 409
|
|
229
|
+
? '已经是最新快照'
|
|
230
|
+
: err.message || '发布失败'
|
|
231
|
+
} finally {
|
|
232
|
+
publishing.value = false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<template></template>
|
|
2
|
+
|
|
3
|
+
<script setup lang="ts">
|
|
4
|
+
import { onMounted, onUnmounted, watch } from 'vue'
|
|
5
|
+
import { useRoute } from 'vitepress'
|
|
6
|
+
|
|
7
|
+
const route = useRoute()
|
|
8
|
+
|
|
9
|
+
function isUserSpeaker(speaker: string): boolean {
|
|
10
|
+
const s = speaker.toLowerCase()
|
|
11
|
+
return s === 'vincent' || s === 'user' || s === '我' || s === 'human'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isAssistantSpeaker(speaker: string): boolean {
|
|
15
|
+
const s = speaker.toLowerCase()
|
|
16
|
+
return s === 'ai' || s === 'claude' || s === 'assistant' || s === 'codex'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shouldStopAt(el: Element): boolean {
|
|
20
|
+
const tag = el.tagName
|
|
21
|
+
if (tag === 'H2' || tag === 'H1') {
|
|
22
|
+
const id = el.id || ''
|
|
23
|
+
return /评论|comment|标注|annotation/i.test(id)
|
|
24
|
+
}
|
|
25
|
+
const cls = el.className || ''
|
|
26
|
+
if (typeof cls === 'string') {
|
|
27
|
+
return /echo-comment-list|echo-comment-box|echo-toolbar|echo-tag-box|echo-serve-notice/i.test(cls)
|
|
28
|
+
}
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function markerBoundary(marker: Element): Element {
|
|
33
|
+
const parent = marker.parentElement
|
|
34
|
+
if (
|
|
35
|
+
parent?.tagName === 'P' &&
|
|
36
|
+
parent.children.length === 1 &&
|
|
37
|
+
parent.textContent?.trim() === ''
|
|
38
|
+
) {
|
|
39
|
+
return parent
|
|
40
|
+
}
|
|
41
|
+
return marker
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function chatify() {
|
|
45
|
+
const doc = document.querySelector('.vp-doc')
|
|
46
|
+
if (!doc) return
|
|
47
|
+
|
|
48
|
+
if (doc.hasAttribute('data-echo-chatified')) return
|
|
49
|
+
|
|
50
|
+
const markers = Array.from(doc.querySelectorAll('.echo-turn-marker'))
|
|
51
|
+
if (markers.length === 0) return
|
|
52
|
+
const boundaries = markers.map(markerBoundary)
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < markers.length; i++) {
|
|
55
|
+
const marker = markers[i]
|
|
56
|
+
const speaker = marker.getAttribute('data-speaker') || 'unknown'
|
|
57
|
+
const boundary = boundaries[i]
|
|
58
|
+
const nextBoundary = boundaries[i + 1] || null
|
|
59
|
+
|
|
60
|
+
boundary.classList.add('echo-turn-boundary')
|
|
61
|
+
|
|
62
|
+
const nodes: Node[] = []
|
|
63
|
+
let cursor: Node | null = boundary.nextSibling
|
|
64
|
+
|
|
65
|
+
while (cursor) {
|
|
66
|
+
if (nextBoundary && cursor === nextBoundary) break
|
|
67
|
+
if (cursor instanceof Element && shouldStopAt(cursor)) break
|
|
68
|
+
if (cursor instanceof Element && cursor.classList.contains('echo-turn-marker')) break
|
|
69
|
+
if (cursor instanceof Element && cursor.classList.contains('echo-turn-boundary')) break
|
|
70
|
+
|
|
71
|
+
const next: Node | null = cursor.nextSibling
|
|
72
|
+
nodes.push(cursor)
|
|
73
|
+
cursor = next
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (nodes.length === 0) continue
|
|
77
|
+
|
|
78
|
+
let bubbleClass = 'echo-chat-assistant'
|
|
79
|
+
if (isUserSpeaker(speaker)) {
|
|
80
|
+
bubbleClass = 'echo-chat-user'
|
|
81
|
+
} else if (!isAssistantSpeaker(speaker)) {
|
|
82
|
+
bubbleClass = 'echo-chat-unknown'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const wrapper = document.createElement('div')
|
|
86
|
+
wrapper.className = `echo-chat-turn ${bubbleClass}`
|
|
87
|
+
|
|
88
|
+
boundary.after(wrapper)
|
|
89
|
+
for (const node of nodes) {
|
|
90
|
+
wrapper.appendChild(node)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
doc.setAttribute('data-echo-chatified', '')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dechatify() {
|
|
98
|
+
const doc = document.querySelector('.vp-doc')
|
|
99
|
+
if (!doc) return
|
|
100
|
+
doc.removeAttribute('data-echo-chatified')
|
|
101
|
+
|
|
102
|
+
const turns = Array.from(doc.querySelectorAll('.echo-chat-turn'))
|
|
103
|
+
for (const turn of turns) {
|
|
104
|
+
const parent = turn.parentNode
|
|
105
|
+
if (!parent) continue
|
|
106
|
+
while (turn.firstChild) {
|
|
107
|
+
parent.insertBefore(turn.firstChild, turn)
|
|
108
|
+
}
|
|
109
|
+
parent.removeChild(turn)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const boundary of Array.from(doc.querySelectorAll('.echo-turn-boundary'))) {
|
|
113
|
+
boundary.classList.remove('echo-turn-boundary')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let observer: MutationObserver | null = null
|
|
118
|
+
|
|
119
|
+
function setup() {
|
|
120
|
+
dechatify()
|
|
121
|
+
if (observer) observer.disconnect()
|
|
122
|
+
|
|
123
|
+
const doc = document.querySelector('.vp-doc')
|
|
124
|
+
if (doc && !doc.hasAttribute('data-echo-chatified')) {
|
|
125
|
+
chatify()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
observer = new MutationObserver(() => {
|
|
129
|
+
const d = document.querySelector('.vp-doc')
|
|
130
|
+
if (d && !d.hasAttribute('data-echo-chatified') && d.querySelector('.echo-turn-marker')) {
|
|
131
|
+
chatify()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const content = document.querySelector('.VPContent')
|
|
136
|
+
if (content) {
|
|
137
|
+
observer.observe(content, { childList: true, subtree: true })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
onMounted(() => {
|
|
142
|
+
setup()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
watch(() => route.path, () => {
|
|
146
|
+
setTimeout(setup, 100)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
onUnmounted(() => {
|
|
150
|
+
if (observer) observer.disconnect()
|
|
151
|
+
dechatify()
|
|
152
|
+
})
|
|
153
|
+
</script>
|