@ma7moudsalama/falak-app 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/README.md +378 -0
- package/bin/falak.js +157 -0
- package/index.js +5 -0
- package/lib/scaffold.js +23 -0
- package/package.json +46 -0
- package/template/_env.example +34 -0
- package/template/_gitignore +8 -0
- package/template/firebase-rules.json +36 -0
- package/template/index.html +21 -0
- package/template/package.json +36 -0
- package/template/postcss.config.js +6 -0
- package/template/public/favicon.svg +5 -0
- package/template/src/App.vue +95 -0
- package/template/src/assets/main.css +100 -0
- package/template/src/components/layout/AppLayout.vue +163 -0
- package/template/src/composables/useAuth.js +393 -0
- package/template/src/composables/useCrypto.js +153 -0
- package/template/src/composables/useDatabase.js +341 -0
- package/template/src/composables/useGroq.js +237 -0
- package/template/src/composables/usePaymob.js +392 -0
- package/template/src/firebase/index.js +87 -0
- package/template/src/i18n/index.js +66 -0
- package/template/src/i18n/locales/ar.json +121 -0
- package/template/src/i18n/locales/en.json +121 -0
- package/template/src/main.js +59 -0
- package/template/src/router/index.js +127 -0
- package/template/src/stores/auth.js +14 -0
- package/template/src/views/AdminView.vue +67 -0
- package/template/src/views/DashboardView.vue +253 -0
- package/template/src/views/HomeView.vue +13 -0
- package/template/src/views/NotFoundView.vue +8 -0
- package/template/src/views/ProfileView.vue +134 -0
- package/template/src/views/auth/ForgotView.vue +57 -0
- package/template/src/views/auth/LoginView.vue +169 -0
- package/template/src/views/auth/RegisterView.vue +103 -0
- package/template/tailwind.config.js +41 -0
- package/template/vite.config.js +29 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDatabase — Realtime Database Manager with Offline Support
|
|
3
|
+
* ─────────────────────────────────────────────────────────────
|
|
4
|
+
* Features:
|
|
5
|
+
* • Reads/writes to Firebase Realtime Database
|
|
6
|
+
* • Mirrors data in IndexedDB for offline access
|
|
7
|
+
* • Detects online/offline status — blocks writes when offline
|
|
8
|
+
* • Listens for remote changes and syncs to IndexedDB automatically
|
|
9
|
+
* • Optional field-level encryption via useCrypto
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const db = useDatabase()
|
|
13
|
+
* const todos = await db.listen('todos') // reactive ref + live sync
|
|
14
|
+
* await db.set('todos/1', { title: 'Buy milk' })
|
|
15
|
+
* await db.update('todos/1', { done: true })
|
|
16
|
+
* await db.remove('todos/1')
|
|
17
|
+
* const item = await db.get('todos/1')
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ref, readonly, onUnmounted } from 'vue'
|
|
21
|
+
import {
|
|
22
|
+
ref as dbRef,
|
|
23
|
+
get,
|
|
24
|
+
set,
|
|
25
|
+
update,
|
|
26
|
+
remove,
|
|
27
|
+
push,
|
|
28
|
+
onValue,
|
|
29
|
+
onChildAdded,
|
|
30
|
+
onChildChanged,
|
|
31
|
+
onChildRemoved,
|
|
32
|
+
serverTimestamp,
|
|
33
|
+
query,
|
|
34
|
+
orderByChild,
|
|
35
|
+
equalTo,
|
|
36
|
+
limitToLast,
|
|
37
|
+
limitToFirst
|
|
38
|
+
} from 'firebase/database'
|
|
39
|
+
import { openDB } from 'idb'
|
|
40
|
+
import { rtdb } from '@/firebase/index.js'
|
|
41
|
+
import { useCrypto } from './useCrypto.js'
|
|
42
|
+
|
|
43
|
+
// ── IndexedDB Setup ───────────────────────────
|
|
44
|
+
const IDB_NAME = 'falak_offline_db'
|
|
45
|
+
const IDB_VERSION = 1
|
|
46
|
+
|
|
47
|
+
async function getIDB() {
|
|
48
|
+
return openDB(IDB_NAME, IDB_VERSION, {
|
|
49
|
+
upgrade(db) {
|
|
50
|
+
if (!db.objectStoreNames.contains('cache')) {
|
|
51
|
+
const store = db.createObjectStore('cache', { keyPath: 'path' })
|
|
52
|
+
store.createIndex('updatedAt', 'updatedAt')
|
|
53
|
+
}
|
|
54
|
+
if (!db.objectStoreNames.contains('pending_writes')) {
|
|
55
|
+
db.createObjectStore('pending_writes', { keyPath: 'id', autoIncrement: true })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Online Status ─────────────────────────────
|
|
62
|
+
const isOnline = ref(navigator.onLine)
|
|
63
|
+
window.addEventListener('online', () => { isOnline.value = true })
|
|
64
|
+
window.addEventListener('offline', () => { isOnline.value = false })
|
|
65
|
+
|
|
66
|
+
// ── Active listeners registry ─────────────────
|
|
67
|
+
const listeners = new Map()
|
|
68
|
+
|
|
69
|
+
export function useDatabase(options = {}) {
|
|
70
|
+
const {
|
|
71
|
+
encryptedPaths = [], // ['users/*/email', 'payments/*']
|
|
72
|
+
encryptFields: encryptFieldsList = [] // field names to encrypt
|
|
73
|
+
} = options
|
|
74
|
+
|
|
75
|
+
const { encrypt, decrypt, encryptFields, decryptFields } = useCrypto()
|
|
76
|
+
const unsubscribers = []
|
|
77
|
+
|
|
78
|
+
// ── Helpers ────────────────────────────────
|
|
79
|
+
function shouldEncrypt(path) {
|
|
80
|
+
return encryptedPaths.some((pattern) => {
|
|
81
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '[^/]+') + '$')
|
|
82
|
+
return regex.test(path)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function prepareForWrite(path, data) {
|
|
87
|
+
if (!data || typeof data !== 'object') return data
|
|
88
|
+
return shouldEncrypt(path) || encryptFieldsList.length
|
|
89
|
+
? encryptFields(data, encryptFieldsList)
|
|
90
|
+
: data
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function prepareForRead(path, data) {
|
|
94
|
+
if (!data || typeof data !== 'object') return data
|
|
95
|
+
return shouldEncrypt(path) || encryptFieldsList.length
|
|
96
|
+
? decryptFields(data, encryptFieldsList)
|
|
97
|
+
: data
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── IndexedDB Helpers ─────────────────────
|
|
101
|
+
async function idbGet(path) {
|
|
102
|
+
const db = await getIDB()
|
|
103
|
+
const item = await db.get('cache', path)
|
|
104
|
+
return item ? prepareForRead(path, item.data) : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function idbSet(path, data) {
|
|
108
|
+
const db = await getIDB()
|
|
109
|
+
await db.put('cache', { path, data, updatedAt: Date.now() })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function idbDelete(path) {
|
|
113
|
+
const db = await getIDB()
|
|
114
|
+
await db.delete('cache', path)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function idbGetAll(prefix) {
|
|
118
|
+
const db = await getIDB()
|
|
119
|
+
const all = await db.getAll('cache')
|
|
120
|
+
return all
|
|
121
|
+
.filter(item => item.path.startsWith(prefix))
|
|
122
|
+
.reduce((acc, item) => {
|
|
123
|
+
const key = item.path.replace(prefix + '/', '')
|
|
124
|
+
acc[key] = prepareForRead(item.path, item.data)
|
|
125
|
+
return acc
|
|
126
|
+
}, {})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── READ ───────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* One-time read. Returns offline data if offline.
|
|
132
|
+
*/
|
|
133
|
+
async function get$(path) {
|
|
134
|
+
if (!isOnline.value) {
|
|
135
|
+
return idbGet(path)
|
|
136
|
+
}
|
|
137
|
+
const snap = await get(dbRef(rtdb, path))
|
|
138
|
+
const data = snap.exists() ? snap.val() : null
|
|
139
|
+
if (data) await idbSet(path, data)
|
|
140
|
+
return prepareForRead(path, data)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── WRITE ──────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Set data at path. Blocked offline.
|
|
146
|
+
*/
|
|
147
|
+
async function set$(path, data) {
|
|
148
|
+
if (!isOnline.value) {
|
|
149
|
+
return { success: false, error: 'You are offline. Data is read-only.' }
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const prepared = prepareForWrite(path, data)
|
|
153
|
+
await set(dbRef(rtdb, path), {
|
|
154
|
+
...prepared,
|
|
155
|
+
updatedAt: serverTimestamp()
|
|
156
|
+
})
|
|
157
|
+
await idbSet(path, data)
|
|
158
|
+
return { success: true }
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return { success: false, error: err.message }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Update (merge) data at path. Blocked offline.
|
|
166
|
+
*/
|
|
167
|
+
async function update$(path, data) {
|
|
168
|
+
if (!isOnline.value) {
|
|
169
|
+
return { success: false, error: 'You are offline. Data is read-only.' }
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const prepared = prepareForWrite(path, data)
|
|
173
|
+
await update(dbRef(rtdb, path), {
|
|
174
|
+
...prepared,
|
|
175
|
+
updatedAt: serverTimestamp()
|
|
176
|
+
})
|
|
177
|
+
// Merge into IDB cache
|
|
178
|
+
const existing = await idbGet(path) || {}
|
|
179
|
+
await idbSet(path, { ...existing, ...data })
|
|
180
|
+
return { success: true }
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return { success: false, error: err.message }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Push new child to a list. Blocked offline.
|
|
188
|
+
*/
|
|
189
|
+
async function push$(path, data) {
|
|
190
|
+
if (!isOnline.value) {
|
|
191
|
+
return { success: false, error: 'You are offline. Data is read-only.' }
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const prepared = prepareForWrite(path, data)
|
|
195
|
+
const newRef = await push(dbRef(rtdb, path), {
|
|
196
|
+
...prepared,
|
|
197
|
+
createdAt: serverTimestamp()
|
|
198
|
+
})
|
|
199
|
+
await idbSet(`${path}/${newRef.key}`, data)
|
|
200
|
+
return { success: true, key: newRef.key }
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return { success: false, error: err.message }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Remove data at path. Blocked offline.
|
|
208
|
+
*/
|
|
209
|
+
async function remove$(path) {
|
|
210
|
+
if (!isOnline.value) {
|
|
211
|
+
return { success: false, error: 'You are offline. Data is read-only.' }
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await remove(dbRef(rtdb, path))
|
|
215
|
+
await idbDelete(path)
|
|
216
|
+
return { success: true }
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { success: false, error: err.message }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── REAL-TIME LISTENER ─────────────────────
|
|
223
|
+
/**
|
|
224
|
+
* Subscribe to a path. Returns reactive ref updated live.
|
|
225
|
+
* Also syncs to IndexedDB on every remote change.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} path - RTDB path
|
|
228
|
+
* @param {object} queryOptions - { orderBy, equalTo, limit, limitMode }
|
|
229
|
+
* @returns {{ data: Ref, unsubscribe: Function }}
|
|
230
|
+
*/
|
|
231
|
+
function listen(path, queryOptions = {}) {
|
|
232
|
+
const data = ref(null)
|
|
233
|
+
|
|
234
|
+
// Preload from IDB
|
|
235
|
+
idbGet(path).then((cached) => {
|
|
236
|
+
if (cached && data.value === null) data.value = cached
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
let q = dbRef(rtdb, path)
|
|
240
|
+
if (queryOptions.orderBy) {
|
|
241
|
+
q = query(q, orderByChild(queryOptions.orderBy))
|
|
242
|
+
}
|
|
243
|
+
if (queryOptions.equalTo !== undefined) {
|
|
244
|
+
q = query(q, equalTo(queryOptions.equalTo))
|
|
245
|
+
}
|
|
246
|
+
if (queryOptions.limit) {
|
|
247
|
+
q = queryOptions.limitMode === 'first'
|
|
248
|
+
? query(q, limitToFirst(queryOptions.limit))
|
|
249
|
+
: query(q, limitToLast(queryOptions.limit))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const unsub = onValue(q, async (snap) => {
|
|
253
|
+
const val = snap.exists() ? snap.val() : null
|
|
254
|
+
if (val) await idbSet(path, val)
|
|
255
|
+
data.value = prepareForRead(path, val)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
unsubscribers.push(unsub)
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
data: readonly(data),
|
|
262
|
+
unsubscribe: unsub
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Listen to child-level events (added/changed/removed) on a list.
|
|
268
|
+
* More efficient than listening to the whole list.
|
|
269
|
+
*
|
|
270
|
+
* @returns {{ items: Ref<Object>, unsubscribe: Function }}
|
|
271
|
+
*/
|
|
272
|
+
function listenList(path) {
|
|
273
|
+
const items = ref({})
|
|
274
|
+
|
|
275
|
+
// Preload from IDB
|
|
276
|
+
idbGetAll(path).then((cached) => {
|
|
277
|
+
if (Object.keys(cached).length && Object.keys(items.value).length === 0) {
|
|
278
|
+
items.value = cached
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const baseRef = dbRef(rtdb, path)
|
|
283
|
+
|
|
284
|
+
const u1 = onChildAdded(baseRef, async (snap) => {
|
|
285
|
+
const key = snap.key
|
|
286
|
+
const val = prepareForRead(`${path}/${key}`, snap.val())
|
|
287
|
+
items.value = { ...items.value, [key]: val }
|
|
288
|
+
await idbSet(`${path}/${key}`, snap.val())
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const u2 = onChildChanged(baseRef, async (snap) => {
|
|
292
|
+
const key = snap.key
|
|
293
|
+
const val = prepareForRead(`${path}/${key}`, snap.val())
|
|
294
|
+
items.value = { ...items.value, [key]: val }
|
|
295
|
+
await idbSet(`${path}/${key}`, snap.val())
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
const u3 = onChildRemoved(baseRef, async (snap) => {
|
|
299
|
+
const key = snap.key
|
|
300
|
+
const copy = { ...items.value }
|
|
301
|
+
delete copy[key]
|
|
302
|
+
items.value = copy
|
|
303
|
+
await idbDelete(`${path}/${key}`)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const unsubscribe = () => { u1(); u2(); u3() }
|
|
307
|
+
unsubscribers.push(unsubscribe)
|
|
308
|
+
|
|
309
|
+
return { items, unsubscribe }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Offline IDB-only read ──────────────────
|
|
313
|
+
async function getOffline(path) {
|
|
314
|
+
return idbGet(path)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function getAllOffline(prefix) {
|
|
318
|
+
return idbGetAll(prefix)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Cleanup on component unmount ───────────
|
|
322
|
+
onUnmounted(() => {
|
|
323
|
+
unsubscribers.forEach((u) => u())
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
// State
|
|
328
|
+
isOnline: readonly(isOnline),
|
|
329
|
+
|
|
330
|
+
// Methods
|
|
331
|
+
get: get$,
|
|
332
|
+
set: set$,
|
|
333
|
+
update: update$,
|
|
334
|
+
push: push$,
|
|
335
|
+
remove: remove$,
|
|
336
|
+
listen,
|
|
337
|
+
listenList,
|
|
338
|
+
getOffline,
|
|
339
|
+
getAllOffline
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useGroq — Groq AI Composable
|
|
3
|
+
* ──────────────────────────────
|
|
4
|
+
* Wraps groq-sdk to provide chat completions, streaming, and
|
|
5
|
+
* a simple conversation history manager.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const groq = useGroq()
|
|
9
|
+
* const reply = await groq.chat('What is Vue.js?')
|
|
10
|
+
*
|
|
11
|
+
* // Streaming
|
|
12
|
+
* await groq.streamChat('Tell me a story', (chunk) => console.log(chunk))
|
|
13
|
+
*
|
|
14
|
+
* // Multi-turn conversation
|
|
15
|
+
* groq.addMessage('user', 'Hello')
|
|
16
|
+
* const reply = await groq.sendConversation()
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import Groq from 'groq-sdk'
|
|
20
|
+
import { ref, readonly } from 'vue'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MODEL = import.meta.env.VITE_GROQ_MODEL || 'llama-3.3-70b-versatile'
|
|
23
|
+
const GROQ_API_KEY = import.meta.env.VITE_GROQ_API_KEY || ''
|
|
24
|
+
|
|
25
|
+
// Singleton client
|
|
26
|
+
let _client = null
|
|
27
|
+
function getClient() {
|
|
28
|
+
if (!_client) {
|
|
29
|
+
if (!GROQ_API_KEY) {
|
|
30
|
+
console.warn('[useGroq] VITE_GROQ_API_KEY is not set')
|
|
31
|
+
}
|
|
32
|
+
_client = new Groq({
|
|
33
|
+
apiKey: GROQ_API_KEY,
|
|
34
|
+
dangerouslyAllowBrowser: true // required for browser usage
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
return _client
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useGroq(defaultSystemPrompt = null) {
|
|
41
|
+
const isLoading = ref(false)
|
|
42
|
+
const lastError = ref(null)
|
|
43
|
+
const messages = ref([]) // conversation history
|
|
44
|
+
|
|
45
|
+
if (defaultSystemPrompt) {
|
|
46
|
+
messages.value = [{ role: 'system', content: defaultSystemPrompt }]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Single completion ──────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Send a single prompt and get a response.
|
|
52
|
+
* Does NOT track conversation history.
|
|
53
|
+
*/
|
|
54
|
+
async function chat(prompt, options = {}) {
|
|
55
|
+
isLoading.value = true
|
|
56
|
+
lastError.value = null
|
|
57
|
+
try {
|
|
58
|
+
const completion = await getClient().chat.completions.create({
|
|
59
|
+
model: options.model || DEFAULT_MODEL,
|
|
60
|
+
max_tokens: options.maxTokens || 1024,
|
|
61
|
+
temperature: options.temperature ?? 0.7,
|
|
62
|
+
messages: [
|
|
63
|
+
...(options.systemPrompt
|
|
64
|
+
? [{ role: 'system', content: options.systemPrompt }]
|
|
65
|
+
: []),
|
|
66
|
+
{ role: 'user', content: prompt }
|
|
67
|
+
]
|
|
68
|
+
})
|
|
69
|
+
return completion.choices[0]?.message?.content || ''
|
|
70
|
+
} catch (err) {
|
|
71
|
+
lastError.value = err.message
|
|
72
|
+
throw err
|
|
73
|
+
} finally {
|
|
74
|
+
isLoading.value = false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Streaming completion ───────────────────
|
|
79
|
+
/**
|
|
80
|
+
* Stream a response, calling onChunk for each token.
|
|
81
|
+
* @param {string} prompt
|
|
82
|
+
* @param {Function} onChunk - called with each text chunk
|
|
83
|
+
* @param {object} options
|
|
84
|
+
* @returns {Promise<string>} full response
|
|
85
|
+
*/
|
|
86
|
+
async function streamChat(prompt, onChunk, options = {}) {
|
|
87
|
+
isLoading.value = true
|
|
88
|
+
lastError.value = null
|
|
89
|
+
let fullText = ''
|
|
90
|
+
try {
|
|
91
|
+
const stream = await getClient().chat.completions.create({
|
|
92
|
+
model: options.model || DEFAULT_MODEL,
|
|
93
|
+
max_tokens: options.maxTokens || 2048,
|
|
94
|
+
temperature: options.temperature ?? 0.7,
|
|
95
|
+
stream: true,
|
|
96
|
+
messages: [
|
|
97
|
+
...(options.systemPrompt
|
|
98
|
+
? [{ role: 'system', content: options.systemPrompt }]
|
|
99
|
+
: []),
|
|
100
|
+
{ role: 'user', content: prompt }
|
|
101
|
+
]
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
for await (const chunk of stream) {
|
|
105
|
+
const text = chunk.choices[0]?.delta?.content || ''
|
|
106
|
+
if (text) {
|
|
107
|
+
fullText += text
|
|
108
|
+
onChunk(text, fullText)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return fullText
|
|
112
|
+
} catch (err) {
|
|
113
|
+
lastError.value = err.message
|
|
114
|
+
throw err
|
|
115
|
+
} finally {
|
|
116
|
+
isLoading.value = false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Multi-turn conversation ────────────────
|
|
121
|
+
function addMessage(role, content) {
|
|
122
|
+
messages.value.push({ role, content })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setSystemPrompt(prompt) {
|
|
126
|
+
const idx = messages.value.findIndex(m => m.role === 'system')
|
|
127
|
+
if (idx >= 0) {
|
|
128
|
+
messages.value[idx].content = prompt
|
|
129
|
+
} else {
|
|
130
|
+
messages.value.unshift({ role: 'system', content: prompt })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function clearConversation(keepSystem = true) {
|
|
135
|
+
if (keepSystem) {
|
|
136
|
+
messages.value = messages.value.filter(m => m.role === 'system')
|
|
137
|
+
} else {
|
|
138
|
+
messages.value = []
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Send current conversation history and get a reply.
|
|
144
|
+
* Automatically appends assistant reply to history.
|
|
145
|
+
*/
|
|
146
|
+
async function sendConversation(userMessage = null, options = {}) {
|
|
147
|
+
if (userMessage) addMessage('user', userMessage)
|
|
148
|
+
isLoading.value = true
|
|
149
|
+
lastError.value = null
|
|
150
|
+
try {
|
|
151
|
+
const completion = await getClient().chat.completions.create({
|
|
152
|
+
model: options.model || DEFAULT_MODEL,
|
|
153
|
+
max_tokens: options.maxTokens || 1024,
|
|
154
|
+
temperature: options.temperature ?? 0.7,
|
|
155
|
+
messages: messages.value
|
|
156
|
+
})
|
|
157
|
+
const reply = completion.choices[0]?.message?.content || ''
|
|
158
|
+
addMessage('assistant', reply)
|
|
159
|
+
return reply
|
|
160
|
+
} catch (err) {
|
|
161
|
+
lastError.value = err.message
|
|
162
|
+
throw err
|
|
163
|
+
} finally {
|
|
164
|
+
isLoading.value = false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Stream multi-turn conversation.
|
|
170
|
+
*/
|
|
171
|
+
async function streamConversation(userMessage, onChunk, options = {}) {
|
|
172
|
+
addMessage('user', userMessage)
|
|
173
|
+
isLoading.value = true
|
|
174
|
+
lastError.value = null
|
|
175
|
+
let fullText = ''
|
|
176
|
+
try {
|
|
177
|
+
const stream = await getClient().chat.completions.create({
|
|
178
|
+
model: options.model || DEFAULT_MODEL,
|
|
179
|
+
max_tokens: options.maxTokens || 2048,
|
|
180
|
+
temperature: options.temperature ?? 0.7,
|
|
181
|
+
stream: true,
|
|
182
|
+
messages: messages.value
|
|
183
|
+
})
|
|
184
|
+
for await (const chunk of stream) {
|
|
185
|
+
const text = chunk.choices[0]?.delta?.content || ''
|
|
186
|
+
if (text) {
|
|
187
|
+
fullText += text
|
|
188
|
+
onChunk(text, fullText)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
addMessage('assistant', fullText)
|
|
192
|
+
return fullText
|
|
193
|
+
} catch (err) {
|
|
194
|
+
lastError.value = err.message
|
|
195
|
+
throw err
|
|
196
|
+
} finally {
|
|
197
|
+
isLoading.value = false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Audio transcription ────────────────────
|
|
202
|
+
/**
|
|
203
|
+
* Transcribe an audio file (Blob or File) using Whisper.
|
|
204
|
+
*/
|
|
205
|
+
async function transcribe(audioFile, language = 'en') {
|
|
206
|
+
isLoading.value = true
|
|
207
|
+
try {
|
|
208
|
+
const transcription = await getClient().audio.transcriptions.create({
|
|
209
|
+
file: audioFile,
|
|
210
|
+
model: 'whisper-large-v3',
|
|
211
|
+
language,
|
|
212
|
+
response_format: 'json'
|
|
213
|
+
})
|
|
214
|
+
return transcription.text
|
|
215
|
+
} catch (err) {
|
|
216
|
+
lastError.value = err.message
|
|
217
|
+
throw err
|
|
218
|
+
} finally {
|
|
219
|
+
isLoading.value = false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
isLoading: readonly(isLoading),
|
|
225
|
+
lastError: readonly(lastError),
|
|
226
|
+
messages: readonly(messages),
|
|
227
|
+
|
|
228
|
+
chat,
|
|
229
|
+
streamChat,
|
|
230
|
+
sendConversation,
|
|
231
|
+
streamConversation,
|
|
232
|
+
addMessage,
|
|
233
|
+
setSystemPrompt,
|
|
234
|
+
clearConversation,
|
|
235
|
+
transcribe
|
|
236
|
+
}
|
|
237
|
+
}
|