clawport-ui 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.
- package/.env.example +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { getWorkPrompt, executeWork, persistWorkChat } from './automation'
|
|
3
|
+
import type { KanbanTicket } from './types'
|
|
4
|
+
|
|
5
|
+
/* ── Helpers ─────────────────────────────────────────── */
|
|
6
|
+
|
|
7
|
+
function makeTicket(overrides: Partial<KanbanTicket> = {}): KanbanTicket {
|
|
8
|
+
return {
|
|
9
|
+
id: 'ticket-1',
|
|
10
|
+
title: 'Build login page',
|
|
11
|
+
description: 'Implement login with email/password',
|
|
12
|
+
status: 'todo',
|
|
13
|
+
priority: 'high',
|
|
14
|
+
assigneeId: 'agent-1',
|
|
15
|
+
assigneeRole: 'lead-dev',
|
|
16
|
+
workState: 'idle',
|
|
17
|
+
workStartedAt: null,
|
|
18
|
+
workError: null,
|
|
19
|
+
workResult: null,
|
|
20
|
+
createdAt: 1000,
|
|
21
|
+
updatedAt: 1000,
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Mock localStorage
|
|
27
|
+
const storage: Record<string, string> = {}
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
Object.keys(storage).forEach((k) => delete storage[k])
|
|
30
|
+
vi.stubGlobal('localStorage', {
|
|
31
|
+
getItem: (key: string) => storage[key] ?? null,
|
|
32
|
+
setItem: (key: string, val: string) => { storage[key] = val },
|
|
33
|
+
removeItem: (key: string) => { delete storage[key] },
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Mock crypto.randomUUID
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
let counter = 0
|
|
40
|
+
vi.stubGlobal('crypto', {
|
|
41
|
+
randomUUID: () => `test-uuid-${++counter}`,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/* ── getWorkPrompt ───────────────────────────────────── */
|
|
46
|
+
|
|
47
|
+
describe('getWorkPrompt', () => {
|
|
48
|
+
it('returns lead-dev prompt for lead-dev role', () => {
|
|
49
|
+
const prompt = getWorkPrompt(makeTicket({ assigneeRole: 'lead-dev' }))
|
|
50
|
+
expect(prompt).toContain('Lead Dev')
|
|
51
|
+
expect(prompt).toContain('Technical breakdown')
|
|
52
|
+
expect(prompt).toContain('Implementation plan')
|
|
53
|
+
expect(prompt).toContain('Build login page')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns ux-ui prompt for ux-ui role', () => {
|
|
57
|
+
const prompt = getWorkPrompt(makeTicket({ assigneeRole: 'ux-ui' }))
|
|
58
|
+
expect(prompt).toContain('UX/UI Lead')
|
|
59
|
+
expect(prompt).toContain('Design review')
|
|
60
|
+
expect(prompt).toContain('Accessibility')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns qa prompt for qa role', () => {
|
|
64
|
+
const prompt = getWorkPrompt(makeTicket({ assigneeRole: 'qa' }))
|
|
65
|
+
expect(prompt).toContain('QA')
|
|
66
|
+
expect(prompt).toContain('Test scenarios')
|
|
67
|
+
expect(prompt).toContain('Acceptance criteria')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns fallback prompt when no role assigned', () => {
|
|
71
|
+
const prompt = getWorkPrompt(makeTicket({ assigneeRole: null }))
|
|
72
|
+
expect(prompt).toContain('Analysis of what needs to be done')
|
|
73
|
+
expect(prompt).toContain('Build login page')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('includes ticket description when present', () => {
|
|
77
|
+
const prompt = getWorkPrompt(makeTicket({ description: 'Custom desc' }))
|
|
78
|
+
expect(prompt).toContain('Description: Custom desc')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('handles empty description', () => {
|
|
82
|
+
const prompt = getWorkPrompt(makeTicket({ description: '' }))
|
|
83
|
+
expect(prompt).toContain('No description provided')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/* ── executeWork ─────────────────────────────────────── */
|
|
88
|
+
|
|
89
|
+
describe('executeWork', () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.restoreAllMocks()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns success with streamed content', async () => {
|
|
95
|
+
const sseData = [
|
|
96
|
+
'data: {"content":"Hello "}\n\n',
|
|
97
|
+
'data: {"content":"world"}\n\n',
|
|
98
|
+
'data: [DONE]\n\n',
|
|
99
|
+
].join('')
|
|
100
|
+
|
|
101
|
+
const stream = new ReadableStream({
|
|
102
|
+
start(controller) {
|
|
103
|
+
controller.enqueue(new TextEncoder().encode(sseData))
|
|
104
|
+
controller.close()
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
109
|
+
ok: true,
|
|
110
|
+
body: stream,
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
const result = await executeWork('agent-1', makeTicket())
|
|
114
|
+
expect(result.success).toBe(true)
|
|
115
|
+
expect(result.content).toBe('Hello world')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('calls onChunk for each SSE chunk', async () => {
|
|
119
|
+
const sseData = 'data: {"content":"A"}\n\ndata: {"content":"B"}\n\ndata: [DONE]\n\n'
|
|
120
|
+
|
|
121
|
+
const stream = new ReadableStream({
|
|
122
|
+
start(controller) {
|
|
123
|
+
controller.enqueue(new TextEncoder().encode(sseData))
|
|
124
|
+
controller.close()
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
129
|
+
ok: true,
|
|
130
|
+
body: stream,
|
|
131
|
+
}))
|
|
132
|
+
|
|
133
|
+
const chunks: string[] = []
|
|
134
|
+
await executeWork('agent-1', makeTicket(), (c) => chunks.push(c))
|
|
135
|
+
expect(chunks).toEqual(['A', 'B'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('returns error on non-ok response', async () => {
|
|
139
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
140
|
+
ok: false,
|
|
141
|
+
status: 500,
|
|
142
|
+
body: null,
|
|
143
|
+
}))
|
|
144
|
+
|
|
145
|
+
const result = await executeWork('agent-1', makeTicket())
|
|
146
|
+
expect(result.success).toBe(false)
|
|
147
|
+
expect(result.error).toContain('500')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('returns error on empty response', async () => {
|
|
151
|
+
const stream = new ReadableStream({
|
|
152
|
+
start(controller) {
|
|
153
|
+
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
|
|
154
|
+
controller.close()
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
159
|
+
ok: true,
|
|
160
|
+
body: stream,
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
const result = await executeWork('agent-1', makeTicket())
|
|
164
|
+
expect(result.success).toBe(false)
|
|
165
|
+
expect(result.error).toContain('Empty response')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('returns error on network failure', async () => {
|
|
169
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
|
|
170
|
+
|
|
171
|
+
const result = await executeWork('agent-1', makeTicket())
|
|
172
|
+
expect(result.success).toBe(false)
|
|
173
|
+
expect(result.error).toBe('Network down')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('skips malformed SSE chunks gracefully', async () => {
|
|
177
|
+
const sseData = 'data: {"content":"Good"}\n\ndata: not-json\n\ndata: {"content":"Also good"}\n\ndata: [DONE]\n\n'
|
|
178
|
+
|
|
179
|
+
const stream = new ReadableStream({
|
|
180
|
+
start(controller) {
|
|
181
|
+
controller.enqueue(new TextEncoder().encode(sseData))
|
|
182
|
+
controller.close()
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
187
|
+
ok: true,
|
|
188
|
+
body: stream,
|
|
189
|
+
}))
|
|
190
|
+
|
|
191
|
+
const result = await executeWork('agent-1', makeTicket())
|
|
192
|
+
expect(result.success).toBe(true)
|
|
193
|
+
expect(result.content).toBe('GoodAlso good')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/* ── persistWorkChat ─────────────────────────────────── */
|
|
198
|
+
|
|
199
|
+
describe('persistWorkChat', () => {
|
|
200
|
+
let fetchMock: ReturnType<typeof vi.fn>
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ ok: true }) })
|
|
204
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('posts prompt and response to chat-history API', () => {
|
|
208
|
+
persistWorkChat('ticket-1', 'Do the work', 'Here is the result')
|
|
209
|
+
|
|
210
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
211
|
+
'/api/kanban/chat-history/ticket-1',
|
|
212
|
+
expect.objectContaining({
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'Content-Type': 'application/json' },
|
|
215
|
+
}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
|
219
|
+
expect(body.messages).toHaveLength(2)
|
|
220
|
+
expect(body.messages[0].role).toBe('user')
|
|
221
|
+
expect(body.messages[0].content).toBe('Do the work')
|
|
222
|
+
expect(body.messages[1].role).toBe('assistant')
|
|
223
|
+
expect(body.messages[1].content).toBe('Here is the result')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('generates unique IDs for messages', () => {
|
|
227
|
+
persistWorkChat('ticket-1', 'Prompt', 'Response')
|
|
228
|
+
|
|
229
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
|
230
|
+
expect(body.messages[0].id).toBe('test-uuid-1')
|
|
231
|
+
expect(body.messages[1].id).toBe('test-uuid-2')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('sets assistant timestamp 1ms after user timestamp', () => {
|
|
235
|
+
persistWorkChat('ticket-1', 'Prompt', 'Response')
|
|
236
|
+
|
|
237
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
|
238
|
+
expect(body.messages[1].timestamp).toBe(body.messages[0].timestamp + 1)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('does not throw when fetch fails', () => {
|
|
242
|
+
fetchMock.mockRejectedValue(new Error('Network error'))
|
|
243
|
+
expect(() => persistWorkChat('ticket-1', 'Prompt', 'Response')).not.toThrow()
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { KanbanTicket, TeamRole } from './types'
|
|
4
|
+
|
|
5
|
+
/* ── Role-specific work prompts ──────────────────────── */
|
|
6
|
+
|
|
7
|
+
const ROLE_PROMPTS: Record<TeamRole, string> = {
|
|
8
|
+
'lead-dev': `You are working this ticket as the Lead Dev. Provide:
|
|
9
|
+
1. Technical breakdown of the work needed
|
|
10
|
+
2. Implementation plan with clear steps
|
|
11
|
+
3. Key technical decisions or trade-offs
|
|
12
|
+
4. Dependencies or blockers to flag
|
|
13
|
+
|
|
14
|
+
Be specific and actionable. Reference concrete files, APIs, or patterns where relevant.`,
|
|
15
|
+
|
|
16
|
+
'ux-ui': `You are working this ticket as the UX/UI Lead. Provide:
|
|
17
|
+
1. Design review and recommendations
|
|
18
|
+
2. User flow walkthrough
|
|
19
|
+
3. Accessibility considerations (WCAG)
|
|
20
|
+
4. Visual/interaction suggestions
|
|
21
|
+
|
|
22
|
+
Focus on the user experience. Call out any usability concerns or improvements.`,
|
|
23
|
+
|
|
24
|
+
'qa': `You are working this ticket as QA. Provide:
|
|
25
|
+
1. Test scenarios (happy path + edge cases)
|
|
26
|
+
2. Acceptance criteria checklist
|
|
27
|
+
3. Potential regression areas
|
|
28
|
+
4. Edge cases and boundary conditions to verify
|
|
29
|
+
|
|
30
|
+
Be thorough. Think about what could break and how to verify it works.`,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FALLBACK_PROMPT = `You are working this ticket. Provide:
|
|
34
|
+
1. Analysis of what needs to be done
|
|
35
|
+
2. Recommended approach
|
|
36
|
+
3. Key considerations or risks
|
|
37
|
+
4. Next steps
|
|
38
|
+
|
|
39
|
+
Be concise and actionable.`
|
|
40
|
+
|
|
41
|
+
export function getWorkPrompt(ticket: KanbanTicket): string {
|
|
42
|
+
const rolePrompt = ticket.assigneeRole
|
|
43
|
+
? ROLE_PROMPTS[ticket.assigneeRole] ?? FALLBACK_PROMPT
|
|
44
|
+
: FALLBACK_PROMPT
|
|
45
|
+
|
|
46
|
+
return `${rolePrompt}
|
|
47
|
+
|
|
48
|
+
Ticket: ${ticket.title}
|
|
49
|
+
${ticket.description ? `Description: ${ticket.description}` : 'No description provided.'}
|
|
50
|
+
Priority: ${ticket.priority}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ── Execute work via chat API ───────────────────────── */
|
|
54
|
+
|
|
55
|
+
interface WorkResult {
|
|
56
|
+
success: boolean
|
|
57
|
+
content: string
|
|
58
|
+
error?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function executeWork(
|
|
62
|
+
agentId: string,
|
|
63
|
+
ticket: KanbanTicket,
|
|
64
|
+
onChunk?: (chunk: string) => void,
|
|
65
|
+
): Promise<WorkResult> {
|
|
66
|
+
const prompt = getWorkPrompt(ticket)
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`/api/kanban/chat/${agentId}`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
messages: [{ role: 'user', content: prompt }],
|
|
74
|
+
ticket: {
|
|
75
|
+
title: ticket.title,
|
|
76
|
+
description: ticket.description,
|
|
77
|
+
status: ticket.status,
|
|
78
|
+
priority: ticket.priority,
|
|
79
|
+
assigneeRole: ticket.assigneeRole,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (!res.ok || !res.body) {
|
|
85
|
+
return { success: false, content: '', error: `API error: ${res.status}` }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const reader = res.body.getReader()
|
|
89
|
+
const decoder = new TextDecoder()
|
|
90
|
+
let buffer = ''
|
|
91
|
+
let fullContent = ''
|
|
92
|
+
|
|
93
|
+
while (true) {
|
|
94
|
+
const { done, value } = await reader.read()
|
|
95
|
+
if (done) break
|
|
96
|
+
|
|
97
|
+
buffer += decoder.decode(value, { stream: true })
|
|
98
|
+
const lines = buffer.split('\n')
|
|
99
|
+
buffer = lines.pop() || ''
|
|
100
|
+
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
|
103
|
+
try {
|
|
104
|
+
const chunk = JSON.parse(line.slice(6))
|
|
105
|
+
if (chunk.content) {
|
|
106
|
+
fullContent += chunk.content
|
|
107
|
+
onChunk?.(chunk.content)
|
|
108
|
+
}
|
|
109
|
+
} catch { /* skip malformed chunks */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!fullContent) {
|
|
115
|
+
return { success: false, content: '', error: 'Empty response from agent' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { success: true, content: fullContent }
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
121
|
+
return { success: false, content: '', error: message }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ── Persist work chat to filesystem via API ─────────── */
|
|
126
|
+
|
|
127
|
+
export function persistWorkChat(
|
|
128
|
+
ticketId: string,
|
|
129
|
+
prompt: string,
|
|
130
|
+
response: string,
|
|
131
|
+
): void {
|
|
132
|
+
const now = Date.now()
|
|
133
|
+
const messages = [
|
|
134
|
+
{ id: crypto.randomUUID(), role: 'user' as const, content: prompt, timestamp: now },
|
|
135
|
+
{ id: crypto.randomUUID(), role: 'assistant' as const, content: response, timestamp: now + 1 },
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
fetch(`/api/kanban/chat-history/${ticketId}`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
body: JSON.stringify({ messages }),
|
|
142
|
+
}).catch(() => { /* persist best-effort */ })
|
|
143
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const { mockReadFileSync, mockAppendFileSync, mockMkdirSync, mockExistsSync } = vi.hoisted(() => ({
|
|
5
|
+
mockReadFileSync: vi.fn(),
|
|
6
|
+
mockAppendFileSync: vi.fn(),
|
|
7
|
+
mockMkdirSync: vi.fn(),
|
|
8
|
+
mockExistsSync: vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('fs', () => ({
|
|
12
|
+
readFileSync: mockReadFileSync,
|
|
13
|
+
appendFileSync: mockAppendFileSync,
|
|
14
|
+
mkdirSync: mockMkdirSync,
|
|
15
|
+
existsSync: mockExistsSync,
|
|
16
|
+
default: {
|
|
17
|
+
readFileSync: mockReadFileSync,
|
|
18
|
+
appendFileSync: mockAppendFileSync,
|
|
19
|
+
mkdirSync: mockMkdirSync,
|
|
20
|
+
existsSync: mockExistsSync,
|
|
21
|
+
},
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
import { getChatMessages, appendChatMessages, StoredChatMessage } from './chat-store'
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks()
|
|
28
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
|
|
29
|
+
mockExistsSync.mockReturnValue(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('getChatMessages', () => {
|
|
33
|
+
it('parses JSONL lines and returns sorted oldest-first', () => {
|
|
34
|
+
const lines = [
|
|
35
|
+
JSON.stringify({ id: 'c', role: 'assistant', content: 'last', timestamp: 3000 }),
|
|
36
|
+
JSON.stringify({ id: 'a', role: 'user', content: 'first', timestamp: 1000 }),
|
|
37
|
+
JSON.stringify({ id: 'b', role: 'assistant', content: 'second', timestamp: 2000 }),
|
|
38
|
+
].join('\n')
|
|
39
|
+
|
|
40
|
+
mockReadFileSync.mockReturnValue(lines)
|
|
41
|
+
|
|
42
|
+
const messages = getChatMessages('ticket-1')
|
|
43
|
+
expect(messages).toHaveLength(3)
|
|
44
|
+
expect(messages[0].id).toBe('a')
|
|
45
|
+
expect(messages[0].timestamp).toBe(1000)
|
|
46
|
+
expect(messages[1].id).toBe('b')
|
|
47
|
+
expect(messages[2].id).toBe('c')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns empty array when file does not exist', () => {
|
|
51
|
+
mockExistsSync.mockReturnValue(false)
|
|
52
|
+
const messages = getChatMessages('missing-ticket')
|
|
53
|
+
expect(messages).toEqual([])
|
|
54
|
+
expect(mockReadFileSync).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns empty array when file is empty', () => {
|
|
58
|
+
mockReadFileSync.mockReturnValue('')
|
|
59
|
+
const messages = getChatMessages('empty-ticket')
|
|
60
|
+
expect(messages).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('skips malformed JSON lines', () => {
|
|
64
|
+
const lines = [
|
|
65
|
+
'not valid json',
|
|
66
|
+
JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 1000 }),
|
|
67
|
+
'{ broken',
|
|
68
|
+
'',
|
|
69
|
+
].join('\n')
|
|
70
|
+
|
|
71
|
+
mockReadFileSync.mockReturnValue(lines)
|
|
72
|
+
|
|
73
|
+
const messages = getChatMessages('ticket-1')
|
|
74
|
+
expect(messages).toHaveLength(1)
|
|
75
|
+
expect(messages[0].id).toBe('a')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('skips lines with missing required fields', () => {
|
|
79
|
+
const lines = [
|
|
80
|
+
JSON.stringify({ role: 'user', content: 'no id', timestamp: 1000 }),
|
|
81
|
+
JSON.stringify({ id: '', role: 'user', content: 'empty id', timestamp: 1000 }),
|
|
82
|
+
JSON.stringify({ id: 'a', role: 'system', content: 'bad role', timestamp: 1000 }),
|
|
83
|
+
JSON.stringify({ id: 'b', role: 'user', content: 'valid', timestamp: 2000 }),
|
|
84
|
+
].join('\n')
|
|
85
|
+
|
|
86
|
+
mockReadFileSync.mockReturnValue(lines)
|
|
87
|
+
|
|
88
|
+
const messages = getChatMessages('ticket-1')
|
|
89
|
+
expect(messages).toHaveLength(1)
|
|
90
|
+
expect(messages[0].id).toBe('b')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('handles unreadable files gracefully', () => {
|
|
94
|
+
mockReadFileSync.mockImplementation(() => { throw new Error('permission denied') })
|
|
95
|
+
const messages = getChatMessages('ticket-1')
|
|
96
|
+
expect(messages).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('defaults timestamp to 0 for non-numeric values', () => {
|
|
100
|
+
const lines = JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 'bad' })
|
|
101
|
+
mockReadFileSync.mockReturnValue(lines)
|
|
102
|
+
|
|
103
|
+
const messages = getChatMessages('ticket-1')
|
|
104
|
+
expect(messages).toHaveLength(1)
|
|
105
|
+
expect(messages[0].timestamp).toBe(0)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('appendChatMessages', () => {
|
|
110
|
+
it('creates directory and appends messages as JSONL', () => {
|
|
111
|
+
const messages: StoredChatMessage[] = [
|
|
112
|
+
{ id: 'a', role: 'user', content: 'hello', timestamp: 1000 },
|
|
113
|
+
{ id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
appendChatMessages('ticket-1', messages)
|
|
117
|
+
|
|
118
|
+
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
119
|
+
expect.stringContaining('kanban/chats'),
|
|
120
|
+
{ recursive: true },
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const written = mockAppendFileSync.mock.calls[0][1] as string
|
|
124
|
+
const lines = written.trim().split('\n')
|
|
125
|
+
expect(lines).toHaveLength(2)
|
|
126
|
+
expect(JSON.parse(lines[0])).toEqual({ id: 'a', role: 'user', content: 'hello', timestamp: 1000 })
|
|
127
|
+
expect(JSON.parse(lines[1])).toEqual({ id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('appends single message correctly', () => {
|
|
131
|
+
const messages: StoredChatMessage[] = [
|
|
132
|
+
{ id: 'x', role: 'user', content: 'test', timestamp: 5000 },
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
appendChatMessages('ticket-2', messages)
|
|
136
|
+
|
|
137
|
+
const written = mockAppendFileSync.mock.calls[0][1] as string
|
|
138
|
+
expect(written).toBe('{"id":"x","role":"user","content":"test","timestamp":5000}\n')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('writes to correct file path based on ticketId', () => {
|
|
142
|
+
appendChatMessages('my-ticket-id', [
|
|
143
|
+
{ id: 'a', role: 'user', content: 'hi', timestamp: 1000 },
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
const filePath = mockAppendFileSync.mock.calls[0][0] as string
|
|
147
|
+
expect(filePath).toContain('my-ticket-id.jsonl')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { requireEnv } from '@/lib/env'
|
|
4
|
+
|
|
5
|
+
/** Serializable chat message (no isStreaming — UI-only field) */
|
|
6
|
+
export interface StoredChatMessage {
|
|
7
|
+
id: string
|
|
8
|
+
role: 'user' | 'assistant'
|
|
9
|
+
content: string
|
|
10
|
+
timestamp: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Derive the chats directory from WORKSPACE_PATH */
|
|
14
|
+
function getChatsDir(): string {
|
|
15
|
+
return path.resolve(requireEnv('WORKSPACE_PATH'), '..', 'kanban', 'chats')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a single JSONL line into a StoredChatMessage.
|
|
20
|
+
* Returns null if the line can't be parsed or is missing required fields.
|
|
21
|
+
*/
|
|
22
|
+
function parseLine(line: string): StoredChatMessage | null {
|
|
23
|
+
if (!line.trim()) return null
|
|
24
|
+
try {
|
|
25
|
+
const obj = JSON.parse(line)
|
|
26
|
+
if (typeof obj.id !== 'string' || !obj.id) return null
|
|
27
|
+
if (obj.role !== 'user' && obj.role !== 'assistant') return null
|
|
28
|
+
if (typeof obj.content !== 'string') return null
|
|
29
|
+
return {
|
|
30
|
+
id: obj.id,
|
|
31
|
+
role: obj.role,
|
|
32
|
+
content: obj.content,
|
|
33
|
+
timestamp: typeof obj.timestamp === 'number' ? obj.timestamp : 0,
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read chat messages for a ticket from its JSONL file.
|
|
42
|
+
* Returns StoredChatMessage[] sorted oldest-first by timestamp.
|
|
43
|
+
*/
|
|
44
|
+
export function getChatMessages(ticketId: string): StoredChatMessage[] {
|
|
45
|
+
const chatsDir = getChatsDir()
|
|
46
|
+
const filePath = path.join(chatsDir, `${ticketId}.jsonl`)
|
|
47
|
+
|
|
48
|
+
if (!existsSync(filePath)) return []
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
52
|
+
const messages: StoredChatMessage[] = []
|
|
53
|
+
for (const line of content.split('\n')) {
|
|
54
|
+
const msg = parseLine(line)
|
|
55
|
+
if (msg) messages.push(msg)
|
|
56
|
+
}
|
|
57
|
+
messages.sort((a, b) => a.timestamp - b.timestamp)
|
|
58
|
+
return messages
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Append chat messages to a ticket's JSONL file.
|
|
66
|
+
* Creates the chats directory and file if they don't exist.
|
|
67
|
+
*/
|
|
68
|
+
export function appendChatMessages(ticketId: string, messages: StoredChatMessage[]): void {
|
|
69
|
+
const chatsDir = getChatsDir()
|
|
70
|
+
mkdirSync(chatsDir, { recursive: true })
|
|
71
|
+
|
|
72
|
+
const filePath = path.join(chatsDir, `${ticketId}.jsonl`)
|
|
73
|
+
const lines = messages.map(m => JSON.stringify({
|
|
74
|
+
id: m.id,
|
|
75
|
+
role: m.role,
|
|
76
|
+
content: m.content,
|
|
77
|
+
timestamp: m.timestamp,
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
appendFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
|
|
81
|
+
}
|