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.
@@ -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>