From e02c2bbb750e2a25bfcb6731f0947adf936555d7 Mon Sep 17 00:00:00 2001 From: Takala Date: Tue, 26 Nov 2024 20:27:49 +0800 Subject: [PATCH 1/3] feat: implement stage1 API --- src/lib/server/llm.ts | 19 ++-- src/lib/server/parse.ts | 5 + src/lib/types/IndividualDiscussion.ts | 21 ++++- src/lib/types/groupDiscussion.ts | 3 + src/lib/types/session.ts | 50 ++++++++-- src/lib/types/speech.ts | 19 ---- src/routes/session/[id]/+page.svelte | 20 +--- .../session/[id]/participant/+page.server.ts | 94 +++++++++++++++++++ .../session/[id]/participant/+page.svelte | 21 +---- 9 files changed, 181 insertions(+), 71 deletions(-) create mode 100644 src/lib/server/parse.ts delete mode 100644 src/lib/types/speech.ts diff --git a/src/lib/server/llm.ts b/src/lib/server/llm.ts index 691da42..c8778dc 100644 --- a/src/lib/server/llm.ts +++ b/src/lib/server/llm.ts @@ -18,11 +18,6 @@ interface ChatMessage { content: string; } -interface Document { - title?: string; - content: string; -} - interface Student_opinion { student_id: string; student_conclusions: string; @@ -55,20 +50,24 @@ export async function chatWithLLMByDocs( messages: ChatMessage[], mainQuestion: string, secondaryGoal: string[], - documents: Document[], + documents: { + name: string; + text: string; + }[], temperature = 0.7 ) { try { if (await isHarmfulContent(messages[messages.length - 1].content)) { return { success: false, + message: '', error: 'Harmful content detected' }; } const formattedDocs = documents .map((doc, index) => { - const title = doc.title || `Document ${index + 1}`; - return `[${title}]:\n${doc.content}`; + const title = doc.name || `Document ${index + 1}`; + return `[${title}]:\n${doc.text}`; }) .join('\n\n'); @@ -91,7 +90,7 @@ export async function chatWithLLMByDocs( temperature }); - const result = response.choices[0].message; + const result = response.choices[0].message.content; if (!result) { throw new Error('Failed to parse response'); } @@ -105,11 +104,13 @@ export async function chatWithLLMByDocs( if (error instanceof z.ZodError) { return { success: false, + message: '', error: 'Type error: ' + error.errors.map((e) => e.message).join(', ') }; } return { success: false, + message: '', error: 'Failed to process documents and generate response' }; } diff --git a/src/lib/server/parse.ts b/src/lib/server/parse.ts new file mode 100644 index 0000000..8602e9e --- /dev/null +++ b/src/lib/server/parse.ts @@ -0,0 +1,5 @@ +export async function parsePdf2Text(filePath: string): Promise { + // TODO: pares pdf file to text + + return filePath; +} diff --git a/src/lib/types/IndividualDiscussion.ts b/src/lib/types/IndividualDiscussion.ts index 7ef7c50..585cbd6 100644 --- a/src/lib/types/IndividualDiscussion.ts +++ b/src/lib/types/IndividualDiscussion.ts @@ -5,10 +5,16 @@ export interface FirestoreIndividualDiscussion { userId: string; sessionId: string; groupId: string; + goal: string; + subQuestions: string[]; + resourcesTexts: { + name: string; + text: string; + }[]; history: { role: 'system' | 'assistant' | 'user'; + fileId: string | null; content: string; - speechId: string | null; timestamp: Timestamp; }[]; summary: string; @@ -19,10 +25,16 @@ export interface IndividualDiscussion { userId: string; sessionId: string; groupId: string; + goal: string; + subQuestions: string[]; + resourcesTexts: { + name: string; + text: string; + }[]; history: { role: 'system' | 'assistant' | 'user'; + fileId: string | null; content: string; - speechId: string | null; timestamp: string; }[]; summary: string; @@ -36,10 +48,13 @@ export function convertFirestoreIndividualDiscussion( userId: data.userId, sessionId: data.sessionId, groupId: data.groupId, + goal: data.goal, + subQuestions: data.subQuestions, + resourcesTexts: data.resourcesTexts, history: data.history.map((history) => ({ role: history.role, + fileId: history.fileId, content: history.content, - speechId: history.speechId, timestamp: history.timestamp.toDate().toISOString() })), summary: data.summary diff --git a/src/lib/types/groupDiscussion.ts b/src/lib/types/groupDiscussion.ts index c041026..74b4cf7 100644 --- a/src/lib/types/groupDiscussion.ts +++ b/src/lib/types/groupDiscussion.ts @@ -4,6 +4,7 @@ import type { Timestamp } from 'firebase-admin/firestore'; export interface FirestoreGroupDiscussion { sessionId: string; groupId: string; + groupName: string; history: { name: string; content: string; @@ -20,6 +21,7 @@ export interface FirestoreGroupDiscussion { export interface GroupDiscussion { sessionId: string; groupId: string; + groupName: string; history: { name: string; content: string; @@ -37,6 +39,7 @@ export function convertFirestoreGroupDiscussion(data: FirestoreGroupDiscussion): return { sessionId: data.sessionId, groupId: data.groupId, + groupName: data.groupName, history: data.history.map((history) => ({ name: history.name, content: history.content, diff --git a/src/lib/types/session.ts b/src/lib/types/session.ts index 597c1d5..cfc9bb7 100644 --- a/src/lib/types/session.ts +++ b/src/lib/types/session.ts @@ -9,18 +9,29 @@ export interface FirestoreSession { title: string; createdAt: Timestamp; status: 'draft' | 'waiting' | 'active' | 'ended'; + stage: 'grouping' | 'individual' | 'group' | 'ended'; tempIdExpiry: Timestamp | null; goal: string; subQuestions: string[]; - resourcesIds: string[]; + resourceIds: string[]; participants: { [userId: string]: { name: string; groupId: string | null; + groupName: string | null; joinedAt: Timestamp; }; }; - stage: 'grouping' | 'individual' | 'group' | 'ended'; + groups: { + [groupId: string]: { + groupName: string; + members: { + [userId: string]: { + name: string; + }; + }; + }; + }; } // Client-side data structure (serializable) @@ -32,10 +43,11 @@ export interface Session { title: string; createdAt: string; status: 'draft' | 'waiting' | 'active' | 'ended'; + stage: 'grouping' | 'individual' | 'group' | 'ended'; tempIdExpiry: string | null; goal: string; subQuestions: string[]; - resourcesIds: string[]; + resourceIds: string[]; participants: { [userId: string]: { name: string; @@ -43,7 +55,16 @@ export interface Session { joinedAt: string; }; }; - stage: 'grouping' | 'individual' | 'group' | 'ended'; + groups: { + [groupId: string]: { + groupName: string; + members: { + [userId: string]: { + name: string; + }; + }; + }; + }; } // convert Firestore data to client-side data @@ -56,20 +77,37 @@ export function convertFirestoreSession(data: FirestoreSession): Session { title: data.title, createdAt: data.createdAt.toDate().toISOString(), status: data.status, + stage: data.stage, tempIdExpiry: data.tempIdExpiry ? data.tempIdExpiry.toDate().toISOString() : null, goal: data.goal, subQuestions: data.subQuestions, - resourcesIds: data.resourcesIds, + resourceIds: data.resourceIds, participants: Object.fromEntries( Object.entries(data.participants).map(([userId, participant]) => [ userId, { name: participant.name, groupId: participant.groupId, + groupName: participant.groupName, joinedAt: participant.joinedAt.toDate().toISOString() } ]) ), - stage: data.stage + groups: Object.fromEntries( + Object.entries(data.groups).map(([groupId, group]) => [ + groupId, + { + groupName: group.groupName, + members: Object.fromEntries( + Object.entries(group.members).map(([userId, member]) => [ + userId, + { + name: member.name + } + ]) + ) + } + ]) + ) }; } diff --git a/src/lib/types/speech.ts b/src/lib/types/speech.ts deleted file mode 100644 index d2c20a8..0000000 --- a/src/lib/types/speech.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface FirestoreSpeech { - speechId: string; - fileId: string; - transcription: string; -} - -export interface Speech { - speechId: string; - fileId: string; - transcription: string; -} - -export function convertFirestoreSpeech(data: FirestoreSpeech): Speech { - return { - speechId: data.speechId, - fileId: data.fileId, - transcription: data.transcription - }; -} diff --git a/src/routes/session/[id]/+page.svelte b/src/routes/session/[id]/+page.svelte index f5c59aa..2c77189 100644 --- a/src/routes/session/[id]/+page.svelte +++ b/src/routes/session/[id]/+page.svelte @@ -8,7 +8,6 @@ Trash2, Play, Users, - Link, FileText, Clock, Pencil, @@ -213,24 +212,9 @@
{#each Object.entries(session.resources) as [, resource]}
- {#if resource.type === 'link'} - - {:else} - - {/if} +
- {#if resource.type === 'link'} - - {resource.content} - - {:else} -

{resource.content}

- {/if} +

{resource.text}

Added {formatDate(resource.addedAt)}

diff --git a/src/routes/session/[id]/participant/+page.server.ts b/src/routes/session/[id]/participant/+page.server.ts index a15f1dd..56f4ab4 100644 --- a/src/routes/session/[id]/participant/+page.server.ts +++ b/src/routes/session/[id]/participant/+page.server.ts @@ -1,4 +1,12 @@ +import { env } from '$env/dynamic/private'; import { adminDb } from '$lib/server/firebase'; +import { chatWithLLMByDocs } from '$lib/server/llm'; +import { parsePdf2Text } from '$lib/server/parse'; +import { transcribe } from '$lib/stt/core'; +import { + convertFirestoreIndividualDiscussion, + type FirestoreIndividualDiscussion +} from '$lib/types/IndividualDiscussion'; import type { FirestoreSession } from '$lib/types/session'; import { convertFirestoreSession } from '$lib/types/session'; import { error, fail, redirect } from '@sveltejs/kit'; @@ -60,5 +68,91 @@ export const actions = { }); return { success: true, groupId }; + }, + + startIndividualStage: async ({ locals, params }) => { + const sessionId = params.id; + const sessionRef = adminDb.collection('sessions').doc(sessionId); + const sessionData = (await sessionRef.get()).data() as FirestoreSession; + if (sessionData.hostId != locals.user?.uid) { + throw redirect(401, 'Unauthorized'); + } + + const resourcesTexts = []; + // parse pdf files to text + for (const resourceId of sessionData.resourceIds) { + const resourceRef = adminDb.collection('resources').doc(resourceId); + const resourceData = (await resourceRef.get()).data(); + const text = await parsePdf2Text(resourceData?.fileId); + resourcesTexts.push(text); + } + + // create conversation for each participants + for (const participantId in sessionData.participants) { + const individualDiscussionRef = adminDb.collection('individualDiscussion').doc(); + await individualDiscussionRef.set({ + userId: participantId, + sessionId: sessionId, + groupId: sessionData.participants[participantId].groupId, + goal: sessionData.goal, + subQuestions: sessionData.subQuestions, + resourcesTexts: resourcesTexts, + history: [], + summary: '' + }); + } + + // set session stage to individual + await sessionRef.update({ stage: 'individual' }); + + return { success: true }; + }, + + sendConversation: async ({ request, locals, params }) => { + if (!locals.user) { + throw error(401, 'Unauthorized'); + } + const { speech } = await request.json(); + const userId = locals.user.uid; + const sessionId = params.id; + + const fileId = 'Unknown file id'; // TODO: get file id from the file upload + const text = await transcribe(speech, env.HUGGINGFACE_TOKEN); + + const individualDiscussionRef = adminDb + .collection('individualDiscussion') + .where('userId', '==', userId) + .where('sessionId', '==', sessionId); + const individualDiscussionData = ( + await individualDiscussionRef.get() + ).docs[0].data() as FirestoreIndividualDiscussion; + + if (!individualDiscussionData) { + throw error(404, 'Individual discussion not found'); + } + + const { history, goal, subQuestions, resourcesTexts } = + convertFirestoreIndividualDiscussion(individualDiscussionData); + + history.push({ + role: 'user', + fileId: fileId, + content: text, + timestamp: new Date().toISOString() + }); + + const response = await chatWithLLMByDocs(history, goal, subQuestions, resourcesTexts); + if (!response.success) { + throw error(500, response.error); + } + + history.push({ + role: 'user', + fileId: null, + content: response.message, + timestamp: new Date().toISOString() + }); + + return { success: true }; } } satisfies Actions; diff --git a/src/routes/session/[id]/participant/+page.svelte b/src/routes/session/[id]/participant/+page.svelte index 63571fb..7093966 100644 --- a/src/routes/session/[id]/participant/+page.svelte +++ b/src/routes/session/[id]/participant/+page.svelte @@ -1,6 +1,5 @@
-

{session.title}

+ {#if isHost} + {#if isEditing} +
+ + + +
+ {:else} +
+

{session.title}

+ +
+ {/if} + {:else} +
+

{session.title}

+
+ {/if}

Hosted by {session.hostName}

@@ -61,6 +166,14 @@ Start Session + {:else if session.status === 'waiting'} + {/if}
{/if} @@ -141,6 +254,56 @@
+ {#if isHost} +
+
+

任務

+
+ +
+
+
+
+ + +
+ {#each Array.from({ length: subQuestionsInput.length }, (_, index) => index) as index} +
+ +
+ + +
+
+ {/each} + +
+

+
+ {/if}

Participants

@@ -161,27 +324,20 @@ Joined {formatDate(participant.joinedAt)}

+ {#if isHost} +
+ + + +
+ {/if}
{/each}
{/if}
- - -
-

Discussion

-

- {#if session.status === 'draft'} - Waiting for the host to start the session... - {:else if session.status === 'waiting'} - Waiting for participants to join... - {:else if session.status === 'ended'} - This session has ended. - {:else} - Discussion in progress... - {/if} -

-
From d63eb3e7e32cebe1293d297733387f6ba5d9e9d1 Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Wed, 27 Nov 2024 06:24:18 +0800 Subject: [PATCH 3/3] feat: break things and rebuild some of them --- .env.example | 7 + src/lib/components/Auth.svelte | 2 +- src/lib/firebase/store.ts | 22 +-- src/lib/schema/code.ts | 9 + src/lib/schema/conversation.ts | 12 ++ src/lib/schema/group.ts | 15 ++ src/lib/schema/profile.ts | 12 ++ src/lib/schema/session.ts | 27 +++ src/lib/stores/profile.ts | 15 +- src/lib/stores/sidebar.ts | 1 + src/lib/types/IndividualDiscussion.ts | 62 ------- src/lib/types/groupDiscussion.ts | 54 ------ src/lib/types/resource.ts | 24 --- src/lib/types/session.ts | 111 ------------- src/lib/utils/debounce.ts | 10 ++ src/routes/+page.svelte | 157 +++++++++++++----- src/routes/create/+page.server.ts | 61 +++---- src/routes/create/+page.svelte | 154 ++++++++--------- src/routes/dashboard/+page.server.ts | 46 ----- src/routes/dashboard/+page.svelte | 154 +++++++++++------ src/routes/join/+page.svelte | 8 +- src/routes/login/+page.svelte | 19 ++- src/routes/profile/+page.svelte | 120 +++++++------ src/routes/session/[id]/+layout.svelte | 21 +++ src/routes/session/[id]/+page.server.ts | 8 - src/routes/session/[id]/+page.svelte | 139 +++++++--------- src/routes/session/[id]/+page.ts | 1 + .../session/[id]/participant/+page.server.ts | 14 +- .../session/[id]/participant/+page.svelte | 14 +- static/daisy-illustration.webp | Bin 0 -> 236660 bytes static/home.webp | Bin 0 -> 419374 bytes tailwind.config.ts | 23 ++- 32 files changed, 611 insertions(+), 711 deletions(-) create mode 100644 src/lib/schema/code.ts create mode 100644 src/lib/schema/conversation.ts create mode 100644 src/lib/schema/group.ts create mode 100644 src/lib/schema/profile.ts create mode 100644 src/lib/schema/session.ts delete mode 100644 src/lib/types/IndividualDiscussion.ts delete mode 100644 src/lib/types/groupDiscussion.ts delete mode 100644 src/lib/types/resource.ts delete mode 100644 src/lib/types/session.ts create mode 100644 src/lib/utils/debounce.ts create mode 100644 src/routes/session/[id]/+layout.svelte create mode 100644 src/routes/session/[id]/+page.ts create mode 100644 static/daisy-illustration.webp create mode 100644 static/home.webp diff --git a/.env.example b/.env.example index b919277..146583f 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ PUBLIC_FIREBASE_MESSAGING_SENDER_ID="585902608528" PUBLIC_FIREBASE_APP_ID="1:585902608528:web:908698a423651bb865c815" PUBLIC_FIREBASE_MEASUREMENT_ID="G-QFXF543DV8" +CLOUDFLARE_ACCOUNT_ID="CLOUDFLARE_ACCOUNT_ID" +CLOUDFLARE_R2_BUCKET="CLOUDFLARE_R2_BUCKET" +CLOUDFLARE_R2_ACCESS_KEY_ID="CLOUDFLARE_R2_ACCESS_KEY_ID" +CLOUDFLARE_R2_SECRET_ACCESS_KEY="CLOUDFLARE_R2_SECRET_ACCESS_KEY" +CLOUDFLARE_PUBLIC_URL="https://hinagiku-dev-storage.csie.cool" + GOOGLE_APPLICATION_CREDENTIALS="service-account-file.example.json" HUGGINGFACE_TOKEN="hf_xxx" OPENAI_BASE_URL="https://api.openai.com/v1" +OPENAI_API_KEY="" diff --git a/src/lib/components/Auth.svelte b/src/lib/components/Auth.svelte index 24b525a..0eb695f 100644 --- a/src/lib/components/Auth.svelte +++ b/src/lib/components/Auth.svelte @@ -5,7 +5,7 @@ {#if $user}
-

Welcome, {$profile?.displayName ?? $user.displayName}!

+

Welcome, {$profile?.displayName || $user.displayName}!

{:else} diff --git a/src/lib/firebase/store.ts b/src/lib/firebase/store.ts index ea926ce..32a2d26 100644 --- a/src/lib/firebase/store.ts +++ b/src/lib/firebase/store.ts @@ -4,20 +4,20 @@ import { writable, type Readable } from 'svelte/store'; const log = debug('app:store'); -export interface DocumentStore extends Readable { +export type DocumentStore = [Readable, { unsubscribe: () => void; -} +}]; export function subscribeAll( ref: Query, - store = writable([]) -): DocumentStore { + store = writable<[string, T][]>([]) +): DocumentStore<[string, T][]> { log('subscribe', ref); const unsubscribe = onSnapshot( ref, (snapshot) => { - const data = snapshot.docs.map((doc) => doc.data() as T); + const data = snapshot.docs.map((doc) => [doc.id, doc.data()] as [string, T]); log('onSnapshot', ref, data); store.set(data); }, @@ -26,18 +26,18 @@ export function subscribeAll( } ); - return Object.assign(store, { + return [store, { unsubscribe: () => { log('unsubscribe', ref); unsubscribe(); } - }); + }]; } export function subscribe( ref: DocumentReference, store = writable(null) -): DocumentStore { +): DocumentStore { log('subscribe', ref.path); const unsubscribe = onSnapshot( @@ -52,10 +52,10 @@ export function subscribe( } ); - return Object.assign(store, { + return [store, { unsubscribe: () => { - log('unsubscribe', ref.path); + log('unsubscribe', ref); unsubscribe(); } - }); + }]; } diff --git a/src/lib/schema/code.ts b/src/lib/schema/code.ts new file mode 100644 index 0000000..940d5b5 --- /dev/null +++ b/src/lib/schema/code.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { Timestamp } from 'firebase/firestore'; + +export const CodeSchema = z.object({ + target: z.string().min(1), + exp: z.instanceof(Timestamp), +}); + +export type Code = z.infer; diff --git a/src/lib/schema/conversation.ts b/src/lib/schema/conversation.ts new file mode 100644 index 0000000..9ec1430 --- /dev/null +++ b/src/lib/schema/conversation.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const ConversationSchema = z.object({ + history: z.array( + z.object({ + role: z.enum(['system', 'user', 'assistant']), + content: z.string(), + }) + ), +}); + +export type Conversation = z.infer; diff --git a/src/lib/schema/group.ts b/src/lib/schema/group.ts new file mode 100644 index 0000000..5777890 --- /dev/null +++ b/src/lib/schema/group.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const GroupSchema = z.object({ + concept: z.string().min(1), + discussions: z.array( + z.object({ + content: z.string(), + id: z.string().nullable(), + }) + ), + summary: z.string().nullable(), + keywords: z.record(z.string(), z.number()), +}); + +export type Group = z.infer; diff --git a/src/lib/schema/profile.ts b/src/lib/schema/profile.ts new file mode 100644 index 0000000..c09e799 --- /dev/null +++ b/src/lib/schema/profile.ts @@ -0,0 +1,12 @@ +import { Timestamp } from 'firebase/firestore'; +import { z } from 'zod'; + +export const ProfileSchema = z.object({ + uid: z.string(), + displayName: z.string(), + title: z.string().nullable(), + bio: z.string().nullable(), + updatedAt: z.instanceof(Timestamp) +}); + +export type Profile = z.infer; diff --git a/src/lib/schema/session.ts b/src/lib/schema/session.ts new file mode 100644 index 0000000..3f15ede --- /dev/null +++ b/src/lib/schema/session.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { Timestamp } from 'firebase/firestore'; + +export const SessionSchema = z.object({ + title: z.string().min(1).max(200), + status: z.enum(['draft', 'waiting', 'active', 'ended']), + host: z.string(), + participants: z.array(z.string()), + group: z.record(z.string(), z.string()), + resources: z.array( + z.object({ + name: z.string(), + content: z.string().min(1), + createdAt: z.instanceof(Timestamp), + id: z.string().nullable(), + }) + ).max(10), + task: z.string().min(1).max(200), + subtasks: z.array(z.string().min(1).max(200)).max(10), + timing: z.object({ + self: z.number().int().min(1), + group: z.number().int().min(1), + }), + createdAt: z.instanceof(Timestamp), +}); + +export type Session = z.infer; diff --git a/src/lib/stores/profile.ts b/src/lib/stores/profile.ts index a4cb5aa..d9f67cc 100644 --- a/src/lib/stores/profile.ts +++ b/src/lib/stores/profile.ts @@ -1,25 +1,18 @@ import { db } from '$lib/firebase'; import { subscribe } from '$lib/firebase/store'; -import { collection, doc, Timestamp } from 'firebase/firestore'; +import { collection, doc } from 'firebase/firestore'; import { writable } from 'svelte/store'; import { z } from 'zod'; import { user } from './auth'; +import type { ProfileSchema } from '$lib/schema/profile'; -export const profileSchema = z.object({ - uid: z.string(), - displayName: z.string(), - title: z.string().nullable(), - bio: z.string().nullable(), - updatedAt: z.instanceof(Timestamp) -}); - -export const profile = writable | null>(null); +export const profile = writable | null>(null); let unsubscribe: () => void | undefined; user.subscribe((user) => { if (user) { const ref = doc(collection(db, 'profiles'), user.uid); - unsubscribe = subscribe(ref, profile).unsubscribe; + unsubscribe = subscribe(ref, profile)[1].unsubscribe; } else { unsubscribe?.(); profile.set(null); diff --git a/src/lib/stores/sidebar.ts b/src/lib/stores/sidebar.ts index d5827fd..a8cb2a9 100644 --- a/src/lib/stores/sidebar.ts +++ b/src/lib/stores/sidebar.ts @@ -1,2 +1,3 @@ import { writable } from 'svelte/store'; + export const sidebarOpen = writable(false); diff --git a/src/lib/types/IndividualDiscussion.ts b/src/lib/types/IndividualDiscussion.ts deleted file mode 100644 index 585cbd6..0000000 --- a/src/lib/types/IndividualDiscussion.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Timestamp } from 'firebase-admin/firestore'; - -// Firestore data structure -export interface FirestoreIndividualDiscussion { - userId: string; - sessionId: string; - groupId: string; - goal: string; - subQuestions: string[]; - resourcesTexts: { - name: string; - text: string; - }[]; - history: { - role: 'system' | 'assistant' | 'user'; - fileId: string | null; - content: string; - timestamp: Timestamp; - }[]; - summary: string; -} - -// Client-side data structure (serializable) -export interface IndividualDiscussion { - userId: string; - sessionId: string; - groupId: string; - goal: string; - subQuestions: string[]; - resourcesTexts: { - name: string; - text: string; - }[]; - history: { - role: 'system' | 'assistant' | 'user'; - fileId: string | null; - content: string; - timestamp: string; - }[]; - summary: string; -} - -// convert Firestore data to client-side data -export function convertFirestoreIndividualDiscussion( - data: FirestoreIndividualDiscussion -): IndividualDiscussion { - return { - userId: data.userId, - sessionId: data.sessionId, - groupId: data.groupId, - goal: data.goal, - subQuestions: data.subQuestions, - resourcesTexts: data.resourcesTexts, - history: data.history.map((history) => ({ - role: history.role, - fileId: history.fileId, - content: history.content, - timestamp: history.timestamp.toDate().toISOString() - })), - summary: data.summary - }; -} diff --git a/src/lib/types/groupDiscussion.ts b/src/lib/types/groupDiscussion.ts deleted file mode 100644 index 74b4cf7..0000000 --- a/src/lib/types/groupDiscussion.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Timestamp } from 'firebase-admin/firestore'; - -// Firestore data structure -export interface FirestoreGroupDiscussion { - sessionId: string; - groupId: string; - groupName: string; - history: { - name: string; - content: string; - speechId: string | null; - timestamp: Timestamp; - }[]; - analysis: { - summary: string; - keywords: string[]; - }; -} - -// Client-side data structure (serializable) -export interface GroupDiscussion { - sessionId: string; - groupId: string; - groupName: string; - history: { - name: string; - content: string; - speechId: string | null; - timestamp: string; - }[]; - analysis: { - summary: string; - keywords: string[]; - }; -} - -// convert Firestore data to client-side data -export function convertFirestoreGroupDiscussion(data: FirestoreGroupDiscussion): GroupDiscussion { - return { - sessionId: data.sessionId, - groupId: data.groupId, - groupName: data.groupName, - history: data.history.map((history) => ({ - name: history.name, - content: history.content, - speechId: history.speechId, - timestamp: history.timestamp.toDate().toISOString() - })), - analysis: { - summary: data.analysis.summary, - keywords: data.analysis.keywords - } - }; -} diff --git a/src/lib/types/resource.ts b/src/lib/types/resource.ts deleted file mode 100644 index 4a37ad7..0000000 --- a/src/lib/types/resource.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Timestamp } from 'firebase-admin/firestore'; - -export interface FirestoreResource { - name: string; - fileId: string; - text: string; - addedAt: Timestamp; -} - -export interface Resource { - name: string; - fileId: string; - text: string; - addedAt: string; -} - -export function convertFirestoreResources(data: FirestoreResource): Resource { - return { - name: data.name, - fileId: data.fileId, - text: data.text, - addedAt: data.addedAt.toDate().toISOString() - }; -} diff --git a/src/lib/types/session.ts b/src/lib/types/session.ts deleted file mode 100644 index 433289f..0000000 --- a/src/lib/types/session.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { Timestamp } from 'firebase/firestore'; - -// Firestore data structure -export interface FirestoreSession { - id: string; - tempId: string | null; - hostId: string; - hostName: string; - title: string; - createdAt: Timestamp; - status: 'draft' | 'waiting' | 'active' | 'ended'; - stage: 'grouping' | 'individual' | 'group' | 'ended'; - tempIdExpiry: Timestamp | null; - goal: string; - subQuestions: string[]; - resourceIds: string[]; - participants: { - [userId: string]: { - name: string; - groupId: string | null; - groupName: string | null; - joinedAt: Timestamp; - }; - }; - groups: { - [groupId: string]: { - groupName: string; - members: { - [userId: string]: { - name: string; - }; - }; - }; - }; -} - -// Client-side data structure (serializable) -export interface Session { - id: string; - tempId: string | null; - hostId: string; - hostName: string; - title: string; - createdAt: string; - goal: string; - subQuestions: string[]; - status: 'draft' | 'waiting' | 'active' | 'ended' | 'individual' | 'group'; - tempIdExpiry: string | null; - resourceIds: string[]; - participants: { - [userId: string]: { - name: string; - groupId: string | null; - joinedAt: string; - }; - }; - groups: { - [groupId: string]: { - groupName: string; - members: { - [userId: string]: { - name: string; - }; - }; - }; - }; -} - -// convert Firestore data to client-side data -export function convertFirestoreSession(data: FirestoreSession): Session { - return { - id: data.id, - tempId: data.tempId, - hostId: data.hostId, - hostName: data.hostName, - title: data.title, - createdAt: data.createdAt.toDate().toISOString(), - status: data.status, - tempIdExpiry: data.tempIdExpiry ? data.tempIdExpiry.toDate().toISOString() : null, - goal: data.goal, - subQuestions: data.subQuestions, - resourceIds: data.resourceIds, - participants: Object.fromEntries( - Object.entries(data.participants).map(([userId, participant]) => [ - userId, - { - name: participant.name, - groupId: participant.groupId, - groupName: participant.groupName, - joinedAt: participant.joinedAt.toDate().toISOString() - } - ]) - ), - groups: Object.fromEntries( - Object.entries(data.groups).map(([groupId, group]) => [ - groupId, - { - groupName: group.groupName, - members: Object.fromEntries( - Object.entries(group.members).map(([userId, member]) => [ - userId, - { - name: member.name - } - ]) - ) - } - ]) - ) - }; -} diff --git a/src/lib/utils/debounce.ts b/src/lib/utils/debounce.ts new file mode 100644 index 0000000..2404668 --- /dev/null +++ b/src/lib/utils/debounce.ts @@ -0,0 +1,10 @@ +export function debounce void>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType; + return function (this: unknown, ...args: Parameters) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b4a6ef7..88ec488 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,53 +1,128 @@ -
-
-

Welcome to Hinagiku

-

- Intelligent system designed to support discussions in educational environments -

- -
- {#if $user} - - Go to Dashboard - - - {:else} - - Get Started - - - {/if} +
+
+
+
+
+

+ Transform Educational Discussions with AI-Powered Insights +

+

+ Hinagiku helps educators facilitate more engaging and productive discussions through + real-time transcription and intelligent analysis. +

+
+ {#if $user} + + {:else} + + + {/if} +
+
+ +
+
-
-
-

Real-time Transcription

-

- Capture and analyze discussions in real-time for immediate insights and feedback. + +

+
+
+

Why Choose Hinagiku?

+

+ Our platform combines cutting-edge technology with educational expertise to enhance + learning outcomes.

-
-

Intelligent Analysis

-

- Advanced AI-powered analysis to help hosts provide timely and meaningful feedback. -

+ +
+ +
+
+ +
+
+

Real-time Transcription

+

+ Capture every valuable insight from your discussions with our advanced speech-to-text + technology. +

+
+ + +
+
+ +
+
+

Intelligent Analysis

+

+ Get AI-powered insights and suggestions to improve discussion quality and participation. +

+
+ + +
+
+ +
+
+

Educational Focus

+

+ Purpose-built for educational environments with features that support meaningful + learning. +

+
-
-

Educational Focus

-

- Designed specifically for educational environments to enhance learning outcomes. -

+
+
+ +
+
+
+
+ Hinagiku Daisy +
+
+

The Story Behind Our Name

+
+

+ Hinagiku (雛菊), or Daisy in English, is + an intelligent system designed to support discussions in educational environments. +

+

+ One of Hinagiku's key features is its real-time voice transcription and analysis, which helps + hosts provide timely and insightful feedback, setting it apart from other educational tools. +

+

+ We chose the name Hinagiku because it reflects our core values: + resilience, simplicity, and growth—much like + the daisy flower itself, which flourishes in diverse conditions. +

+

+ Our mission is to help participants and hosts connect meaningfully by providing tools that + facilitate better communication and collaboration in classrooms. +

+
+
diff --git a/src/routes/create/+page.server.ts b/src/routes/create/+page.server.ts index 3609f18..fe386ab 100644 --- a/src/routes/create/+page.server.ts +++ b/src/routes/create/+page.server.ts @@ -1,6 +1,8 @@ import { adminDb } from '$lib/server/firebase'; import { fail, redirect } from '@sveltejs/kit'; import type { Actions } from './$types'; +import { SessionSchema, type Session } from '$lib/schema/session'; +import { Timestamp } from 'firebase/firestore'; export const actions = { default: async ({ request, locals }) => { @@ -9,44 +11,35 @@ export const actions = { } const data = await request.formData(); - const title = data.get('title')?.toString(); - if (!title) { - return fail(400, { title, missing: true }); - } - - // Process resources - const resources: Record = {}; - let i = 0; - while (data.has(`resourceType${i}`)) { - const type = data.get(`resourceType${i}`)?.toString(); - const content = data.get(`resourceContent${i}`)?.toString(); - - if (type && content) { - resources[`resource${i}`] = { - type: type as 'text' | 'link', - content, - addedAt: new Date() - }; - } - i++; + const formData: Session = { + title: data.get('title')?.toString() || '', + host: locals.user.uid, + status: 'draft', + participants: [], + group: {}, + resources: [], + timing: { + self: Number(data.get('selfTime')) || 5, + group: Number(data.get('groupTime')) || 10, + }, + task: data.get('task')?.toString() || '', + subtasks: [], + createdAt: Timestamp.now() + }; + + const result = SessionSchema.safeParse(formData); + + if (!result.success) { + return fail(400, { + data: formData, + errors: result.error.flatten().fieldErrors + }); } const sessionRef = adminDb.collection('sessions').doc(); + await sessionRef.set(result.data); - await sessionRef.set({ - id: sessionRef.id, - tempId: null, - tempIdExpiry: null, - hostId: locals.user.uid, - hostName: locals.user.name, - title, - createdAt: new Date(), - status: 'draft', - resources, - participants: {} - }); - - return { success: true, sessionId: sessionRef.id }; + throw redirect(303, `/session/${sessionRef.id}`); } } satisfies Actions; diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index 784bc06..7f00d83 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -1,102 +1,86 @@

Create Discussion Session

- {#if form?.success} -
-

Session Created!

-

Your session has been created successfully.

-

- You can start the session and generate an invitation code when you're ready. -

- - Go to Session - + {#if form?.errors} +
+ Please fix the following errors: + {#each Object.entries(form.errors) as [field, errors]} +

{field}: {errors}

+ {/each} +
+ {/if} + +
+
+ + + {#if form?.errors?.title} +

{form.errors.title}

+ {/if} +
+ +
+ + + {#if form?.errors?.task} +

{form.errors.task}

+ {/if}
- {:else} - + +
- - Individual Time (minutes) + - {#if form?.missing} -

Please enter a title

- {/if}
- -
-
- - - -
- - {#each resources as resource, i} -
- - - -
- {/each} +
+ +
+ {#if form?.errors?.timing} +

{form.errors.timing}

+ {/if} +
- - - {/if} +
+ + +
+
diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts index 8821bd6..15177fe 100644 --- a/src/routes/dashboard/+page.server.ts +++ b/src/routes/dashboard/+page.server.ts @@ -1,4 +1,3 @@ -import { adminDb } from '$lib/server/firebase'; import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; @@ -7,52 +6,7 @@ export const load: PageServerLoad = async ({ locals }) => { throw redirect(303, '/login'); } - // find user's joined sessions - const joinedSessionsQuery = await adminDb - .collection('sessions') - .where(`participants.${locals.user.uid}`, '!=', null) - .get(); - - const joinedSessions = joinedSessionsQuery.docs.map((doc) => { - const data = doc.data(); - const id = doc.id; - return { - ...convertTimestamps(data), - id - }; - }); - - // find user's created sessions - const createdSessionsQuery = await adminDb - .collection('sessions') - .where('hostId', '==', locals.user.uid) - .get(); - - const createdSessions = createdSessionsQuery.docs.map((doc) => { - const data = doc.data(); - const id = doc.id; - return { - ...convertTimestamps(data), - id - }; - }); - return { user: locals.user, - createdSessions: createdSessions, - joinedSessions: joinedSessions }; }; - -function convertTimestamps(obj: FirebaseFirestore.DocumentData): FirebaseFirestore.DocumentData { - if (obj !== null && typeof obj === 'object') { - for (const key in obj) { - if (obj[key] && typeof obj[key].toDate === 'function') { - obj[key] = obj[key].toMillis(); - } else if (obj[key] !== null && typeof obj[key] === 'object') { - obj[key] = convertTimestamps(obj[key]); - } - } - } - return obj; -} diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index 04dac1a..9e0c064 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -1,72 +1,122 @@
-
-
-

Dashboard

-

Welcome back, {$user?.displayName}

-
+
+

Dashboard

+

Welcome back, {$profile?.displayName || $user?.displayName}

-

Recent Sessions

-
- {#if data.createdSessions} - {#each data.createdSessions as session} - - {session.title} -
- Host by: {session.hostName} - {session.tempId} -
-
+

Recent Sessions

+ {#if sessions?.length} +
+ {#each sessions as [id, session]} + + +
+

{session.title}

+ + {session.status === 'active' + ? 'Active' + : session.status === 'waiting' + ? 'Waiting' + : session.status === 'draft' + ? 'Draft' + : 'Ended'} + +
+
+

+ Participants: {session.participants?.length || 0} +

+
+
+
{/each} - {:else} -
-

No recent sessions found

-

Create or join a session to get started

+
+ {:else} + +
+
+ +
+

No recent sessions found

+

Create or join a session to get started

+
- {/if} -
+ + {/if}
diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte index 9250b24..4ebfa89 100644 --- a/src/routes/join/+page.svelte +++ b/src/routes/join/+page.svelte @@ -57,10 +57,10 @@ /> {#if form?.idInvalid} -

Please enter a valid 6-digit code

+

Please enter a valid 6-digit code

{/if} {#if form?.notFound} -

Session not found or already started

+

Session not found or already started

{/if}
@@ -76,7 +76,7 @@ placeholder="輸入組別" /> {#if form?.groupNumberInvalid} -

請輸入有效的組別

+

請輸入有效的組別

{/if}
@@ -94,7 +94,7 @@ diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index c251898..30254ac 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,8 +1,21 @@ -
-

Sign in to Hinagiku

- +
+
+
+

Welcome to Hinagiku

+

Sign in with your Google account to get started

+
+ + + + + +

+ ← Back to home +

+
diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte index e416ab0..ed2beda 100644 --- a/src/routes/profile/+page.svelte +++ b/src/routes/profile/+page.svelte @@ -1,74 +1,84 @@
-

Profile Settings

+
+

Profile Settings

+

Update your personal information and preferences

+
{#if form?.success} -
Profile updated successfully!
+ + + + + Profile updated successfully! + {/if} {#key $profile} -
{ - loading = true; - return async ({ update }) => { - await update(); - loading = false; - }; - }} - class="space-y-6" - > -
- - -
+ + { + loading = true; + return async ({ update }) => { + await update(); + loading = false; + }; + }} + class="space-y-6" + > +
+ + +
-
- - -
+
+ + +
-
- - -
+
+ +