jblyons15-research-ui 1.0.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/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "jblyons15-research-ui",
3
+ "version": "1.0.0",
4
+ "description": "React UI components for CommandCenter research system",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "keywords": [
8
+ "research",
9
+ "react",
10
+ "components",
11
+ "ui"
12
+ ],
13
+ "author": "Crossover Research",
14
+ "license": "MIT",
15
+ "peerDependencies": {
16
+ "react": "^18.2.0",
17
+ "react-dom": "^18.2.0"
18
+ },
19
+ "dependencies": {
20
+ "jblyons15-research-sdk": "^1.0.0",
21
+ "jblyons15-research-types": "^1.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^18.2.0",
25
+ "@types/react-dom": "^18.2.0",
26
+ "react": "^18.2.0",
27
+ "react-dom": "^18.2.0",
28
+ "typescript": "^5.0.0"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "type-check": "tsc --noEmit"
33
+ }
34
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,463 @@
1
+ // @crossover/research-ui - Complete Package Definition
2
+ // React components for research requests, results, and history
3
+
4
+ import React, { useState, useEffect } from 'react'
5
+ import type {
6
+ ResearchRequest,
7
+ ResearchResult,
8
+ ResearchJobStatus,
9
+ ResearchStatus,
10
+ ResearchError,
11
+ } from '@crossover/research-types'
12
+ import { ResearchOrchestrator } from '@crossover/research-sdk'
13
+
14
+ // ============================================================================
15
+ // HOOK: useResearchOrchestrator
16
+ // ============================================================================
17
+
18
+ export function useResearchOrchestrator(config?: {
19
+ perplexityApiKey?: string
20
+ firecrawlApiKey?: string
21
+ supabaseUrl?: string
22
+ supabaseAnonKey?: string
23
+ }): ResearchOrchestrator {
24
+ const [orchestrator] = useState(() => {
25
+ // In real usage, get these from environment or context
26
+ return new ResearchOrchestrator({
27
+ perplexityApiKey: config?.perplexityApiKey || process.env.REACT_APP_PERPLEXITY_KEY || '',
28
+ firecrawlApiKey: config?.firecrawlApiKey || process.env.REACT_APP_FIRECRAWL_KEY || '',
29
+ supabaseUrl: config?.supabaseUrl || process.env.REACT_APP_SUPABASE_URL || '',
30
+ supabaseAnonKey: config?.supabaseAnonKey || process.env.REACT_APP_SUPABASE_ANON_KEY || '',
31
+ })
32
+ })
33
+
34
+ return orchestrator
35
+ }
36
+
37
+ // ============================================================================
38
+ // HOOK: useResearchStatus - Real-time job status polling
39
+ // ============================================================================
40
+
41
+ export function useResearchStatus(jobId: string, orchestrator: ResearchOrchestrator) {
42
+ const [status, setStatus] = useState<ResearchJobStatus | null>(null)
43
+ const [loading, setLoading] = useState(true)
44
+ const [error, setError] = useState<ResearchError | null>(null)
45
+
46
+ useEffect(() => {
47
+ let isMounted = true
48
+ let pollInterval: NodeJS.Timeout
49
+
50
+ const fetchStatus = async () => {
51
+ try {
52
+ const result = await orchestrator.getStatus(jobId)
53
+ if (isMounted) {
54
+ setStatus(result)
55
+ setLoading(false)
56
+ }
57
+
58
+ // Continue polling if not completed
59
+ if (result && result.status !== 'completed' && result.status !== 'failed') {
60
+ pollInterval = setTimeout(fetchStatus, 2000) // Poll every 2s
61
+ }
62
+ } catch (err) {
63
+ if (isMounted) {
64
+ setError(err as ResearchError)
65
+ setLoading(false)
66
+ }
67
+ }
68
+ }
69
+
70
+ fetchStatus()
71
+
72
+ return () => {
73
+ isMounted = false
74
+ if (pollInterval) clearTimeout(pollInterval)
75
+ }
76
+ }, [jobId, orchestrator])
77
+
78
+ return { status, loading, error }
79
+ }
80
+
81
+ // ============================================================================
82
+ // COMPONENT: RequestForm - Submit research requests
83
+ // ============================================================================
84
+
85
+ interface RequestFormProps {
86
+ onSubmit?: (jobId: string) => void
87
+ onError?: (error: ResearchError) => void
88
+ defaultCompany?: string
89
+ }
90
+
91
+ export function RequestForm({
92
+ onSubmit,
93
+ onError,
94
+ defaultCompany = '',
95
+ }: RequestFormProps) {
96
+ const orchestrator = useResearchOrchestrator()
97
+ const [loading, setLoading] = useState(false)
98
+ const [company, setCompany] = useState(defaultCompany)
99
+ const [questions, setQuestions] = useState<string[]>([''])
100
+ const [format, setFormat] = useState<'summary' | 'detailed' | 'brief'>('summary')
101
+ const [timeline, setTimeline] = useState<'urgent' | 'normal' | 'flexible'>('normal')
102
+ const [context, setContext] = useState('')
103
+
104
+ const handleAddQuestion = () => {
105
+ setQuestions([...questions, ''])
106
+ }
107
+
108
+ const handleRemoveQuestion = (index: number) => {
109
+ setQuestions(questions.filter((_, i) => i !== index))
110
+ }
111
+
112
+ const handleQuestionChange = (index: number, value: string) => {
113
+ const updated = [...questions]
114
+ updated[index] = value
115
+ setQuestions(updated)
116
+ }
117
+
118
+ const handleSubmit = async (e: React.FormEvent) => {
119
+ e.preventDefault()
120
+ setLoading(true)
121
+
122
+ try {
123
+ const jobId = await orchestrator.submitRequest({
124
+ company,
125
+ questions: questions.filter(q => q.trim()),
126
+ format,
127
+ timeline,
128
+ context: context || undefined,
129
+ submittedBy: 'current-user-id', // Would get from auth context
130
+ })
131
+
132
+ onSubmit?.(jobId)
133
+ } catch (error) {
134
+ onError?.(error as ResearchError)
135
+ } finally {
136
+ setLoading(false)
137
+ }
138
+ }
139
+
140
+ return (
141
+ <form onSubmit={handleSubmit} className="space-y-6">
142
+ <div>
143
+ <label className="block text-sm font-medium mb-2">Company Name</label>
144
+ <input
145
+ type="text"
146
+ value={company}
147
+ onChange={(e) => setCompany(e.target.value)}
148
+ required
149
+ placeholder="e.g., Acme Corp"
150
+ className="w-full px-3 py-2 border rounded-md"
151
+ />
152
+ </div>
153
+
154
+ <div>
155
+ <label className="block text-sm font-medium mb-2">Research Questions</label>
156
+ {questions.map((q, i) => (
157
+ <div key={i} className="flex gap-2 mb-2">
158
+ <input
159
+ type="text"
160
+ value={q}
161
+ onChange={(e) => handleQuestionChange(i, e.target.value)}
162
+ placeholder={`Question ${i + 1}`}
163
+ className="flex-1 px-3 py-2 border rounded-md"
164
+ />
165
+ {questions.length > 1 && (
166
+ <button
167
+ type="button"
168
+ onClick={() => handleRemoveQuestion(i)}
169
+ className="px-3 py-2 bg-red-50 text-red-600 rounded-md"
170
+ >
171
+ Remove
172
+ </button>
173
+ )}
174
+ </div>
175
+ ))}
176
+ <button
177
+ type="button"
178
+ onClick={handleAddQuestion}
179
+ className="mt-2 px-3 py-2 bg-blue-50 text-blue-600 rounded-md"
180
+ >
181
+ Add Question
182
+ </button>
183
+ </div>
184
+
185
+ <div className="grid grid-cols-2 gap-4">
186
+ <div>
187
+ <label className="block text-sm font-medium mb-2">Format</label>
188
+ <select
189
+ value={format}
190
+ onChange={(e) => setFormat(e.target.value as any)}
191
+ className="w-full px-3 py-2 border rounded-md"
192
+ >
193
+ <option value="summary">Executive Summary</option>
194
+ <option value="detailed">Detailed Analysis</option>
195
+ <option value="brief">Quick Brief</option>
196
+ </select>
197
+ </div>
198
+
199
+ <div>
200
+ <label className="block text-sm font-medium mb-2">Timeline</label>
201
+ <select
202
+ value={timeline}
203
+ onChange={(e) => setTimeline(e.target.value as any)}
204
+ className="w-full px-3 py-2 border rounded-md"
205
+ >
206
+ <option value="urgent">Urgent (24h)</option>
207
+ <option value="normal">Normal (3-5d)</option>
208
+ <option value="flexible">Flexible (1-2w)</option>
209
+ </select>
210
+ </div>
211
+ </div>
212
+
213
+ <div>
214
+ <label className="block text-sm font-medium mb-2">Additional Context (optional)</label>
215
+ <textarea
216
+ value={context}
217
+ onChange={(e) => setContext(e.target.value)}
218
+ placeholder="Any additional details or constraints..."
219
+ rows={3}
220
+ className="w-full px-3 py-2 border rounded-md"
221
+ />
222
+ </div>
223
+
224
+ <button
225
+ type="submit"
226
+ disabled={loading}
227
+ className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
228
+ >
229
+ {loading ? 'Submitting...' : 'Submit Research Request'}
230
+ </button>
231
+ </form>
232
+ )
233
+ }
234
+
235
+ // ============================================================================
236
+ // COMPONENT: ResultsViewer - Display research results
237
+ // ============================================================================
238
+
239
+ interface ResultsViewerProps {
240
+ jobId: string
241
+ orchestrator: ResearchOrchestrator
242
+ }
243
+
244
+ export function ResultsViewer({ jobId, orchestrator }: ResultsViewerProps) {
245
+ const { status, loading, error } = useResearchStatus(jobId, orchestrator)
246
+ const [results, setResults] = useState<ResearchResult | null>(null)
247
+
248
+ useEffect(() => {
249
+ if (status?.status === 'completed') {
250
+ orchestrator.getResults(jobId).then(setResults)
251
+ }
252
+ }, [status?.status, jobId, orchestrator])
253
+
254
+ if (loading) {
255
+ return <div className="p-4 text-center">Loading...</div>
256
+ }
257
+
258
+ if (error) {
259
+ return (
260
+ <div className="p-4 bg-red-50 border border-red-200 rounded-md">
261
+ <p className="text-red-600 font-medium">Error: {error.message}</p>
262
+ {error.details && <pre className="mt-2 text-sm">{JSON.stringify(error.details, null, 2)}</pre>}
263
+ </div>
264
+ )
265
+ }
266
+
267
+ if (!status) {
268
+ return <div className="p-4 text-center">No results found</div>
269
+ }
270
+
271
+ return (
272
+ <div className="space-y-6">
273
+ {/* Status */}
274
+ <div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
275
+ <div className="flex items-center justify-between">
276
+ <div>
277
+ <p className="font-medium">Status: {status.status}</p>
278
+ <p className="text-sm text-gray-600">Progress: {status.progress}%</p>
279
+ </div>
280
+ <div className="w-32 h-2 bg-gray-300 rounded-full overflow-hidden">
281
+ <div
282
+ className="h-full bg-blue-600 transition-all"
283
+ style={{ width: `${status.progress}%` }}
284
+ />
285
+ </div>
286
+ </div>
287
+ </div>
288
+
289
+ {/* Results */}
290
+ {results && (
291
+ <>
292
+ {/* Synthesis */}
293
+ <div>
294
+ <h3 className="text-lg font-medium mb-2">Research Synthesis</h3>
295
+ <div className="p-4 bg-gray-50 border rounded-md">
296
+ <p className="text-sm">{results.synthesis}</p>
297
+ </div>
298
+ </div>
299
+
300
+ {/* Sources */}
301
+ <div>
302
+ <h3 className="text-lg font-medium mb-2">Sources</h3>
303
+ <div className="space-y-2">
304
+ {results.sources.map((source, i) => (
305
+ <div key={i} className="p-3 bg-gray-50 border rounded-md">
306
+ <a href={source.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">
307
+ {source.title}
308
+ </a>
309
+ <p className="text-xs text-gray-500 mt-1">{source.url}</p>
310
+ <p className="text-sm text-gray-700 mt-1">{source.snippet}</p>
311
+ <p className="text-xs text-gray-500 mt-1">Confidence: {(source.confidence * 100).toFixed(0)}%</p>
312
+ </div>
313
+ ))}
314
+ </div>
315
+ </div>
316
+
317
+ {/* Metadata */}
318
+ <div className="grid grid-cols-3 gap-4">
319
+ <div className="p-3 bg-gray-50 border rounded-md">
320
+ <p className="text-xs text-gray-500">Cost</p>
321
+ <p className="font-medium">${results.cost.toFixed(2)}</p>
322
+ </div>
323
+ <div className="p-3 bg-gray-50 border rounded-md">
324
+ <p className="text-xs text-gray-500">Latency</p>
325
+ <p className="font-medium">{results.latency_ms}ms</p>
326
+ </div>
327
+ <div className="p-3 bg-gray-50 border rounded-md">
328
+ <p className="text-xs text-gray-500">Confidence</p>
329
+ <p className="font-medium">{(results.confidence * 100).toFixed(0)}%</p>
330
+ </div>
331
+ </div>
332
+
333
+ {/* Export */}
334
+ <div className="flex gap-2">
335
+ <button
336
+ onClick={() => {
337
+ const json = JSON.stringify(results, null, 2)
338
+ const blob = new Blob([json], { type: 'application/json' })
339
+ const url = URL.createObjectURL(blob)
340
+ const a = document.createElement('a')
341
+ a.href = url
342
+ a.download = `research-${jobId}.json`
343
+ a.click()
344
+ }}
345
+ className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
346
+ >
347
+ Export JSON
348
+ </button>
349
+ </div>
350
+ </>
351
+ )}
352
+ </div>
353
+ )
354
+ }
355
+
356
+ // ============================================================================
357
+ // COMPONENT: ResearchCard - Compact research result display
358
+ // ============================================================================
359
+
360
+ interface ResearchCardProps {
361
+ jobId: string
362
+ company: string
363
+ status: ResearchStatus
364
+ progress: number
365
+ onClick?: () => void
366
+ }
367
+
368
+ export function ResearchCard({
369
+ jobId,
370
+ company,
371
+ status,
372
+ progress,
373
+ onClick,
374
+ }: ResearchCardProps) {
375
+ const statusColor = {
376
+ pending: 'bg-gray-100 text-gray-700',
377
+ running: 'bg-blue-100 text-blue-700',
378
+ completed: 'bg-green-100 text-green-700',
379
+ failed: 'bg-red-100 text-red-700',
380
+ }
381
+
382
+ return (
383
+ <div
384
+ onClick={onClick}
385
+ className="p-4 border rounded-md hover:bg-gray-50 cursor-pointer transition"
386
+ >
387
+ <div className="flex items-start justify-between">
388
+ <div>
389
+ <h4 className="font-medium">{company}</h4>
390
+ <p className="text-xs text-gray-500">ID: {jobId.slice(0, 8)}...</p>
391
+ </div>
392
+ <span className={`px-2 py-1 rounded text-xs font-medium ${statusColor[status]}`}>
393
+ {status}
394
+ </span>
395
+ </div>
396
+ <div className="mt-2 w-full h-2 bg-gray-300 rounded-full overflow-hidden">
397
+ <div
398
+ className="h-full bg-blue-600 transition-all"
399
+ style={{ width: `${progress}%` }}
400
+ />
401
+ </div>
402
+ </div>
403
+ )
404
+ }
405
+
406
+ // ============================================================================
407
+ // COMPONENT: HistoryDashboard - List of research requests
408
+ // ============================================================================
409
+
410
+ interface HistoryDashboardProps {
411
+ orchestrator: ResearchOrchestrator
412
+ onSelectJob?: (jobId: string) => void
413
+ }
414
+
415
+ export function HistoryDashboard({
416
+ orchestrator,
417
+ onSelectJob,
418
+ }: HistoryDashboardProps) {
419
+ const [jobs, setJobs] = useState<Array<{
420
+ jobId: string
421
+ company: string
422
+ status: ResearchStatus
423
+ progress: number
424
+ }>>([])
425
+
426
+ // This would normally fetch from your backend
427
+ // For now it's a placeholder
428
+
429
+ return (
430
+ <div>
431
+ <h2 className="text-2xl font-bold mb-4">Research History</h2>
432
+ {jobs.length === 0 ? (
433
+ <div className="p-4 text-center text-gray-500">No research requests yet</div>
434
+ ) : (
435
+ <div className="grid gap-4">
436
+ {jobs.map((job) => (
437
+ <ResearchCard
438
+ key={job.jobId}
439
+ jobId={job.jobId}
440
+ company={job.company}
441
+ status={job.status}
442
+ progress={job.progress}
443
+ onClick={() => onSelectJob?.(job.jobId)}
444
+ />
445
+ ))}
446
+ </div>
447
+ )}
448
+ </div>
449
+ )
450
+ }
451
+
452
+ // ============================================================================
453
+ // EXPORTS
454
+ // ============================================================================
455
+
456
+ export default {
457
+ useResearchOrchestrator,
458
+ useResearchStatus,
459
+ RequestForm,
460
+ ResultsViewer,
461
+ ResearchCard,
462
+ HistoryDashboard,
463
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020"],
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "moduleResolution": "bundler"
14
+ },
15
+ "include": ["src/**/*.ts"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }