echoctl 0.1.0 → 0.1.3
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
- package/scripts/build-docs.js +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import DefaultTheme from 'vitepress/theme'
|
|
2
|
+
import Layout from './Layout.vue'
|
|
3
|
+
import EchoArticleActions from './components/EchoArticleActions.vue'
|
|
4
|
+
import EchoSelectionComment from './components/EchoSelectionComment.vue'
|
|
5
|
+
import EchoSearchLanding from './components/EchoSearchLanding.vue'
|
|
6
|
+
import EchoTagsPage from './components/EchoTagsPage.vue'
|
|
7
|
+
import EchoChatBubbles from './components/EchoChatBubbles.vue'
|
|
8
|
+
import EchoProjectTabs from './components/EchoProjectTabs.vue'
|
|
9
|
+
import EchoCommentChain from './components/EchoCommentChain.vue'
|
|
10
|
+
import EchoCommentNode from './components/EchoCommentNode.vue'
|
|
11
|
+
// [LIVE_SESSION_DISABLED] 后期恢复时取消注释
|
|
12
|
+
// import EchoLiveSession from './components/EchoLiveSession.vue'
|
|
13
|
+
import EchoLegacyRecovery from './components/EchoLegacyRecovery.vue'
|
|
14
|
+
import EchoGlobalControls from './components/EchoGlobalControls.vue'
|
|
15
|
+
import EchoClaudeImportBanner from './components/EchoClaudeImportBanner.vue'
|
|
16
|
+
import './custom.css'
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
extends: DefaultTheme,
|
|
20
|
+
Layout,
|
|
21
|
+
enhanceApp({ app }: any) {
|
|
22
|
+
app.component('EchoArticleActions', EchoArticleActions)
|
|
23
|
+
app.component('EchoSelectionComment', EchoSelectionComment)
|
|
24
|
+
app.component('EchoSearchLanding', EchoSearchLanding)
|
|
25
|
+
app.component('EchoTagsPage', EchoTagsPage)
|
|
26
|
+
app.component('EchoChatBubbles', EchoChatBubbles)
|
|
27
|
+
app.component('EchoProjectTabs', EchoProjectTabs)
|
|
28
|
+
app.component('EchoCommentChain', EchoCommentChain)
|
|
29
|
+
app.component('EchoCommentNode', EchoCommentNode)
|
|
30
|
+
// [LIVE_SESSION_DISABLED] 后期恢复时取消注释
|
|
31
|
+
// app.component('EchoLiveSession', EchoLiveSession)
|
|
32
|
+
app.component('EchoLegacyRecovery', EchoLegacyRecovery)
|
|
33
|
+
app.component('EchoGlobalControls', EchoGlobalControls)
|
|
34
|
+
app.component('EchoClaudeImportBanner', EchoClaudeImportBanner)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
const CONFIGURED_API_BASE = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_ECHO_API_BASE) || ''
|
|
2
|
+
const DEFAULT_API_BASE = 'http://127.0.0.1:8787'
|
|
3
|
+
const API_CANDIDATES = [CONFIGURED_API_BASE, DEFAULT_API_BASE].filter(Boolean)
|
|
4
|
+
|
|
5
|
+
interface EchoStatus {
|
|
6
|
+
ok: boolean
|
|
7
|
+
captureEnabled: boolean
|
|
8
|
+
projectId: string | null
|
|
9
|
+
version: string
|
|
10
|
+
author: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CommentPayload {
|
|
14
|
+
articleId: string
|
|
15
|
+
comment: string
|
|
16
|
+
quote?: string
|
|
17
|
+
prefix?: string
|
|
18
|
+
suffix?: string
|
|
19
|
+
occurrence?: number
|
|
20
|
+
author?: string
|
|
21
|
+
scope?: string
|
|
22
|
+
evolutionOf?: string[]
|
|
23
|
+
evolutionKind?: string
|
|
24
|
+
projectId?: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TagPayload {
|
|
28
|
+
articleId: string
|
|
29
|
+
tag: string
|
|
30
|
+
projectId?: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PublishPayload {
|
|
34
|
+
sessionId: string
|
|
35
|
+
projectId?: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PublishResponse {
|
|
39
|
+
ok: boolean
|
|
40
|
+
id: string
|
|
41
|
+
slug: string
|
|
42
|
+
turnCount: number
|
|
43
|
+
version: number
|
|
44
|
+
latest: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface LiveSessionState {
|
|
48
|
+
ok: boolean
|
|
49
|
+
exists: boolean
|
|
50
|
+
projectId: string | null
|
|
51
|
+
sessionId: string
|
|
52
|
+
turnCount: number
|
|
53
|
+
hash: string | null
|
|
54
|
+
updatedAt: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface LegacyCandidate {
|
|
58
|
+
sessionId: string
|
|
59
|
+
fileName: string
|
|
60
|
+
sourcePath: string
|
|
61
|
+
turnCount: number
|
|
62
|
+
mtime: string
|
|
63
|
+
confidence: string
|
|
64
|
+
evidence: { kind: string; projectRoot: string } | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface LegacyCandidatesResponse {
|
|
68
|
+
projectId: string
|
|
69
|
+
sourceDir: string
|
|
70
|
+
candidates: LegacyCandidate[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ClaudeImportCandidate {
|
|
74
|
+
sessionId: string
|
|
75
|
+
filePath: string
|
|
76
|
+
status: string
|
|
77
|
+
articleId: string
|
|
78
|
+
turnCount: number
|
|
79
|
+
mtime: string
|
|
80
|
+
fileHash: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ClaudeImportCandidatesResponse {
|
|
84
|
+
projectId: string
|
|
85
|
+
provider: string
|
|
86
|
+
projectDir: string
|
|
87
|
+
summary: { total: number; new: number; updated: number; skipped: number }
|
|
88
|
+
candidates: ClaudeImportCandidate[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ClaudeImportResponse {
|
|
92
|
+
ok: boolean
|
|
93
|
+
imported: number
|
|
94
|
+
skipped: number
|
|
95
|
+
articlesDir: string | null
|
|
96
|
+
refreshScheduled: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface LegacyMigrateResponse {
|
|
100
|
+
ok: boolean
|
|
101
|
+
migrated: number
|
|
102
|
+
skipped: number
|
|
103
|
+
targetDir: string
|
|
104
|
+
refreshScheduled: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class EchoApiError extends Error {
|
|
108
|
+
status: number
|
|
109
|
+
constructor(message: string, status: number) {
|
|
110
|
+
super(message)
|
|
111
|
+
this.name = 'EchoApiError'
|
|
112
|
+
this.status = status
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
117
|
+
if (API_CANDIDATES.length === 0) {
|
|
118
|
+
throw new EchoApiError('API not available', 0)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let lastError: EchoApiError | null = null
|
|
122
|
+
for (const base of API_CANDIDATES) {
|
|
123
|
+
try {
|
|
124
|
+
return await requestFrom<T>(base, path, options)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
lastError = err instanceof EchoApiError ? err : new EchoApiError((err as Error).message || 'Network error', 0)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
throw lastError || new EchoApiError('Network error', 0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function requestFrom<T>(base: string, path: string, options?: RequestInit): Promise<T> {
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
const timeout = setTimeout(() => controller.abort(), 5000)
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(`${base}${path}`, {
|
|
138
|
+
...options,
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
141
|
+
})
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const body = await res.json().catch(() => ({}))
|
|
144
|
+
throw new EchoApiError((body as any).error || res.statusText, res.status)
|
|
145
|
+
}
|
|
146
|
+
return res.json() as Promise<T>
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err instanceof EchoApiError) throw err
|
|
149
|
+
if ((err as Error).name === 'AbortError') {
|
|
150
|
+
throw new EchoApiError('Request timed out', 0)
|
|
151
|
+
}
|
|
152
|
+
throw new EchoApiError((err as Error).message || 'Network error', 0)
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timeout)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getStatus(): Promise<EchoStatus> {
|
|
159
|
+
return request<EchoStatus>('/api/status')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getCaptureStatus(): Promise<{ enabled: boolean }> {
|
|
163
|
+
return request<{ enabled: boolean }>('/api/capture')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function setCapture(enabled: boolean): Promise<{ enabled: boolean }> {
|
|
167
|
+
return request<{ enabled: boolean }>('/api/capture', {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: JSON.stringify({ enabled }),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function getMcpConfig(): Promise<{ canonical: { command: string; args: string[] }; legacy: any[]; serverInfo: any }> {
|
|
174
|
+
return request('/api/mcp-config')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function postComment(payload: CommentPayload): Promise<any> {
|
|
178
|
+
return request('/api/comments', {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
body: JSON.stringify(payload),
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function postTag(payload: TagPayload): Promise<any> {
|
|
185
|
+
return request('/api/tags', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
body: JSON.stringify(payload),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface RemoveTagPayload {
|
|
192
|
+
articleId: string
|
|
193
|
+
tags: string[]
|
|
194
|
+
projectId?: string | null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function removeTags(payload: RemoveTagPayload): Promise<any> {
|
|
198
|
+
return request('/api/tags/remove', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body: JSON.stringify(payload),
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface RenameTagPayload {
|
|
205
|
+
oldTag: string
|
|
206
|
+
newTag: string
|
|
207
|
+
projectId?: string | null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface RenameTagResponse {
|
|
211
|
+
oldTag: string
|
|
212
|
+
newTag: string
|
|
213
|
+
renamed: number
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function renameTag(payload: RenameTagPayload): Promise<RenameTagResponse> {
|
|
217
|
+
return request<RenameTagResponse>('/api/tags/rename', {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
body: JSON.stringify(payload),
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface PurgeTagPayload {
|
|
224
|
+
tag: string
|
|
225
|
+
projectId?: string | null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface PurgeTagResponse {
|
|
229
|
+
tag: string
|
|
230
|
+
purged: number
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function purgeTag(payload: PurgeTagPayload): Promise<PurgeTagResponse> {
|
|
234
|
+
return request<PurgeTagResponse>('/api/tags/purge', {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
body: JSON.stringify(payload),
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface SummaryPayload {
|
|
241
|
+
articleId: string
|
|
242
|
+
summary: string
|
|
243
|
+
projectId?: string | null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function updateSummary(payload: SummaryPayload): Promise<any> {
|
|
247
|
+
return request('/api/summary', {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
body: JSON.stringify(payload),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function postPublish(payload: PublishPayload): Promise<PublishResponse> {
|
|
254
|
+
return request<PublishResponse>('/api/publish', {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
body: JSON.stringify(payload),
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function getLiveSessionState(projectId: string | null, sessionId: string): Promise<LiveSessionState> {
|
|
261
|
+
const params = new URLSearchParams({ sessionId })
|
|
262
|
+
if (projectId) params.set('projectId', projectId)
|
|
263
|
+
return request<LiveSessionState>(`/api/live-session-state?${params.toString()}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function getLegacyCandidates(projectId: string): Promise<LegacyCandidatesResponse> {
|
|
267
|
+
return request<LegacyCandidatesResponse>(`/api/legacy-candidates?projectId=${encodeURIComponent(projectId)}`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function migrateLegacyCandidates(projectId: string, candidateIds?: string[]): Promise<LegacyMigrateResponse> {
|
|
271
|
+
return request<LegacyMigrateResponse>('/api/legacy-candidates/migrate', {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
body: JSON.stringify({ projectId, candidateIds }),
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface ProjectsResponse {
|
|
278
|
+
projects: { id: string; name: string; root: string; dataRoot: string }[]
|
|
279
|
+
currentId: string | null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function getProjects(): Promise<ProjectsResponse> {
|
|
283
|
+
return request<ProjectsResponse>('/api/projects')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function getClaudeImportCandidates(projectId: string): Promise<ClaudeImportCandidatesResponse> {
|
|
287
|
+
return request<ClaudeImportCandidatesResponse>(`/api/import/claude-candidates?projectId=${encodeURIComponent(projectId)}`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function importClaudeSessions(projectId: string, sessionIds: string[]): Promise<ClaudeImportResponse> {
|
|
291
|
+
return request<ClaudeImportResponse>('/api/import/claude', {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
body: JSON.stringify({ projectId, sessionIds }),
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export { EchoApiError, CONFIGURED_API_BASE, DEFAULT_API_BASE }
|
|
298
|
+
export type { EchoStatus, CommentPayload, TagPayload, RemoveTagPayload, RenameTagPayload, RenameTagResponse, PurgeTagPayload, PurgeTagResponse, SummaryPayload, PublishPayload, PublishResponse, LiveSessionState, LegacyCandidate, LegacyCandidatesResponse, LegacyMigrateResponse, ClaudeImportCandidate, ClaudeImportCandidatesResponse, ClaudeImportResponse }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
type Subscriber = () => void | Promise<void>
|
|
2
|
+
|
|
3
|
+
const subscribers = new Set<Subscriber>()
|
|
4
|
+
let timer: ReturnType<typeof window.setInterval> | null = null
|
|
5
|
+
const intervalMs = 10000
|
|
6
|
+
|
|
7
|
+
function tick() {
|
|
8
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
|
9
|
+
for (const subscriber of Array.from(subscribers)) {
|
|
10
|
+
void subscriber()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function start() {
|
|
15
|
+
if (typeof window === 'undefined' || timer) return
|
|
16
|
+
timer = window.setInterval(tick, intervalMs)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stop() {
|
|
20
|
+
if (!timer || subscribers.size > 0) return
|
|
21
|
+
window.clearInterval(timer)
|
|
22
|
+
timer = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function subscribeEchoHeartbeat(subscriber: Subscriber): () => void {
|
|
26
|
+
subscribers.add(subscriber)
|
|
27
|
+
start()
|
|
28
|
+
return () => {
|
|
29
|
+
subscribers.delete(subscriber)
|
|
30
|
+
stop()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { intervalMs as echoHeartbeatIntervalMs }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
|
|
2
|
+
import { getStatus, EchoApiError, type EchoStatus } from './echo-api'
|
|
3
|
+
|
|
4
|
+
type StatusState = 'loading' | 'ready' | 'unavailable'
|
|
5
|
+
|
|
6
|
+
export function useEchoStatus(articleId: Ref<string | undefined>) {
|
|
7
|
+
const state = ref<StatusState>('loading')
|
|
8
|
+
const status = ref<EchoStatus | null>(null)
|
|
9
|
+
const error = ref<string>('')
|
|
10
|
+
let controller: AbortController | null = null
|
|
11
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
12
|
+
|
|
13
|
+
async function check() {
|
|
14
|
+
controller?.abort()
|
|
15
|
+
controller = new AbortController()
|
|
16
|
+
state.value = 'loading'
|
|
17
|
+
error.value = ''
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const result = await getStatus()
|
|
21
|
+
status.value = result
|
|
22
|
+
state.value = 'ready'
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if ((err as Error).name === 'AbortError' || (err as Error).message?.includes('abort')) return
|
|
25
|
+
state.value = 'unavailable'
|
|
26
|
+
error.value = err instanceof EchoApiError ? err.message : '无法连接 Echo 服务'
|
|
27
|
+
scheduleRetry()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scheduleRetry() {
|
|
32
|
+
if (retryTimer) clearTimeout(retryTimer)
|
|
33
|
+
retryTimer = setTimeout(check, 30000)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cancel() {
|
|
37
|
+
controller?.abort()
|
|
38
|
+
controller = null
|
|
39
|
+
if (retryTimer) {
|
|
40
|
+
clearTimeout(retryTimer)
|
|
41
|
+
retryTimer = null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onMounted(() => {
|
|
46
|
+
check()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
watch(articleId, () => {
|
|
50
|
+
cancel()
|
|
51
|
+
check()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
onUnmounted(() => {
|
|
55
|
+
cancel()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return { state, status, error, retry: check }
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
import { getProjects } from './echo-api'
|
|
3
|
+
|
|
4
|
+
interface Project {
|
|
5
|
+
id: string
|
|
6
|
+
name: string
|
|
7
|
+
root: string
|
|
8
|
+
dataRoot: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Module-level state for cross-component sharing
|
|
12
|
+
const selected = ref<string>('__all__')
|
|
13
|
+
const projects = ref<Project[]>([])
|
|
14
|
+
const loaded = ref(false)
|
|
15
|
+
|
|
16
|
+
export function useProjectFilter() {
|
|
17
|
+
const selectedProject = computed(() => selected.value)
|
|
18
|
+
const allProjects = computed(() => projects.value)
|
|
19
|
+
|
|
20
|
+
async function load() {
|
|
21
|
+
if (loaded.value) return
|
|
22
|
+
try {
|
|
23
|
+
const result = await getProjects()
|
|
24
|
+
projects.value = result.projects || []
|
|
25
|
+
loaded.value = true
|
|
26
|
+
} catch (_) {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function select(projectId: string) {
|
|
30
|
+
selected.value = projectId
|
|
31
|
+
try { localStorage.setItem('echo-project-filter', projectId) } catch (_) {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function restore() {
|
|
35
|
+
try {
|
|
36
|
+
const saved = localStorage.getItem('echo-project-filter')
|
|
37
|
+
if (saved) selected.value = saved
|
|
38
|
+
} catch (_) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { selectedProject, allProjects, load, select, restore }
|
|
42
|
+
}
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: home
|
|
3
|
+
|
|
4
|
+
hero:
|
|
5
|
+
name: "Echo"
|
|
6
|
+
text: "本地优先的 AI 对话知识论坛"
|
|
7
|
+
tagline: 把 AI 编程会话捕获为不可变的 Markdown 文章,支持浏览、搜索、标签、评论、MCP 访问。
|
|
8
|
+
actions:
|
|
9
|
+
- theme: brand
|
|
10
|
+
text: 快速开始
|
|
11
|
+
link: /../GETTING_STARTED.md
|
|
12
|
+
- theme: alt
|
|
13
|
+
text: 完整文档
|
|
14
|
+
link: /../README.md
|
|
15
|
+
|
|
16
|
+
features:
|
|
17
|
+
- icon: 📝
|
|
18
|
+
title: 不可变归档
|
|
19
|
+
details: 文章正文一旦创建即不可修改。AI 对话作为源记录永久保存,后续整理通过标签、评论、标注完成。
|
|
20
|
+
- icon: 🔴
|
|
21
|
+
title: Live Session 实时预览
|
|
22
|
+
details: 正在聊天的内容通过 live session 页面实时查看,前端心跳自动检测更新,无需手动刷新。
|
|
23
|
+
- icon: 🤖
|
|
24
|
+
title: MCP AI 接口
|
|
25
|
+
details: 9 个 MCP 工具让 AI 助手直接读取、搜索 Echo 本地归档,成为 AI 的长期记忆。
|
|
26
|
+
- icon: 📁
|
|
27
|
+
title: 多项目支持
|
|
28
|
+
details: 每个项目独立管理,会话自动归入对应项目。空目录需显式注册,未注册目录降级写入 legacy buffer。
|
|
29
|
+
- icon: 🔍
|
|
30
|
+
title: 全文搜索
|
|
31
|
+
details: 本地搜索索引,通过 CLI 或网页快速找到历史对话中的关键信息。
|
|
32
|
+
- icon: 🏷️
|
|
33
|
+
title: 标签管理
|
|
34
|
+
details: 为文章打标签、分类整理,支持添加、移除、重命名、删除标签,构建个人知识体系。
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 一分钟了解 Echo
|
|
38
|
+
|
|
39
|
+
Echo 在你和 AI 聊天时自动捕获会话,转成 Markdown 文章,并在本地网页上展示。你不需要手动整理——hook 会静默工作。
|
|
40
|
+
|
|
41
|
+
**安装只需三步:**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd echo-prototype && npm install && npm link # 安装 CLI
|
|
45
|
+
echoctl init && echoctl init project # 注册项目
|
|
46
|
+
echoctl hook install claude --write # 安装捕获 hook
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
然后启动网页:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
echoctl serve
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
浏览器打开显示的地址,就能看到你的 AI 对话文章了。
|
|
56
|
+
|
|
57
|
+
**核心命令速览:**
|
|
58
|
+
|
|
59
|
+
| 命令 | 用途 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `echoctl serve` | 后台启动本地网页和 API |
|
|
62
|
+
| `echoctl stop` | 停止后台服务 |
|
|
63
|
+
| `echoctl status` | 查看 Echo 全面状态 |
|
|
64
|
+
| `echoctl all` | 手动跑完整管线(convert → validate → index) |
|
|
65
|
+
| `echoctl refresh` | 不重启 serve 刷新页面 |
|
|
66
|
+
| `echoctl search -- --keyword "关键词"` | 全文搜索文章 |
|
|
67
|
+
| `echoctl tag add <id> <tag>` | 给文章加标签 |
|
|
68
|
+
| `echoctl project list` | 查看已注册项目 |
|
|
69
|
+
|
|
70
|
+
**MCP 接入(让 AI 读你的归档):**
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"echo": {
|
|
76
|
+
"command": "echoctl",
|
|
77
|
+
"args": ["mcp"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
配置后 AI 助手即可通过 `search_articles`、`get_article`、`list_recent` 等工具访问你的 Echo 归档。
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
> **状态:开发中,未发布 npm。** 当前通过 `npm link` 使用开发版。
|
|
88
|
+
>
|
|
89
|
+
> 更多文档:[上手指南](/../GETTING_STARTED.md) · [完整 README](https://github.com/daxiguaguagua-hash/echo/blob/main/README.md) · [使用指南 V3](/../USAGE_GUIDE_V3.md) · [工程边界](/../ENGINEERING_BOUNDARIES.md) · [项目进度](/../ECHO_STATUS.md)
|
package/package.json
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echoctl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
5
6
|
"description": "Local-first AI conversation knowledge forum — capture, search, and annotate Claude Code sessions as Markdown articles with MCP server support",
|
|
6
|
-
"keywords": [
|
|
7
|
+
"keywords": [
|
|
8
|
+
"echo",
|
|
9
|
+
"knowledge-forum",
|
|
10
|
+
"ai-conversations",
|
|
11
|
+
"markdown",
|
|
12
|
+
"local-first",
|
|
13
|
+
"mcp",
|
|
14
|
+
"claude-code",
|
|
15
|
+
"cli"
|
|
16
|
+
],
|
|
7
17
|
"engines": {
|
|
8
18
|
"node": ">=18.0.0"
|
|
9
19
|
},
|
|
@@ -51,6 +61,5 @@
|
|
|
51
61
|
"gray-matter": "^4.0.3",
|
|
52
62
|
"vitepress": "^1.6.4"
|
|
53
63
|
},
|
|
54
|
-
"devDependencies": {
|
|
55
|
-
}
|
|
64
|
+
"devDependencies": {}
|
|
56
65
|
}
|
package/scripts/build-docs.js
CHANGED
|
@@ -7,7 +7,7 @@ const store = require("./lib/infra/markdown-store");
|
|
|
7
7
|
const { stripCommentSections } = require("./lib/usecases/strip-comments");
|
|
8
8
|
const { TURN_MARKER_REGEX } = require("./lib/domain/echo-format");
|
|
9
9
|
|
|
10
|
-
const PACKAGE_DOCS_ROOT = path.resolve(__dirname, "
|
|
10
|
+
const PACKAGE_DOCS_ROOT = path.resolve(__dirname, "../docs");
|
|
11
11
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
12
12
|
|
|
13
13
|
function defaultDocsRoot() {
|