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,335 @@
1
+ <template>
2
+ <div v-if="visible" class="echo-import-banner">
3
+ <div class="echo-import-banner-inner">
4
+ <div class="echo-import-banner-header" @click="expanded = !expanded">
5
+ <span class="echo-import-banner-icon">📥</span>
6
+ <span class="echo-import-banner-title">
7
+ 发现 <strong>{{ candidates.length }}</strong> 个未导入的 Claude 历史会话
8
+ </span>
9
+ <span class="echo-import-banner-chevron">{{ expanded ? '▾' : '▸' }}</span>
10
+ <button class="echo-import-banner-close" @click.stop="dismiss" title="关闭">✕</button>
11
+ </div>
12
+
13
+ <div v-if="expanded" class="echo-import-banner-body">
14
+ <p class="echo-import-banner-desc">
15
+ 这些会话来自 Claude Code 的 JSONL 转录文件,尚未导入 Echo。
16
+ 导入后它们会作为不可变文章出现在当前项目的文章列表中。
17
+ </p>
18
+
19
+ <div class="echo-import-banner-candidates" v-if="candidates.length > 0">
20
+ <div v-for="c in candidates" :key="c.sessionId" class="echo-import-candidate-item">
21
+ <span class="echo-import-candidate-id">{{ c.articleId }}</span>
22
+ <span class="echo-import-candidate-meta">{{ c.turnCount }} turns · {{ formatDate(c.mtime) }}</span>
23
+ <span class="echo-import-candidate-status" :class="'echo-status-' + c.status">{{ statusLabel(c.status) }}</span>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="echo-import-banner-cli">
28
+ <p class="echo-import-banner-cli-title">💡 命令行导入</p>
29
+ <pre class="echo-import-banner-cli-code"><code>echoctl import claude --project {{ projectRoot || '&lt;项目路径&gt;' }} --all --apply</code></pre>
30
+ <p class="echo-import-banner-cli-note">
31
+ 更多选项:<code>echoctl import claude --help</code>
32
+ </p>
33
+ </div>
34
+
35
+ <div v-if="error" class="echo-import-banner-error">{{ error }}</div>
36
+ <div v-if="success" class="echo-import-banner-success">{{ success }}</div>
37
+
38
+ <div class="echo-import-banner-actions">
39
+ <button
40
+ class="echo-import-btn echo-import-btn-primary"
41
+ :disabled="importing"
42
+ @click="importAll"
43
+ >
44
+ {{ importing ? '导入中…' : `导入全部(${candidates.length} 个)` }}
45
+ </button>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </template>
51
+
52
+ <script setup lang="ts">
53
+ import { ref, onMounted } from 'vue'
54
+ import { getClaudeImportCandidates, importClaudeSessions, getStatus } from '../lib/echo-api'
55
+
56
+ const STORAGE_KEY = 'echo-claude-import-dismissed'
57
+
58
+ const visible = ref(false)
59
+ const expanded = ref(false)
60
+ const candidates = ref<any[]>([])
61
+ const projectRoot = ref('')
62
+ const projectId = ref<string | null>(null)
63
+ const importing = ref(false)
64
+ const error = ref('')
65
+ const success = ref('')
66
+
67
+ onMounted(async () => {
68
+ // 检查是否已被关闭(跨会话保留)
69
+ try {
70
+ if (localStorage.getItem(STORAGE_KEY) === '1') return
71
+ } catch (_) {}
72
+
73
+ // 获取当前项目 ID
74
+ let pid: string | null = null
75
+ try {
76
+ const s = await getStatus()
77
+ pid = s.projectId
78
+ } catch (_) {}
79
+
80
+ if (!pid) return
81
+ projectId.value = pid
82
+
83
+ try {
84
+ const result = await getClaudeImportCandidates(pid)
85
+ const newCandidates = result.candidates.filter(
86
+ (c) => c.status === 'new' || c.status === 'updated'
87
+ )
88
+ if (newCandidates.length === 0) return
89
+ candidates.value = newCandidates
90
+ projectRoot.value = result.projectDir || ''
91
+ visible.value = true
92
+ } catch (_) {}
93
+ })
94
+
95
+ function dismiss() {
96
+ visible.value = false
97
+ try { localStorage.setItem(STORAGE_KEY, '1') } catch (_) {}
98
+ }
99
+
100
+ function statusLabel(status: string): string {
101
+ switch (status) {
102
+ case 'new': return '新'
103
+ case 'updated': return '已更新'
104
+ case 'skipped': return '已跳过'
105
+ default: return status
106
+ }
107
+ }
108
+
109
+ function formatDate(mtime: string): string {
110
+ try {
111
+ const d = new Date(mtime)
112
+ return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
113
+ } catch {
114
+ return mtime
115
+ }
116
+ }
117
+
118
+ async function importAll() {
119
+ if (!projectId.value) return
120
+ importing.value = true
121
+ error.value = ''
122
+ success.value = ''
123
+ try {
124
+ const ids = candidates.value.map((c) => c.sessionId)
125
+ const result = await importClaudeSessions(projectId.value, ids)
126
+ if (result.ok) {
127
+ success.value = `已导入 ${result.imported} 个会话,跳过 ${result.skipped} 个。页面即将刷新…`
128
+ try { localStorage.removeItem(STORAGE_KEY) } catch (_) {}
129
+ setTimeout(() => location.reload(), 2000)
130
+ }
131
+ } catch (err: any) {
132
+ error.value = err.message || '导入失败'
133
+ } finally {
134
+ importing.value = false
135
+ }
136
+ }
137
+ </script>
138
+
139
+ <style scoped>
140
+ .echo-import-banner {
141
+ margin: 16px 0 24px;
142
+ border: 1px solid var(--vp-c-brand-soft);
143
+ border-radius: 8px;
144
+ background: var(--vp-c-bg-soft);
145
+ overflow: hidden;
146
+ }
147
+
148
+ .echo-import-banner-inner {
149
+ padding: 0;
150
+ }
151
+
152
+ .echo-import-banner-header {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 8px;
156
+ padding: 12px 16px;
157
+ cursor: pointer;
158
+ user-select: none;
159
+ transition: background 0.15s;
160
+ }
161
+ .echo-import-banner-header:hover {
162
+ background: var(--vp-c-bg-mute);
163
+ }
164
+
165
+ .echo-import-banner-icon {
166
+ font-size: 16px;
167
+ }
168
+
169
+ .echo-import-banner-title {
170
+ flex: 1;
171
+ font-size: 14px;
172
+ color: var(--vp-c-text-1);
173
+ }
174
+
175
+ .echo-import-banner-chevron {
176
+ font-size: 12px;
177
+ color: var(--vp-c-text-3);
178
+ transition: transform 0.15s;
179
+ }
180
+
181
+ .echo-import-banner-close {
182
+ background: none;
183
+ border: none;
184
+ color: var(--vp-c-text-3);
185
+ cursor: pointer;
186
+ font-size: 14px;
187
+ padding: 2px 6px;
188
+ border-radius: 4px;
189
+ }
190
+ .echo-import-banner-close:hover {
191
+ background: var(--vp-c-bg-mute);
192
+ color: var(--vp-c-text-1);
193
+ }
194
+
195
+ .echo-import-banner-body {
196
+ padding: 0 16px 16px;
197
+ border-top: 1px solid var(--vp-c-divider);
198
+ }
199
+
200
+ .echo-import-banner-desc {
201
+ margin: 12px 0;
202
+ font-size: 13px;
203
+ color: var(--vp-c-text-2);
204
+ line-height: 1.6;
205
+ }
206
+
207
+ .echo-import-banner-candidates {
208
+ max-height: 200px;
209
+ overflow-y: auto;
210
+ margin-bottom: 12px;
211
+ border: 1px solid var(--vp-c-divider);
212
+ border-radius: 6px;
213
+ }
214
+
215
+ .echo-import-candidate-item {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 12px;
219
+ padding: 6px 12px;
220
+ border-bottom: 1px solid var(--vp-c-divider-light);
221
+ font-size: 13px;
222
+ }
223
+ .echo-import-candidate-item:last-child {
224
+ border-bottom: none;
225
+ }
226
+
227
+ .echo-import-candidate-id {
228
+ font-family: monospace;
229
+ font-weight: 600;
230
+ color: var(--vp-c-brand);
231
+ min-width: 140px;
232
+ }
233
+
234
+ .echo-import-candidate-meta {
235
+ color: var(--vp-c-text-3);
236
+ flex: 1;
237
+ }
238
+
239
+ .echo-import-candidate-status {
240
+ font-size: 12px;
241
+ padding: 1px 8px;
242
+ border-radius: 10px;
243
+ font-weight: 500;
244
+ }
245
+ .echo-status-new {
246
+ background: var(--vp-c-brand-soft);
247
+ color: var(--vp-c-brand);
248
+ }
249
+ .echo-status-updated {
250
+ background: var(--vp-c-warning-soft);
251
+ color: var(--vp-c-warning);
252
+ }
253
+
254
+ .echo-import-banner-cli {
255
+ margin-bottom: 16px;
256
+ padding: 12px;
257
+ background: var(--vp-c-bg);
258
+ border: 1px solid var(--vp-c-divider);
259
+ border-radius: 6px;
260
+ }
261
+
262
+ .echo-import-banner-cli-title {
263
+ margin: 0 0 8px;
264
+ font-size: 13px;
265
+ font-weight: 600;
266
+ color: var(--vp-c-text-1);
267
+ }
268
+
269
+ .echo-import-banner-cli-code {
270
+ margin: 0 0 4px;
271
+ padding: 8px 12px;
272
+ background: var(--vp-code-block-bg);
273
+ border-radius: 4px;
274
+ font-size: 12px;
275
+ overflow-x: auto;
276
+ }
277
+ .echo-import-banner-cli-code code {
278
+ color: var(--vp-c-text-1);
279
+ }
280
+
281
+ .echo-import-banner-cli-note {
282
+ margin: 4px 0 0;
283
+ font-size: 12px;
284
+ color: var(--vp-c-text-3);
285
+ }
286
+ .echo-import-banner-cli-note code {
287
+ font-size: 12px;
288
+ }
289
+
290
+ .echo-import-banner-error {
291
+ margin-bottom: 12px;
292
+ padding: 8px 12px;
293
+ background: var(--vp-c-danger-soft);
294
+ color: var(--vp-c-danger);
295
+ border-radius: 4px;
296
+ font-size: 13px;
297
+ }
298
+
299
+ .echo-import-banner-success {
300
+ margin-bottom: 12px;
301
+ padding: 8px 12px;
302
+ background: var(--vp-c-brand-soft);
303
+ color: var(--vp-c-brand);
304
+ border-radius: 4px;
305
+ font-size: 13px;
306
+ }
307
+
308
+ .echo-import-banner-actions {
309
+ display: flex;
310
+ gap: 8px;
311
+ }
312
+
313
+ .echo-import-btn {
314
+ padding: 8px 16px;
315
+ border-radius: 6px;
316
+ border: 1px solid var(--vp-c-divider);
317
+ background: var(--vp-c-bg);
318
+ color: var(--vp-c-text-1);
319
+ cursor: pointer;
320
+ font-size: 14px;
321
+ }
322
+
323
+ .echo-import-btn-primary {
324
+ background: var(--vp-c-brand);
325
+ color: #fff;
326
+ border-color: var(--vp-c-brand);
327
+ }
328
+ .echo-import-btn-primary:hover {
329
+ background: var(--vp-c-brand-dark);
330
+ }
331
+ .echo-import-btn:disabled {
332
+ opacity: 0.5;
333
+ cursor: not-allowed;
334
+ }
335
+ </style>
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <div class="echo-comment-chain" v-if="roots.length > 0">
3
+ <h3>评论回复链</h3>
4
+ <EchoCommentNode
5
+ v-for="root in roots"
6
+ :key="root.id"
7
+ :comment="root"
8
+ :children-map="childrenMap"
9
+ :depth="0"
10
+ :article-id="articleId"
11
+ :project-id="projectId"
12
+ :author="author"
13
+ @reply-submitted="handleReplySubmitted"
14
+ />
15
+ </div>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import { ref, computed, onMounted } from 'vue'
20
+ import { useData } from 'vitepress'
21
+ import { useEchoStatus } from '../lib/useEchoStatus'
22
+ import EchoCommentNode from './EchoCommentNode.vue'
23
+
24
+ interface CommentData {
25
+ id: string
26
+ author: string
27
+ date: string
28
+ content: string
29
+ quote: string | null
30
+ evolutionOf: string[]
31
+ evolutionKind: string
32
+ }
33
+
34
+ const { frontmatter } = useData()
35
+ const articleId = computed(() => (frontmatter.value as any)?.echo?.articleId as string | undefined)
36
+ const projectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
37
+ const { status } = useEchoStatus(articleId)
38
+ const author = computed(() => status.value?.author || 'vincent')
39
+
40
+ const comments = ref<CommentData[]>([])
41
+ const roots = ref<CommentData[]>([])
42
+ const childrenMap = ref<Map<string, CommentData[]>>(new Map())
43
+
44
+ function buildTree(items: CommentData[]) {
45
+ const byId = new Map<string, CommentData>()
46
+ for (const c of items) byId.set(c.id, c)
47
+
48
+ const children = new Map<string, CommentData[]>()
49
+ const rootIds = new Set(byId.keys())
50
+
51
+ for (const c of items) {
52
+ const parents = c.evolutionOf.filter((pid) => byId.has(pid))
53
+ if (parents.length > 0) {
54
+ rootIds.delete(c.id)
55
+ for (const pid of parents) {
56
+ if (!children.has(pid)) children.set(pid, [])
57
+ children.get(pid)!.push(c)
58
+ }
59
+ }
60
+ }
61
+
62
+ childrenMap.value = children
63
+ roots.value = [...rootIds].map((id) => byId.get(id)!).sort((a, b) => a.id.localeCompare(b.id))
64
+ }
65
+
66
+ function handleReplySubmitted() {
67
+ setTimeout(() => location.reload(), 1200)
68
+ }
69
+
70
+ onMounted(() => {
71
+ try {
72
+ const el = document.getElementById('echo-comments-data')
73
+ if (!el) return
74
+ const items = JSON.parse(el.textContent || '[]') as CommentData[]
75
+ comments.value = items
76
+ buildTree(items)
77
+ } catch (_) {}
78
+ })
79
+ </script>
@@ -0,0 +1,126 @@
1
+ <template>
2
+ <div class="echo-thread" :class="{ 'echo-thread-root': depth === 0 }">
3
+ <div class="echo-thread-item">
4
+ <div class="echo-thread-connector" v-if="depth > 0">
5
+ <span class="echo-evo-kind" v-if="comment.evolutionKind && comment.evolutionKind !== 'null'">
6
+ {{ kindLabel(comment.evolutionKind) }}
7
+ </span>
8
+ </div>
9
+ <div class="echo-thread-card">
10
+ <div class="echo-thread-head">
11
+ <strong>{{ comment.author }}</strong>
12
+ <span>{{ comment.date }}</span>
13
+ </div>
14
+ <div class="echo-thread-body" v-html="comment.content"></div>
15
+ <button class="echo-btn echo-reply-btn" @click="toggleReply">
16
+ {{ replying ? '取消回复' : '回复' }}
17
+ </button>
18
+ <div class="echo-reply-form" v-if="replying">
19
+ <textarea
20
+ v-model="replyText"
21
+ :placeholder="`回复 ${comment.id}...`"
22
+ rows="2"
23
+ ></textarea>
24
+ <div class="echo-reply-btns">
25
+ <button
26
+ class="echo-btn"
27
+ :disabled="!replyText.trim() || submitting"
28
+ @click="submitReply"
29
+ >
30
+ {{ submitting ? '提交中...' : '提交回复' }}
31
+ </button>
32
+ <button class="echo-btn" @click="replying = false">取消</button>
33
+ </div>
34
+ <span v-if="replyError" class="echo-inline-error">{{ replyError }}</span>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <div class="echo-thread-children">
39
+ <EchoCommentNode
40
+ v-for="child in children"
41
+ :key="child.id"
42
+ :comment="child"
43
+ :children-map="childrenMap"
44
+ :depth="depth + 1"
45
+ :article-id="articleId"
46
+ :project-id="projectId"
47
+ :author="author"
48
+ @reply-submitted="$emit('replySubmitted')"
49
+ />
50
+ </div>
51
+ </div>
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ import { computed, ref } from 'vue'
56
+ import { postComment } from '../lib/echo-api'
57
+
58
+ interface CommentData {
59
+ id: string
60
+ author: string
61
+ date: string
62
+ content: string
63
+ quote: string | null
64
+ evolutionOf: string[]
65
+ evolutionKind: string
66
+ }
67
+
68
+ const props = defineProps<{
69
+ comment: CommentData
70
+ childrenMap: Map<string, CommentData[]>
71
+ depth: number
72
+ articleId: string | undefined
73
+ projectId: string | null | undefined
74
+ author: string
75
+ }>()
76
+
77
+ const emit = defineEmits<{
78
+ replySubmitted: []
79
+ }>()
80
+
81
+ const children = computed(() => props.childrenMap.get(props.comment.id) || [])
82
+
83
+ const replying = ref(false)
84
+ const replyText = ref('')
85
+ const submitting = ref(false)
86
+ const replyError = ref('')
87
+
88
+ const kindLabels: Record<string, string> = {
89
+ expands: '扩展',
90
+ contradicts: '反驳',
91
+ refines: '深化',
92
+ supersedes: '替代',
93
+ }
94
+
95
+ function kindLabel(kind: string): string {
96
+ return kindLabels[kind] || kind
97
+ }
98
+
99
+ function toggleReply() {
100
+ replying.value = !replying.value
101
+ replyText.value = ''
102
+ replyError.value = ''
103
+ }
104
+
105
+ async function submitReply() {
106
+ if (!props.articleId || !replyText.value.trim()) return
107
+ submitting.value = true
108
+ replyError.value = ''
109
+ try {
110
+ await postComment({
111
+ articleId: props.articleId,
112
+ comment: replyText.value.trim(),
113
+ scope: 'article',
114
+ author: props.author,
115
+ evolutionOf: [props.comment.id],
116
+ evolutionKind: 'expands',
117
+ projectId: props.projectId ?? null,
118
+ })
119
+ emit('replySubmitted')
120
+ } catch (err: any) {
121
+ replyError.value = err.message || '提交失败'
122
+ } finally {
123
+ submitting.value = false
124
+ }
125
+ }
126
+ </script>
@@ -0,0 +1,95 @@
1
+ <template>
2
+ <div class="echo-reply-form" v-if="activeReplyId">
3
+ <textarea
4
+ v-model="replyText"
5
+ :placeholder="`回复 ${activeReplyId}...`"
6
+ rows="2"
7
+ ></textarea>
8
+ <div class="echo-reply-btns">
9
+ <button class="echo-btn" :disabled="!replyText.trim() || submitting" @click="submitReply">
10
+ {{ submitting ? '提交中...' : '提交回复' }}
11
+ </button>
12
+ <button class="echo-btn" @click="cancelReply">取消</button>
13
+ </div>
14
+ <span v-if="replyError" class="echo-inline-error">{{ replyError }}</span>
15
+ </div>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
20
+ import { useData } from 'vitepress'
21
+ import { useEchoStatus } from '../lib/useEchoStatus'
22
+ import { postComment } from '../lib/echo-api'
23
+
24
+ const { frontmatter } = useData()
25
+ const articleId = computed(() => (frontmatter.value as any)?.echo?.articleId as string | undefined)
26
+ const projectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
27
+ const { status } = useEchoStatus(articleId)
28
+
29
+ const activeReplyId = ref('')
30
+ const replyText = ref('')
31
+ const submitting = ref(false)
32
+ const replyError = ref('')
33
+
34
+ function attachReplyButtons() {
35
+ const comments = document.querySelectorAll<HTMLElement>('.echo-comment[data-comment-id]')
36
+ for (const el of comments) {
37
+ if (el.querySelector('.echo-reply-btn')) continue
38
+ const btn = document.createElement('button')
39
+ btn.className = 'echo-btn echo-reply-btn'
40
+ btn.textContent = '回复'
41
+ btn.addEventListener('click', () => {
42
+ const id = el.dataset.commentId
43
+ if (!id) return
44
+ activeReplyId.value = id
45
+ replyText.value = ''
46
+ replyError.value = ''
47
+ })
48
+ el.appendChild(btn)
49
+ }
50
+ }
51
+
52
+ async function submitReply() {
53
+ if (!activeReplyId.value || !replyText.value.trim() || !articleId.value) return
54
+ submitting.value = true
55
+ replyError.value = ''
56
+ try {
57
+ await postComment({
58
+ articleId: articleId.value,
59
+ comment: replyText.value.trim(),
60
+ scope: 'article',
61
+ author: status.value?.author,
62
+ evolutionOf: [activeReplyId.value],
63
+ evolutionKind: 'expands',
64
+ projectId: projectId.value ?? null,
65
+ })
66
+ replyText.value = ''
67
+ activeReplyId.value = ''
68
+ replyError.value = '回复已提交,即将刷新...'
69
+ setTimeout(() => location.reload(), 1200)
70
+ } catch (err: any) {
71
+ replyError.value = err.message || '提交失败'
72
+ } finally {
73
+ submitting.value = false
74
+ }
75
+ }
76
+
77
+ function cancelReply() {
78
+ activeReplyId.value = ''
79
+ replyText.value = ''
80
+ }
81
+
82
+ let observer: MutationObserver | null = null
83
+ onMounted(() => {
84
+ attachReplyButtons()
85
+
86
+ observer = new MutationObserver(() => {
87
+ attachReplyButtons()
88
+ })
89
+ observer.observe(document.body, { childList: true, subtree: true })
90
+ })
91
+
92
+ onUnmounted(() => {
93
+ observer?.disconnect()
94
+ })
95
+ </script>