@standardagents/cli 0.10.1-next.bbd142a → 0.11.0-next.ab7e1ea
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/LICENSE.txt +48 -0
- package/chat/next/app/layout.tsx +24 -0
- package/chat/next/app/page.tsx +21 -0
- package/chat/next/next-env.d.ts +6 -0
- package/chat/next/next.config.ts +15 -0
- package/chat/next/postcss.config.mjs +5 -0
- package/chat/next/tsconfig.json +27 -0
- package/chat/package.json +32 -0
- package/chat/src/App.tsx +130 -0
- package/chat/src/components/AgentSelector.tsx +102 -0
- package/chat/src/components/Chat.tsx +134 -0
- package/chat/src/components/EmptyState.tsx +437 -0
- package/chat/src/components/Login.tsx +139 -0
- package/chat/src/components/Logo.tsx +39 -0
- package/chat/src/components/Markdown.tsx +222 -0
- package/chat/src/components/MessageInput.tsx +197 -0
- package/chat/src/components/MessageList.tsx +796 -0
- package/chat/src/components/Sidebar.tsx +253 -0
- package/chat/src/hooks/useAuth.tsx +118 -0
- package/chat/src/hooks/useTheme.tsx +55 -0
- package/chat/src/hooks/useThreads.ts +131 -0
- package/chat/src/index.css +168 -0
- package/chat/tsconfig.json +24 -0
- package/chat/vite/favicon.svg +3 -0
- package/chat/vite/index.html +17 -0
- package/chat/vite/main.tsx +25 -0
- package/chat/vite/vite.config.ts +23 -0
- package/dist/index.js +669 -99
- package/dist/index.js.map +1 -1
- package/package.json +13 -9
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { type Thread, getThreadTitle, getTagValue } from '../hooks/useThreads'
|
|
5
|
+
import { LogoMark } from './Logo'
|
|
6
|
+
import { useTheme } from '../hooks/useTheme'
|
|
7
|
+
import { useAuth } from '../hooks/useAuth'
|
|
8
|
+
|
|
9
|
+
interface SidebarProps {
|
|
10
|
+
threads: Thread[]
|
|
11
|
+
currentThreadId: string | null
|
|
12
|
+
onSelectThread: (threadId: string) => void
|
|
13
|
+
onNewThread: () => void
|
|
14
|
+
onDeleteThread: (threadId: string) => void
|
|
15
|
+
isOpen: boolean
|
|
16
|
+
onToggle: () => void
|
|
17
|
+
loading?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Sidebar({
|
|
21
|
+
threads,
|
|
22
|
+
currentThreadId,
|
|
23
|
+
onSelectThread,
|
|
24
|
+
onNewThread,
|
|
25
|
+
onDeleteThread,
|
|
26
|
+
isOpen,
|
|
27
|
+
onToggle,
|
|
28
|
+
loading,
|
|
29
|
+
}: SidebarProps) {
|
|
30
|
+
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null)
|
|
31
|
+
const { theme, toggleTheme } = useTheme()
|
|
32
|
+
const { logout } = useAuth()
|
|
33
|
+
|
|
34
|
+
const formatRelativeTime = (timestamp: number) => {
|
|
35
|
+
// Handle different timestamp formats based on magnitude
|
|
36
|
+
let ms: number
|
|
37
|
+
if (timestamp > 1e15) {
|
|
38
|
+
ms = timestamp / 1000
|
|
39
|
+
} else if (timestamp > 1e12) {
|
|
40
|
+
ms = timestamp
|
|
41
|
+
} else {
|
|
42
|
+
ms = timestamp * 1000
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const date = new Date(ms)
|
|
46
|
+
const now = new Date()
|
|
47
|
+
const diffMs = now.getTime() - date.getTime()
|
|
48
|
+
const diffSeconds = Math.floor(diffMs / 1000)
|
|
49
|
+
const diffMinutes = Math.floor(diffSeconds / 60)
|
|
50
|
+
const diffHours = Math.floor(diffMinutes / 60)
|
|
51
|
+
const diffDays = Math.floor(diffHours / 24)
|
|
52
|
+
const diffWeeks = Math.floor(diffDays / 7)
|
|
53
|
+
const diffMonths = Math.floor(diffDays / 30)
|
|
54
|
+
const diffYears = Math.floor(diffDays / 365)
|
|
55
|
+
|
|
56
|
+
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
|
57
|
+
|
|
58
|
+
if (diffSeconds < 60) {
|
|
59
|
+
return rtf.format(-diffSeconds, 'second')
|
|
60
|
+
} else if (diffMinutes < 60) {
|
|
61
|
+
return rtf.format(-diffMinutes, 'minute')
|
|
62
|
+
} else if (diffHours < 24) {
|
|
63
|
+
return rtf.format(-diffHours, 'hour')
|
|
64
|
+
} else if (diffDays < 7) {
|
|
65
|
+
return rtf.format(-diffDays, 'day')
|
|
66
|
+
} else if (diffWeeks < 4) {
|
|
67
|
+
return rtf.format(-diffWeeks, 'week')
|
|
68
|
+
} else if (diffMonths < 12) {
|
|
69
|
+
return rtf.format(-diffMonths, 'month')
|
|
70
|
+
} else {
|
|
71
|
+
return rtf.format(-diffYears, 'year')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handleDeleteClick = (e: React.MouseEvent, threadId: string) => {
|
|
76
|
+
e.stopPropagation()
|
|
77
|
+
setConfirmingDelete(threadId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const handleConfirmDelete = (e: React.MouseEvent, threadId: string) => {
|
|
81
|
+
e.stopPropagation()
|
|
82
|
+
onDeleteThread(threadId)
|
|
83
|
+
setConfirmingDelete(null)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const handleCancelDelete = (e: React.MouseEvent) => {
|
|
87
|
+
e.stopPropagation()
|
|
88
|
+
setConfirmingDelete(null)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<aside
|
|
93
|
+
className={`
|
|
94
|
+
fixed top-0 left-0 h-full w-72 bg-[var(--bg-secondary)] flex flex-col border-r border-[var(--border-primary)] z-40
|
|
95
|
+
transform transition-transform duration-300 ease-out
|
|
96
|
+
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
97
|
+
`}
|
|
98
|
+
>
|
|
99
|
+
{/* Header */}
|
|
100
|
+
<div className="h-[47px] flex items-center justify-between px-3 border-b border-[var(--border-primary)]">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<LogoMark className="w-[18px] h-[18px] text-[var(--text-secondary)]" />
|
|
103
|
+
</div>
|
|
104
|
+
<div className="flex items-center gap-1">
|
|
105
|
+
<button
|
|
106
|
+
onClick={onNewThread}
|
|
107
|
+
className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
|
|
108
|
+
title="New chat"
|
|
109
|
+
>
|
|
110
|
+
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
111
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
|
112
|
+
</svg>
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
onClick={onToggle}
|
|
116
|
+
className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
|
|
117
|
+
title="Collapse sidebar"
|
|
118
|
+
>
|
|
119
|
+
{/* ChatGPT drawer icon */}
|
|
120
|
+
<svg className="w-5 h-5 text-[var(--text-secondary)]" viewBox="0 0 20 20" fill="currentColor">
|
|
121
|
+
<path d="M6.835 4c-.451.004-.82.012-1.137.038-.386.032-.659.085-.876.162l-.2.086c-.44.224-.807.564-1.063.982l-.103.184c-.126.247-.206.562-.248 1.076-.043.523-.043 1.19-.043 2.135v2.664c0 .944 0 1.612.043 2.135.042.515.122.829.248 1.076l.103.184c.256.418.624.758 1.063.982l.2.086c.217.077.49.13.876.162.316.026.685.034 1.136.038zm11.33 7.327c0 .922 0 1.654-.048 2.243-.043.522-.125.977-.305 1.395l-.082.177a4 4 0 0 1-1.473 1.593l-.276.155c-.465.237-.974.338-1.57.387-.59.048-1.322.048-2.244.048H7.833c-.922 0-1.654 0-2.243-.048-.522-.042-.977-.126-1.395-.305l-.176-.082a4 4 0 0 1-1.594-1.473l-.154-.275c-.238-.466-.34-.975-.388-1.572-.048-.589-.048-1.32-.048-2.243V8.663c0-.922 0-1.654.048-2.243.049-.597.15-1.106.388-1.571l.154-.276a4 4 0 0 1 1.594-1.472l.176-.083c.418-.18.873-.263 1.395-.305.589-.048 1.32-.048 2.243-.048h4.334c.922 0 1.654 0 2.243.048.597.049 1.106.15 1.571.388l.276.154a4 4 0 0 1 1.473 1.594l.082.176c.18.418.262.873.305 1.395.048.589.048 1.32.048 2.243zm-10 4.668h4.002c.944 0 1.612 0 2.135-.043.514-.042.829-.122 1.076-.248l.184-.103c.418-.256.758-.624.982-1.063l.086-.2c.077-.217.13-.49.162-.876.043-.523.043-1.19.043-2.135V8.663c0-.944 0-1.612-.043-2.135-.032-.386-.085-.659-.162-.876l-.086-.2a2.67 2.67 0 0 0-.982-1.063l-.184-.103c-.247-.126-.562-.206-1.076-.248-.523-.043-1.19-.043-2.135-.043H8.164L8.165 4z" />
|
|
122
|
+
</svg>
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Thread list */}
|
|
128
|
+
<div className="flex-1 overflow-y-auto py-2">
|
|
129
|
+
{loading ? (
|
|
130
|
+
<div className="px-4 py-8 text-center">
|
|
131
|
+
<div className="inline-block w-5 h-5 border-2 border-[var(--text-muted)] border-t-[var(--text-secondary)] rounded-full animate-spin" />
|
|
132
|
+
</div>
|
|
133
|
+
) : threads.length === 0 ? (
|
|
134
|
+
<div className="px-4 py-8 text-center">
|
|
135
|
+
<p className="text-sm text-[var(--text-tertiary)]">No conversations yet</p>
|
|
136
|
+
<p className="text-xs text-[var(--text-muted)] mt-1">Start a new chat to begin</p>
|
|
137
|
+
</div>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="space-y-0.5 px-2">
|
|
140
|
+
{threads.map((thread) => {
|
|
141
|
+
const isConfirming = confirmingDelete === thread.id
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
key={thread.id}
|
|
146
|
+
onClick={() => !isConfirming && onSelectThread(thread.id)}
|
|
147
|
+
className={`group rounded-xl transition-all duration-150 ${
|
|
148
|
+
isConfirming
|
|
149
|
+
? 'bg-[var(--bg-active)]'
|
|
150
|
+
: thread.id === currentThreadId
|
|
151
|
+
? 'bg-[var(--bg-active)] cursor-pointer'
|
|
152
|
+
: 'hover:bg-[var(--bg-hover)] cursor-pointer'
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
{isConfirming ? (
|
|
156
|
+
// Delete confirmation UI
|
|
157
|
+
<div className="px-3 py-3 space-y-2">
|
|
158
|
+
<p className="text-sm text-[var(--text-primary)]">Delete this thread?</p>
|
|
159
|
+
<div className="flex gap-2">
|
|
160
|
+
<button
|
|
161
|
+
onClick={handleCancelDelete}
|
|
162
|
+
className="flex-1 px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] bg-[var(--bg-hover)] hover:bg-[var(--bg-active)] rounded-lg transition-colors"
|
|
163
|
+
>
|
|
164
|
+
Cancel
|
|
165
|
+
</button>
|
|
166
|
+
<button
|
|
167
|
+
onClick={(e) => handleConfirmDelete(e, thread.id)}
|
|
168
|
+
className="flex-1 px-3 py-1.5 text-xs font-medium text-white bg-red-600/80 hover:bg-red-600 rounded-lg transition-colors"
|
|
169
|
+
>
|
|
170
|
+
Delete
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
) : (
|
|
175
|
+
// Normal thread row
|
|
176
|
+
<div className="flex items-center gap-3 px-3 py-2.5">
|
|
177
|
+
<div className="flex-1 min-w-0">
|
|
178
|
+
{(() => {
|
|
179
|
+
const title = getTagValue(thread.tags, 'title')
|
|
180
|
+
const agentName = thread.agent?.title || thread.agent?.name || 'Agent'
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
<p className={`text-sm truncate ${
|
|
184
|
+
thread.id === currentThreadId ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'
|
|
185
|
+
}`}>
|
|
186
|
+
{title || agentName}
|
|
187
|
+
</p>
|
|
188
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
189
|
+
<span className="text-xs text-[var(--text-muted)]">
|
|
190
|
+
{formatRelativeTime(thread.created_at)}
|
|
191
|
+
</span>
|
|
192
|
+
{title && (
|
|
193
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-hover)] text-[var(--text-muted)]">
|
|
194
|
+
{agentName}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</>
|
|
199
|
+
)
|
|
200
|
+
})()}
|
|
201
|
+
</div>
|
|
202
|
+
<button
|
|
203
|
+
onClick={(e) => handleDeleteClick(e, thread.id)}
|
|
204
|
+
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-[var(--bg-active)] rounded-lg transition-all shrink-0"
|
|
205
|
+
title="Delete thread"
|
|
206
|
+
>
|
|
207
|
+
<svg className="w-3.5 h-3.5 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
208
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
209
|
+
</svg>
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Bottom toolbar */}
|
|
221
|
+
<div className="px-3 py-2 border-t border-[var(--border-primary)] flex items-center justify-between">
|
|
222
|
+
<button
|
|
223
|
+
onClick={toggleTheme}
|
|
224
|
+
className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
|
|
225
|
+
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
226
|
+
>
|
|
227
|
+
{theme === 'dark' ? (
|
|
228
|
+
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
229
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
230
|
+
</svg>
|
|
231
|
+
) : (
|
|
232
|
+
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
233
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
234
|
+
</svg>
|
|
235
|
+
)}
|
|
236
|
+
<span className="sr-only">
|
|
237
|
+
{theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
238
|
+
</span>
|
|
239
|
+
</button>
|
|
240
|
+
<button
|
|
241
|
+
onClick={logout}
|
|
242
|
+
className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
|
|
243
|
+
title="Log out"
|
|
244
|
+
>
|
|
245
|
+
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
246
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
247
|
+
</svg>
|
|
248
|
+
<span className="sr-only">Log out</span>
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</aside>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
interface User {
|
|
6
|
+
id: string
|
|
7
|
+
username: string
|
|
8
|
+
role: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AuthContextValue {
|
|
12
|
+
isAuthenticated: boolean
|
|
13
|
+
isLoading: boolean
|
|
14
|
+
user: User | null
|
|
15
|
+
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>
|
|
16
|
+
logout: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const AuthContext = createContext<AuthContextValue | null>(null)
|
|
20
|
+
|
|
21
|
+
const TOKEN_KEY = 'agentbuilder_auth_token'
|
|
22
|
+
|
|
23
|
+
function getBaseUrl() {
|
|
24
|
+
const isVite = typeof import.meta !== 'undefined' && import.meta.env
|
|
25
|
+
if (isVite) {
|
|
26
|
+
return import.meta.env.VITE_AGENTBUILDER_URL || ''
|
|
27
|
+
}
|
|
28
|
+
return process.env.NEXT_PUBLIC_AGENTBUILDER_URL || ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
32
|
+
const [user, setUser] = useState<User | null>(null)
|
|
33
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
34
|
+
|
|
35
|
+
const baseUrl = getBaseUrl()
|
|
36
|
+
|
|
37
|
+
// Check for existing session on mount
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const token = localStorage.getItem(TOKEN_KEY)
|
|
40
|
+
if (!token) {
|
|
41
|
+
setIsLoading(false)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate token by calling /api/auth/me
|
|
46
|
+
fetch(`${baseUrl}/api/auth/me`, {
|
|
47
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
48
|
+
})
|
|
49
|
+
.then(res => {
|
|
50
|
+
if (res.ok) return res.json()
|
|
51
|
+
throw new Error('Invalid token')
|
|
52
|
+
})
|
|
53
|
+
.then(data => {
|
|
54
|
+
setUser(data.user)
|
|
55
|
+
})
|
|
56
|
+
.catch(() => {
|
|
57
|
+
localStorage.removeItem(TOKEN_KEY)
|
|
58
|
+
})
|
|
59
|
+
.finally(() => {
|
|
60
|
+
setIsLoading(false)
|
|
61
|
+
})
|
|
62
|
+
}, [baseUrl])
|
|
63
|
+
|
|
64
|
+
const login = useCallback(async (username: string, password: string) => {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ username, password })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const data = await res.json().catch(() => ({}))
|
|
74
|
+
return { success: false, error: data.error || 'Invalid credentials' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = await res.json()
|
|
78
|
+
localStorage.setItem(TOKEN_KEY, data.token)
|
|
79
|
+
setUser(data.user)
|
|
80
|
+
return { success: true }
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { success: false, error: 'Network error' }
|
|
83
|
+
}
|
|
84
|
+
}, [baseUrl])
|
|
85
|
+
|
|
86
|
+
const logout = useCallback(() => {
|
|
87
|
+
const token = localStorage.getItem(TOKEN_KEY)
|
|
88
|
+
if (token) {
|
|
89
|
+
// Fire and forget logout request
|
|
90
|
+
fetch(`${baseUrl}/api/auth/logout`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
93
|
+
}).catch(() => {})
|
|
94
|
+
}
|
|
95
|
+
localStorage.removeItem(TOKEN_KEY)
|
|
96
|
+
setUser(null)
|
|
97
|
+
}, [baseUrl])
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<AuthContext.Provider value={{
|
|
101
|
+
isAuthenticated: !!user,
|
|
102
|
+
isLoading,
|
|
103
|
+
user,
|
|
104
|
+
login,
|
|
105
|
+
logout
|
|
106
|
+
}}>
|
|
107
|
+
{children}
|
|
108
|
+
</AuthContext.Provider>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function useAuth() {
|
|
113
|
+
const context = useContext(AuthContext)
|
|
114
|
+
if (!context) {
|
|
115
|
+
throw new Error('useAuth must be used within an AuthProvider')
|
|
116
|
+
}
|
|
117
|
+
return context
|
|
118
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
type Theme = 'light' | 'dark'
|
|
6
|
+
|
|
7
|
+
interface ThemeContextValue {
|
|
8
|
+
theme: Theme
|
|
9
|
+
toggleTheme: () => void
|
|
10
|
+
setTheme: (theme: Theme) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
14
|
+
|
|
15
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
16
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
17
|
+
// Check localStorage first
|
|
18
|
+
const stored = localStorage.getItem('theme') as Theme | null
|
|
19
|
+
if (stored === 'light' || stored === 'dark') return stored
|
|
20
|
+
// Fall back to system preference
|
|
21
|
+
if (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
22
|
+
return 'light'
|
|
23
|
+
}
|
|
24
|
+
return 'dark'
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Update document class and localStorage when theme changes
|
|
29
|
+
document.documentElement.classList.remove('light', 'dark')
|
|
30
|
+
document.documentElement.classList.add(theme)
|
|
31
|
+
localStorage.setItem('theme', theme)
|
|
32
|
+
}, [theme])
|
|
33
|
+
|
|
34
|
+
const toggleTheme = () => {
|
|
35
|
+
setThemeState(prev => prev === 'dark' ? 'light' : 'dark')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const setTheme = (newTheme: Theme) => {
|
|
39
|
+
setThemeState(newTheme)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
|
44
|
+
{children}
|
|
45
|
+
</ThemeContext.Provider>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useTheme() {
|
|
50
|
+
const context = useContext(ThemeContext)
|
|
51
|
+
if (!context) {
|
|
52
|
+
throw new Error('useTheme must be used within a ThemeProvider')
|
|
53
|
+
}
|
|
54
|
+
return context
|
|
55
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface Thread {
|
|
6
|
+
id: string
|
|
7
|
+
agent_id: string
|
|
8
|
+
user_id: string | null
|
|
9
|
+
tags: string[]
|
|
10
|
+
created_at: number
|
|
11
|
+
agent: {
|
|
12
|
+
name?: string
|
|
13
|
+
title?: string
|
|
14
|
+
type: string
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Helper to get a tag value by key (format: "key:value")
|
|
19
|
+
export function getTagValue(tags: string[], key: string): string | undefined {
|
|
20
|
+
const tag = tags.find(t => t.startsWith(`${key}:`))
|
|
21
|
+
return tag ? tag.slice(key.length + 1) : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper to get thread title from tags
|
|
25
|
+
export function getThreadTitle(thread: Thread): string {
|
|
26
|
+
return getTagValue(thread.tags, 'title') || 'Untitled'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Helper to get API config
|
|
30
|
+
function getApiConfig() {
|
|
31
|
+
const isVite = typeof import.meta !== 'undefined' && import.meta.env
|
|
32
|
+
const baseUrl = isVite
|
|
33
|
+
? (import.meta.env.VITE_AGENTBUILDER_URL || '')
|
|
34
|
+
: (process.env.NEXT_PUBLIC_AGENTBUILDER_URL || '')
|
|
35
|
+
|
|
36
|
+
// Token is stored in localStorage by auth flow
|
|
37
|
+
const token = typeof localStorage !== 'undefined'
|
|
38
|
+
? localStorage.getItem('agentbuilder_auth_token') || ''
|
|
39
|
+
: ''
|
|
40
|
+
|
|
41
|
+
return { baseUrl, token }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useThreads() {
|
|
45
|
+
const [threads, setThreads] = useState<Thread[]>([])
|
|
46
|
+
const [loading, setLoading] = useState(true)
|
|
47
|
+
const [error, setError] = useState<string | null>(null)
|
|
48
|
+
|
|
49
|
+
const { baseUrl, token } = useMemo(() => getApiConfig(), [])
|
|
50
|
+
|
|
51
|
+
const headers = useMemo(() => {
|
|
52
|
+
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
53
|
+
if (token) h['Authorization'] = `Bearer ${token}`
|
|
54
|
+
return h
|
|
55
|
+
}, [token])
|
|
56
|
+
|
|
57
|
+
const fetchThreads = useCallback(async () => {
|
|
58
|
+
try {
|
|
59
|
+
setLoading(true)
|
|
60
|
+
const response = await fetch(`${baseUrl}/api/threads?limit=50`, { headers })
|
|
61
|
+
if (!response.ok) throw new Error('Failed to fetch threads')
|
|
62
|
+
const data = await response.json()
|
|
63
|
+
setThreads(data.threads || [])
|
|
64
|
+
setError(null)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error('Failed to load threads:', err)
|
|
67
|
+
setError(err instanceof Error ? err.message : 'Failed to load threads')
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false)
|
|
70
|
+
}
|
|
71
|
+
}, [baseUrl, headers])
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
fetchThreads()
|
|
75
|
+
}, [fetchThreads])
|
|
76
|
+
|
|
77
|
+
const deleteThread = useCallback(async (threadId: string) => {
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(`${baseUrl}/api/threads/${threadId}`, {
|
|
80
|
+
method: 'DELETE',
|
|
81
|
+
headers,
|
|
82
|
+
})
|
|
83
|
+
if (!response.ok) throw new Error('Failed to delete thread')
|
|
84
|
+
setThreads((prev) => prev.filter((t) => t.id !== threadId))
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error('Failed to delete thread:', err)
|
|
87
|
+
throw err
|
|
88
|
+
}
|
|
89
|
+
}, [baseUrl, headers])
|
|
90
|
+
|
|
91
|
+
const updateThreadTitle = useCallback(async (threadId: string, title: string) => {
|
|
92
|
+
// Find the thread and update tags optimistically
|
|
93
|
+
setThreads((prev) => prev.map((t) => {
|
|
94
|
+
if (t.id !== threadId) return t
|
|
95
|
+
// Remove existing title tag and add new one
|
|
96
|
+
const tagsWithoutTitle = t.tags.filter(tag => !tag.startsWith('title:'))
|
|
97
|
+
return { ...t, tags: [...tagsWithoutTitle, `title:${title}`] }
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// Get current thread to preserve other tags
|
|
102
|
+
const thread = threads.find(t => t.id === threadId)
|
|
103
|
+
if (!thread) return
|
|
104
|
+
|
|
105
|
+
const tagsWithoutTitle = thread.tags.filter(tag => !tag.startsWith('title:'))
|
|
106
|
+
const newTags = [...tagsWithoutTitle, `title:${title}`]
|
|
107
|
+
|
|
108
|
+
const response = await fetch(`${baseUrl}/api/threads/${threadId}`, {
|
|
109
|
+
method: 'PATCH',
|
|
110
|
+
headers,
|
|
111
|
+
body: JSON.stringify({ tags: newTags }),
|
|
112
|
+
})
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
console.error('Failed to update thread title:', response.status)
|
|
115
|
+
fetchThreads() // Revert on failure
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error('Failed to update thread:', err)
|
|
119
|
+
fetchThreads() // Revert on failure
|
|
120
|
+
}
|
|
121
|
+
}, [baseUrl, headers, fetchThreads, threads])
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
threads,
|
|
125
|
+
loading,
|
|
126
|
+
error,
|
|
127
|
+
refreshThreads: fetchThreads,
|
|
128
|
+
deleteThread,
|
|
129
|
+
updateThreadTitle
|
|
130
|
+
}
|
|
131
|
+
}
|