cognova 0.1.0

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.
Files changed (205) hide show
  1. package/.env.example +58 -0
  2. package/Claude/CLAUDE.md +92 -0
  3. package/Claude/hooks/lib/__init__.py +1 -0
  4. package/Claude/hooks/lib/hook_client.py +207 -0
  5. package/Claude/hooks/log-event.py +97 -0
  6. package/Claude/hooks/pre-compact.py +46 -0
  7. package/Claude/hooks/session-end.py +26 -0
  8. package/Claude/hooks/session-start.py +35 -0
  9. package/Claude/hooks/stop-extract.py +40 -0
  10. package/Claude/rules/frontmatter.md +54 -0
  11. package/Claude/rules/markdown.md +43 -0
  12. package/Claude/rules/note-organization.md +33 -0
  13. package/Claude/settings.json +54 -0
  14. package/Claude/skills/README.md +136 -0
  15. package/Claude/skills/_lib/__init__.py +1 -0
  16. package/Claude/skills/_lib/api.py +164 -0
  17. package/Claude/skills/_lib/output.py +95 -0
  18. package/Claude/skills/environment/SKILL.md +73 -0
  19. package/Claude/skills/environment/environment.py +239 -0
  20. package/Claude/skills/memory/SKILL.md +153 -0
  21. package/Claude/skills/memory/memory.py +270 -0
  22. package/Claude/skills/project/SKILL.md +105 -0
  23. package/Claude/skills/project/project.py +203 -0
  24. package/Claude/skills/skill-creator/SKILL.md +261 -0
  25. package/Claude/skills/task/SKILL.md +135 -0
  26. package/Claude/skills/task/task.py +310 -0
  27. package/LICENSE +21 -0
  28. package/README.md +176 -0
  29. package/app/app.config.ts +8 -0
  30. package/app/app.vue +39 -0
  31. package/app/assets/css/main.css +10 -0
  32. package/app/components/AppLogo.vue +40 -0
  33. package/app/components/AssistantPanel.client.vue +518 -0
  34. package/app/components/ConfirmModal.vue +84 -0
  35. package/app/components/TemplateMenu.vue +49 -0
  36. package/app/components/agents/AgentActivityChart.client.vue +105 -0
  37. package/app/components/agents/AgentActivityChart.server.vue +25 -0
  38. package/app/components/agents/AgentForm.vue +304 -0
  39. package/app/components/agents/AgentRunModal.vue +154 -0
  40. package/app/components/agents/AgentStatsCards.vue +98 -0
  41. package/app/components/chat/ChatInput.vue +85 -0
  42. package/app/components/chat/ConversationList.vue +78 -0
  43. package/app/components/chat/MessageBubble.vue +81 -0
  44. package/app/components/chat/StreamingMessage.vue +36 -0
  45. package/app/components/chat/ToolCallBlock.vue +77 -0
  46. package/app/components/editor/CodeEditor.client.vue +212 -0
  47. package/app/components/editor/CodeEditorFallback.vue +12 -0
  48. package/app/components/editor/DocumentEditor.vue +326 -0
  49. package/app/components/editor/DocumentMetadata.vue +140 -0
  50. package/app/components/editor/MarkdownEditor.vue +146 -0
  51. package/app/components/files/FileTree.vue +436 -0
  52. package/app/components/hooks/HookActivityChart.client.vue +117 -0
  53. package/app/components/hooks/HookActivityChart.server.vue +25 -0
  54. package/app/components/hooks/HookStatsCards.vue +63 -0
  55. package/app/components/hooks/RecentEventsTable.vue +123 -0
  56. package/app/components/hooks/ToolBreakdownTable.vue +72 -0
  57. package/app/components/search/DashboardSearch.vue +122 -0
  58. package/app/components/tasks/ProjectSelect.vue +35 -0
  59. package/app/components/tasks/TaskCard.vue +182 -0
  60. package/app/components/tasks/TaskDetail.vue +160 -0
  61. package/app/components/tasks/TaskForm.vue +280 -0
  62. package/app/components/tasks/TaskList.vue +69 -0
  63. package/app/components/view/ViewToc.vue +85 -0
  64. package/app/composables/useAgents.ts +153 -0
  65. package/app/composables/useAuth.ts +73 -0
  66. package/app/composables/useChat.ts +298 -0
  67. package/app/composables/useDocument.ts +141 -0
  68. package/app/composables/useEditor.ts +100 -0
  69. package/app/composables/useFileTree.ts +220 -0
  70. package/app/composables/useHookEvents.ts +68 -0
  71. package/app/composables/useMemories.ts +83 -0
  72. package/app/composables/useNotificationBus.ts +154 -0
  73. package/app/composables/usePreferences.ts +131 -0
  74. package/app/composables/useProjects.ts +97 -0
  75. package/app/composables/useSearch.ts +52 -0
  76. package/app/composables/useTasks.ts +201 -0
  77. package/app/composables/useTerminal.ts +135 -0
  78. package/app/layouts/auth.vue +20 -0
  79. package/app/layouts/dashboard.vue +186 -0
  80. package/app/layouts/view.vue +60 -0
  81. package/app/middleware/auth.ts +9 -0
  82. package/app/pages/agents/[id].vue +602 -0
  83. package/app/pages/agents/index.vue +412 -0
  84. package/app/pages/chat.vue +146 -0
  85. package/app/pages/dashboard.vue +80 -0
  86. package/app/pages/docs.vue +131 -0
  87. package/app/pages/hooks.vue +163 -0
  88. package/app/pages/index.vue +249 -0
  89. package/app/pages/login.vue +60 -0
  90. package/app/pages/memories.vue +282 -0
  91. package/app/pages/settings.vue +625 -0
  92. package/app/pages/tasks.vue +312 -0
  93. package/app/pages/view/[uuid].vue +376 -0
  94. package/dist/cli/index.js +2711 -0
  95. package/drizzle.config.ts +10 -0
  96. package/nuxt.config.ts +98 -0
  97. package/package.json +107 -0
  98. package/server/api/agents/[id]/cancel.post.ts +27 -0
  99. package/server/api/agents/[id]/run.post.ts +34 -0
  100. package/server/api/agents/[id]/runs.get.ts +45 -0
  101. package/server/api/agents/[id]/stats.get.ts +94 -0
  102. package/server/api/agents/[id].delete.ts +29 -0
  103. package/server/api/agents/[id].get.ts +25 -0
  104. package/server/api/agents/[id].patch.ts +55 -0
  105. package/server/api/agents/index.get.ts +15 -0
  106. package/server/api/agents/index.post.ts +48 -0
  107. package/server/api/agents/stats.get.ts +86 -0
  108. package/server/api/auth/[...all].ts +5 -0
  109. package/server/api/conversations/[id].delete.ts +16 -0
  110. package/server/api/conversations/[id].get.ts +34 -0
  111. package/server/api/conversations/index.get.ts +17 -0
  112. package/server/api/documents/[id]/index.delete.ts +47 -0
  113. package/server/api/documents/[id]/index.put.ts +102 -0
  114. package/server/api/documents/[id]/public.get.ts +60 -0
  115. package/server/api/documents/[id]/restore.post.ts +65 -0
  116. package/server/api/documents/by-path.post.ts +168 -0
  117. package/server/api/documents/index.get.ts +48 -0
  118. package/server/api/fs/delete.post.ts +41 -0
  119. package/server/api/fs/list.get.ts +99 -0
  120. package/server/api/fs/mkdir.post.ts +44 -0
  121. package/server/api/fs/move.post.ts +68 -0
  122. package/server/api/fs/read.post.ts +48 -0
  123. package/server/api/fs/rename.post.ts +55 -0
  124. package/server/api/fs/write.post.ts +51 -0
  125. package/server/api/health.get.ts +40 -0
  126. package/server/api/home.get.ts +26 -0
  127. package/server/api/hooks/events/index.get.ts +56 -0
  128. package/server/api/hooks/events/index.post.ts +36 -0
  129. package/server/api/hooks/stats.get.ts +99 -0
  130. package/server/api/memory/[id].delete.ts +26 -0
  131. package/server/api/memory/context.get.ts +83 -0
  132. package/server/api/memory/extract.post.ts +42 -0
  133. package/server/api/memory/search.get.ts +70 -0
  134. package/server/api/memory/store.post.ts +31 -0
  135. package/server/api/projects/[id]/index.delete.ts +40 -0
  136. package/server/api/projects/[id]/index.get.ts +25 -0
  137. package/server/api/projects/[id]/index.put.ts +50 -0
  138. package/server/api/projects/index.get.ts +20 -0
  139. package/server/api/projects/index.post.ts +34 -0
  140. package/server/api/secrets/[key].delete.ts +31 -0
  141. package/server/api/secrets/[key].get.ts +30 -0
  142. package/server/api/secrets/[key].put.ts +52 -0
  143. package/server/api/secrets/index.get.ts +20 -0
  144. package/server/api/secrets/index.post.ts +58 -0
  145. package/server/api/tasks/[id]/index.delete.ts +46 -0
  146. package/server/api/tasks/[id]/index.get.ts +24 -0
  147. package/server/api/tasks/[id]/index.put.ts +70 -0
  148. package/server/api/tasks/[id]/restore.post.ts +49 -0
  149. package/server/api/tasks/index.get.ts +53 -0
  150. package/server/api/tasks/index.post.ts +47 -0
  151. package/server/api/tasks/tags.get.ts +21 -0
  152. package/server/api/user/email.patch.ts +56 -0
  153. package/server/db/index.ts +76 -0
  154. package/server/db/migrate.ts +41 -0
  155. package/server/db/schema.ts +345 -0
  156. package/server/db/seed.ts +46 -0
  157. package/server/db/types.ts +28 -0
  158. package/server/drizzle/migrations/0000_brown_george_stacy.sql +34 -0
  159. package/server/drizzle/migrations/0001_stormy_pyro.sql +16 -0
  160. package/server/drizzle/migrations/0002_clean_colossus.sql +50 -0
  161. package/server/drizzle/migrations/0003_fine_joystick.sql +12 -0
  162. package/server/drizzle/migrations/0004_tan_groot.sql +26 -0
  163. package/server/drizzle/migrations/0005_cloudy_lilith.sql +33 -0
  164. package/server/drizzle/migrations/0006_ordinary_retro_girl.sql +13 -0
  165. package/server/drizzle/migrations/0007_flowery_venus.sql +15 -0
  166. package/server/drizzle/migrations/0008_talented_zombie.sql +13 -0
  167. package/server/drizzle/migrations/0009_gray_shen.sql +15 -0
  168. package/server/drizzle/migrations/meta/0000_snapshot.json +230 -0
  169. package/server/drizzle/migrations/meta/0001_snapshot.json +306 -0
  170. package/server/drizzle/migrations/meta/0002_snapshot.json +615 -0
  171. package/server/drizzle/migrations/meta/0003_snapshot.json +730 -0
  172. package/server/drizzle/migrations/meta/0004_snapshot.json +916 -0
  173. package/server/drizzle/migrations/meta/0005_snapshot.json +1127 -0
  174. package/server/drizzle/migrations/meta/0006_snapshot.json +1213 -0
  175. package/server/drizzle/migrations/meta/0007_snapshot.json +1307 -0
  176. package/server/drizzle/migrations/meta/0008_snapshot.json +1390 -0
  177. package/server/drizzle/migrations/meta/0009_snapshot.json +1487 -0
  178. package/server/drizzle/migrations/meta/_journal.json +76 -0
  179. package/server/middleware/auth.ts +79 -0
  180. package/server/plugins/00.env-validate.ts +38 -0
  181. package/server/plugins/01.api-token.ts +31 -0
  182. package/server/plugins/02.database.ts +54 -0
  183. package/server/plugins/03.file-watcher.ts +65 -0
  184. package/server/plugins/04.cron-agents.ts +26 -0
  185. package/server/routes/_ws/chat.ts +252 -0
  186. package/server/routes/notifications.ts +47 -0
  187. package/server/routes/terminal.ts +98 -0
  188. package/server/services/agent-executor.ts +218 -0
  189. package/server/services/cron-scheduler.ts +78 -0
  190. package/server/services/memory-extractor.ts +120 -0
  191. package/server/utils/agent-cleanup.ts +91 -0
  192. package/server/utils/agent-registry.ts +95 -0
  193. package/server/utils/auth.ts +33 -0
  194. package/server/utils/chat-session-manager.ts +59 -0
  195. package/server/utils/crypto.ts +40 -0
  196. package/server/utils/db-guard.ts +12 -0
  197. package/server/utils/db-state.ts +63 -0
  198. package/server/utils/document-sync.ts +207 -0
  199. package/server/utils/frontmatter.ts +84 -0
  200. package/server/utils/notification-bus.ts +60 -0
  201. package/server/utils/path-validator.ts +55 -0
  202. package/server/utils/pty-manager.ts +130 -0
  203. package/shared/types/index.ts +604 -0
  204. package/shared/utils/language-detection.ts +87 -0
  205. package/tsconfig.json +10 -0
@@ -0,0 +1,298 @@
1
+ import type {
2
+ ChatConversation,
3
+ ChatMessage,
4
+ ChatContentBlock,
5
+ ChatServerMessage,
6
+ ChatSessionStatus,
7
+ ChatConnectionStatus
8
+ } from '~~/shared/types'
9
+
10
+ // crypto.randomUUID() requires secure context (HTTPS). Fallback for HTTP.
11
+ function generateId(): string {
12
+ if (typeof crypto !== 'undefined' && crypto.randomUUID)
13
+ return crypto.randomUUID()
14
+ const bytes = new Uint8Array(16)
15
+ crypto.getRandomValues(bytes)
16
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40
17
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80
18
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
19
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
20
+ }
21
+
22
+ // Shared state across component instances
23
+ const connectionStatus = ref<ChatConnectionStatus>('disconnected')
24
+ const sessionStatus = ref<ChatSessionStatus>('idle')
25
+ const activeConversationId = ref<string | null>(null)
26
+ const messages = ref<ChatMessage[]>([])
27
+ const conversations = ref<ChatConversation[]>([])
28
+ const streamingText = ref('')
29
+ const streamingToolCalls = ref<Record<string, { name: string, result?: string, isError?: boolean }>>({})
30
+ const lastCostUsd = ref(0)
31
+ const loading = ref(false)
32
+
33
+ const ws = ref<WebSocket | null>(null)
34
+ const reconnectAttempts = ref(0)
35
+ const maxReconnectAttempts = 5
36
+ let isInitialized = false
37
+ let pingInterval: ReturnType<typeof setInterval> | null = null
38
+
39
+ export function useChat() {
40
+ function getWebSocketUrl(): string {
41
+ if (import.meta.server) return ''
42
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
43
+ return `${protocol}//${window.location.host}/_ws/chat`
44
+ }
45
+
46
+ function handleServerMessage(msg: ChatServerMessage) {
47
+ switch (msg.type) {
48
+ case 'chat:session_created':
49
+ activeConversationId.value = msg.conversationId
50
+ loadConversations()
51
+ break
52
+
53
+ case 'chat:stream_start':
54
+ sessionStatus.value = 'streaming'
55
+ streamingText.value = ''
56
+ streamingToolCalls.value = {}
57
+ break
58
+
59
+ case 'chat:text_delta':
60
+ streamingText.value += msg.delta
61
+ break
62
+
63
+ case 'chat:tool_start':
64
+ streamingToolCalls.value = { ...streamingToolCalls.value, [msg.toolUseId]: { name: msg.toolName } }
65
+ break
66
+
67
+ case 'chat:tool_end': {
68
+ const tool = streamingToolCalls.value[msg.toolUseId]
69
+ if (tool) {
70
+ streamingToolCalls.value = {
71
+ ...streamingToolCalls.value,
72
+ [msg.toolUseId]: { ...tool, result: msg.result, isError: msg.isError }
73
+ }
74
+ }
75
+ break
76
+ }
77
+
78
+ case 'chat:stream_end': {
79
+ sessionStatus.value = 'idle'
80
+ lastCostUsd.value = msg.costUsd
81
+
82
+ // Finalize streaming into a proper message
83
+ const contentBlocks: ChatContentBlock[] = []
84
+ if (streamingText.value)
85
+ contentBlocks.push({ type: 'text', text: streamingText.value })
86
+
87
+ for (const [id, tool] of Object.entries(streamingToolCalls.value)) {
88
+ contentBlocks.push({ type: 'tool_use', id, name: tool.name, input: {} })
89
+ if (tool.result !== undefined)
90
+ contentBlocks.push({ type: 'tool_result', tool_use_id: id, content: tool.result, is_error: tool.isError })
91
+ }
92
+
93
+ if (contentBlocks.length > 0) {
94
+ messages.value.push({
95
+ id: generateId(),
96
+ conversationId: msg.conversationId,
97
+ role: 'assistant',
98
+ content: contentBlocks,
99
+ costUsd: msg.costUsd,
100
+ durationMs: msg.durationMs,
101
+ createdAt: new Date()
102
+ })
103
+ }
104
+
105
+ streamingText.value = ''
106
+ streamingToolCalls.value = {}
107
+
108
+ // Refresh conversations list
109
+ loadConversations()
110
+ break
111
+ }
112
+
113
+ case 'chat:error':
114
+ sessionStatus.value = 'error'
115
+ console.error('[chat] Server error:', msg.message)
116
+ break
117
+
118
+ case 'chat:interrupted':
119
+ sessionStatus.value = 'interrupted'
120
+ // Save any partial streaming text as a message
121
+ if (streamingText.value) {
122
+ messages.value.push({
123
+ id: generateId(),
124
+ conversationId: msg.conversationId,
125
+ role: 'assistant',
126
+ content: [{ type: 'text', text: streamingText.value }],
127
+ createdAt: new Date()
128
+ })
129
+ streamingText.value = ''
130
+ streamingToolCalls.value = {}
131
+ }
132
+ break
133
+ }
134
+ }
135
+
136
+ function connect() {
137
+ if (import.meta.server) return
138
+ if (ws.value?.readyState === WebSocket.OPEN) return
139
+ if (isInitialized && connectionStatus.value === 'connecting') return
140
+
141
+ isInitialized = true
142
+ connectionStatus.value = 'connecting'
143
+
144
+ const socket = new WebSocket(getWebSocketUrl())
145
+ ws.value = socket
146
+
147
+ socket.onopen = () => {
148
+ connectionStatus.value = 'connected'
149
+ reconnectAttempts.value = 0
150
+ startPingInterval()
151
+ }
152
+
153
+ socket.onmessage = (event) => {
154
+ try {
155
+ const data = JSON.parse(event.data) as Record<string, unknown>
156
+ if (data.type === 'chat:connected' || data.type === 'pong') return
157
+ handleServerMessage(data as unknown as ChatServerMessage)
158
+ } catch (e) {
159
+ console.error('[chat] Failed to parse message:', e)
160
+ }
161
+ }
162
+
163
+ socket.onclose = () => {
164
+ connectionStatus.value = 'disconnected'
165
+ stopPingInterval()
166
+
167
+ if (reconnectAttempts.value < maxReconnectAttempts) {
168
+ reconnectAttempts.value++
169
+ setTimeout(connect, 2000 * reconnectAttempts.value)
170
+ }
171
+ }
172
+
173
+ socket.onerror = () => {
174
+ connectionStatus.value = 'error'
175
+ }
176
+ }
177
+
178
+ function disconnect() {
179
+ stopPingInterval()
180
+ if (ws.value) {
181
+ ws.value.close()
182
+ ws.value = null
183
+ }
184
+ connectionStatus.value = 'disconnected'
185
+ isInitialized = false
186
+ }
187
+
188
+ function startPingInterval() {
189
+ if (pingInterval) clearInterval(pingInterval)
190
+ pingInterval = setInterval(() => {
191
+ if (ws.value?.readyState === WebSocket.OPEN)
192
+ ws.value.send(JSON.stringify({ type: 'ping' }))
193
+ }, 30000)
194
+ }
195
+
196
+ function stopPingInterval() {
197
+ if (pingInterval) {
198
+ clearInterval(pingInterval)
199
+ pingInterval = null
200
+ }
201
+ }
202
+
203
+ function sendMessage(message: string) {
204
+ if (!ws.value || ws.value.readyState !== WebSocket.OPEN) return
205
+
206
+ // Add user message locally
207
+ messages.value.push({
208
+ id: generateId(),
209
+ conversationId: activeConversationId.value || '',
210
+ role: 'user',
211
+ content: [{ type: 'text', text: message }],
212
+ createdAt: new Date()
213
+ })
214
+
215
+ ws.value.send(JSON.stringify({
216
+ type: 'chat:send',
217
+ message,
218
+ conversationId: activeConversationId.value
219
+ }))
220
+ }
221
+
222
+ function interrupt() {
223
+ if (!ws.value || !activeConversationId.value) return
224
+ ws.value.send(JSON.stringify({
225
+ type: 'chat:interrupt',
226
+ conversationId: activeConversationId.value
227
+ }))
228
+ }
229
+
230
+ function startNewConversation() {
231
+ activeConversationId.value = null
232
+ messages.value = []
233
+ sessionStatus.value = 'idle'
234
+ streamingText.value = ''
235
+ streamingToolCalls.value = {}
236
+ }
237
+
238
+ async function loadConversation(conversationId: string) {
239
+ loading.value = true
240
+ try {
241
+ const response = await $fetch<{ data: ChatConversation & { messages: ChatMessage[] } }>(`/api/conversations/${conversationId}`)
242
+ activeConversationId.value = conversationId
243
+ messages.value = (response.data.messages || []).map(m => ({
244
+ ...m,
245
+ createdAt: new Date(m.createdAt)
246
+ }))
247
+ sessionStatus.value = 'idle'
248
+ } catch (e) {
249
+ console.error('[chat] Failed to load conversation:', e)
250
+ } finally {
251
+ loading.value = false
252
+ }
253
+ }
254
+
255
+ async function loadConversations() {
256
+ try {
257
+ const response = await $fetch<{ data: ChatConversation[] }>('/api/conversations')
258
+ conversations.value = response.data
259
+ } catch (e) {
260
+ // Database may be unavailable — fail silently
261
+ console.error('[chat] Failed to load conversations:', e)
262
+ }
263
+ }
264
+
265
+ async function deleteConversation(conversationId: string) {
266
+ try {
267
+ await $fetch(`/api/conversations/${conversationId}`, { method: 'DELETE' })
268
+ conversations.value = conversations.value.filter(c => c.id !== conversationId)
269
+ if (activeConversationId.value === conversationId)
270
+ startNewConversation()
271
+ } catch (e) {
272
+ console.error('[chat] Failed to delete conversation:', e)
273
+ }
274
+ }
275
+
276
+ return {
277
+ // State
278
+ connectionStatus: readonly(connectionStatus),
279
+ sessionStatus: readonly(sessionStatus),
280
+ activeConversationId: readonly(activeConversationId),
281
+ messages,
282
+ conversations,
283
+ streamingText: readonly(streamingText),
284
+ streamingToolCalls: readonly(streamingToolCalls),
285
+ lastCostUsd: readonly(lastCostUsd),
286
+ loading: readonly(loading),
287
+
288
+ // Actions
289
+ connect,
290
+ disconnect,
291
+ sendMessage,
292
+ interrupt,
293
+ startNewConversation,
294
+ loadConversation,
295
+ loadConversations,
296
+ deleteConversation
297
+ }
298
+ }
@@ -0,0 +1,141 @@
1
+ import { useDebounceFn } from '@vueuse/core'
2
+ import type { Document, DocumentMetadata, DocumentWithContent, UpdateDocumentInput, SaveStatus } from '~~/shared/types'
3
+
4
+ export function useDocument() {
5
+ const document = ref<Document | null>(null)
6
+ const metadata = ref<DocumentMetadata | null>(null)
7
+ const body = ref('')
8
+ const filePath = ref<string | null>(null)
9
+ const loading = ref(false)
10
+ const saveStatus = ref<SaveStatus>('idle')
11
+ const isDirty = ref(false)
12
+ const metadataDirty = ref(false)
13
+
14
+ const debouncedSaveBody = useDebounceFn(async () => {
15
+ if (!document.value || !isDirty.value) return
16
+ await saveDocument({ body: body.value })
17
+ }, 2000)
18
+
19
+ const debouncedSaveMetadata = useDebounceFn(async () => {
20
+ if (!document.value || !metadataDirty.value) return
21
+ await saveDocument()
22
+ }, 500)
23
+
24
+ async function loadDocument(path: string) {
25
+ loading.value = true
26
+ filePath.value = path
27
+ isDirty.value = false
28
+ metadataDirty.value = false
29
+ saveStatus.value = 'idle'
30
+
31
+ try {
32
+ const response = await $fetch<{ data: DocumentWithContent }>('/api/documents/by-path', {
33
+ method: 'POST',
34
+ body: { path }
35
+ })
36
+
37
+ document.value = response.data.document
38
+ metadata.value = response.data.metadata
39
+ body.value = response.data.body || ''
40
+ } catch (e) {
41
+ console.error('Failed to load document:', e)
42
+ document.value = null
43
+ metadata.value = null
44
+ body.value = ''
45
+ } finally {
46
+ loading.value = false
47
+ }
48
+ }
49
+
50
+ function updateBody(newBody: string) {
51
+ if (body.value !== newBody) {
52
+ body.value = newBody
53
+ isDirty.value = true
54
+ debouncedSaveBody()
55
+ }
56
+ }
57
+
58
+ function updateMetadata(updates: Partial<DocumentMetadata>) {
59
+ if (!metadata.value) return
60
+
61
+ metadata.value = { ...metadata.value, ...updates }
62
+ metadataDirty.value = true
63
+ debouncedSaveMetadata()
64
+ }
65
+
66
+ async function saveDocument(updates: UpdateDocumentInput = {}) {
67
+ if (!document.value) return
68
+
69
+ saveStatus.value = 'saving'
70
+
71
+ try {
72
+ const payload: UpdateDocumentInput = { ...updates }
73
+
74
+ // Include metadata if dirty
75
+ if (metadataDirty.value && metadata.value) {
76
+ payload.title = metadata.value.title
77
+ payload.tags = metadata.value.tags
78
+ payload.projectId = metadata.value.projectId
79
+ payload.shared = metadata.value.shared
80
+ payload.shareType = metadata.value.shareType
81
+ }
82
+
83
+ // Include body if dirty and not already in updates
84
+ if (isDirty.value && updates.body === undefined)
85
+ payload.body = body.value
86
+
87
+ const response = await $fetch<{ data: DocumentWithContent }>(`/api/documents/${document.value.id}`, {
88
+ method: 'PUT',
89
+ body: payload
90
+ })
91
+
92
+ document.value = response.data.document
93
+ metadata.value = response.data.metadata
94
+ body.value = response.data.body || ''
95
+
96
+ isDirty.value = false
97
+ metadataDirty.value = false
98
+ saveStatus.value = 'saved'
99
+
100
+ setTimeout(() => {
101
+ if (saveStatus.value === 'saved')
102
+ saveStatus.value = 'idle'
103
+ }, 2000)
104
+ } catch (e) {
105
+ console.error('Failed to save document:', e)
106
+ saveStatus.value = 'error'
107
+ }
108
+ }
109
+
110
+ async function saveNow() {
111
+ if (!isDirty.value && !metadataDirty.value) return
112
+ await saveDocument({ body: body.value })
113
+ }
114
+
115
+ function closeDocument() {
116
+ document.value = null
117
+ metadata.value = null
118
+ body.value = ''
119
+ filePath.value = null
120
+ isDirty.value = false
121
+ metadataDirty.value = false
122
+ saveStatus.value = 'idle'
123
+ }
124
+
125
+ return {
126
+ document,
127
+ metadata,
128
+ body,
129
+ filePath,
130
+ loading,
131
+ saveStatus,
132
+ isDirty,
133
+ metadataDirty,
134
+ loadDocument,
135
+ updateBody,
136
+ updateMetadata,
137
+ saveDocument,
138
+ saveNow,
139
+ closeDocument
140
+ }
141
+ }
@@ -0,0 +1,100 @@
1
+ import { useDebounceFn } from '@vueuse/core'
2
+ import type { SaveStatus } from '~~/shared/types'
3
+
4
+ export function useEditor() {
5
+ const content = ref('')
6
+ const filePath = ref<string | null>(null)
7
+ const loading = ref(false)
8
+ const saveStatus = ref<SaveStatus>('idle')
9
+ const isDirty = ref(false)
10
+
11
+ const { writeFile } = useFileTree()
12
+
13
+ const debouncedSave = useDebounceFn(async () => {
14
+ if (!filePath.value || !isDirty.value) return
15
+
16
+ saveStatus.value = 'saving'
17
+ try {
18
+ await writeFile(filePath.value, content.value)
19
+ saveStatus.value = 'saved'
20
+ isDirty.value = false
21
+
22
+ // Reset to idle after showing saved status
23
+ setTimeout(() => {
24
+ if (saveStatus.value === 'saved') {
25
+ saveStatus.value = 'idle'
26
+ }
27
+ }, 2000)
28
+ } catch (e) {
29
+ console.error('Failed to save:', e)
30
+ saveStatus.value = 'error'
31
+ }
32
+ }, 2000)
33
+
34
+ async function loadFile(path: string) {
35
+ loading.value = true
36
+ filePath.value = path
37
+ isDirty.value = false
38
+ saveStatus.value = 'idle'
39
+
40
+ try {
41
+ const response = await $fetch<{ data: { content: string } }>('/api/fs/read', {
42
+ method: 'POST',
43
+ body: { path }
44
+ })
45
+ content.value = response.data.content
46
+ } catch (e) {
47
+ console.error('Failed to load file:', e)
48
+ content.value = ''
49
+ } finally {
50
+ loading.value = false
51
+ }
52
+ }
53
+
54
+ function updateContent(newContent: string) {
55
+ if (content.value !== newContent) {
56
+ content.value = newContent
57
+ isDirty.value = true
58
+ debouncedSave()
59
+ }
60
+ }
61
+
62
+ async function saveNow() {
63
+ if (!filePath.value || !isDirty.value) return
64
+
65
+ saveStatus.value = 'saving'
66
+ try {
67
+ await writeFile(filePath.value, content.value)
68
+ saveStatus.value = 'saved'
69
+ isDirty.value = false
70
+
71
+ setTimeout(() => {
72
+ if (saveStatus.value === 'saved') {
73
+ saveStatus.value = 'idle'
74
+ }
75
+ }, 2000)
76
+ } catch (e) {
77
+ console.error('Failed to save:', e)
78
+ saveStatus.value = 'error'
79
+ }
80
+ }
81
+
82
+ function closeFile() {
83
+ content.value = ''
84
+ filePath.value = null
85
+ isDirty.value = false
86
+ saveStatus.value = 'idle'
87
+ }
88
+
89
+ return {
90
+ content,
91
+ filePath,
92
+ loading,
93
+ saveStatus,
94
+ isDirty,
95
+ loadFile,
96
+ updateContent,
97
+ saveNow,
98
+ closeFile
99
+ }
100
+ }