fix session data local saving issue

This commit is contained in:
samtiz 2025-07-05 22:00:36 +09:00
parent 0516b96562
commit 05557c2e68
13 changed files with 475 additions and 282 deletions

View File

@ -89,12 +89,7 @@ function SessionDetailsContent() {
) )
} }
const combinedConversation = [ const askMessages = sessionDetails.ai_messages || [];
...sessionDetails.transcripts.map(t => ({ ...t, type: 'transcript' as const, created_at: t.start_at })),
...sessionDetails.ai_messages.map(m => ({ ...m, type: 'ai_message' as const, created_at: m.sent_at }))
].sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
const audioTranscripts = sessionDetails.transcripts.filter(t => t.speaker !== 'Me');
return ( return (
<div className="min-h-screen bg-[#FDFCF9] text-gray-800"> <div className="min-h-screen bg-[#FDFCF9] text-gray-800">
@ -108,8 +103,8 @@ function SessionDetailsContent() {
</Link> </Link>
</div> </div>
<div className="bg-white p-8 rounded-xl"> <div className="bg-white p-8 rounded-xl shadow-md border border-gray-100">
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">
{sessionDetails.session.title || `Conversation on ${new Date(sessionDetails.session.started_at * 1000).toLocaleDateString()}`} {sessionDetails.session.title || `Conversation on ${new Date(sessionDetails.session.started_at * 1000).toLocaleDateString()}`}
@ -117,6 +112,9 @@ function SessionDetailsContent() {
<div className="flex items-center text-sm text-gray-500 space-x-4"> <div className="flex items-center text-sm text-gray-500 space-x-4">
<span>{new Date(sessionDetails.session.started_at * 1000).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span> <span>{new Date(sessionDetails.session.started_at * 1000).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
<span>{new Date(sessionDetails.session.started_at * 1000).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</span> <span>{new Date(sessionDetails.session.started_at * 1000).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</span>
<span className={`capitalize px-2 py-0.5 rounded-full text-xs font-medium ${sessionDetails.session.session_type === 'listen' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'}`}>
{sessionDetails.session.session_type}
</span>
</div> </div>
</div> </div>
<button <button
@ -130,29 +128,57 @@ function SessionDetailsContent() {
{sessionDetails.summary && ( {sessionDetails.summary && (
<Section title="Summary"> <Section title="Summary">
<p className="italic">"{sessionDetails.summary.tldr}"</p> <p className="text-lg italic text-gray-600 mb-4">"{sessionDetails.summary.tldr}"</p>
{sessionDetails.summary.bullet_json && JSON.parse(sessionDetails.summary.bullet_json).length > 0 &&
<div className="mt-4">
<h3 className="font-semibold text-gray-700 mb-2">Key Points:</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600">
{JSON.parse(sessionDetails.summary.bullet_json).map((point: string, index: number) => (
<li key={index}>{point}</li>
))}
</ul>
</div>
}
{sessionDetails.summary.action_json && JSON.parse(sessionDetails.summary.action_json).length > 0 &&
<div className="mt-4">
<h3 className="font-semibold text-gray-700 mb-2">Action Items:</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600">
{JSON.parse(sessionDetails.summary.action_json).map((action: string, index: number) => (
<li key={index}>{action}</li>
))}
</ul>
</div>
}
</Section> </Section>
)} )}
<Section title="Notes"> {sessionDetails.transcripts && sessionDetails.transcripts.length > 0 && (
{combinedConversation.map((item) => ( <Section title="Listen: Transcript">
<p key={item.id}> <div className="space-y-3">
<span className="font-semibold">{(item.type === 'transcript' && item.speaker === 'Me') || (item.type === 'ai_message' && item.role === 'user') ? 'You: ' : 'AI: '}</span> {sessionDetails.transcripts.map((item) => (
{item.type === 'transcript' ? item.text : item.content} <p key={item.id} className="text-gray-700">
</p> <span className="font-semibold capitalize">{item.speaker}: </span>
))} {item.text}
{combinedConversation.length === 0 && <p>No notes recorded for this session.</p>} </p>
</Section> ))}
</div>
</Section>
)}
<Section title="Audio transcript content"> {askMessages.length > 0 && (
{audioTranscripts.length > 0 ? ( <Section title="Ask: Q&A">
<ul className="list-disc list-inside space-y-1"> <div className="space-y-4">
{audioTranscripts.map(t => <li key={t.id}>{t.text}</li>)} {askMessages.map((item) => (
</ul> <div key={item.id} className={`p-3 rounded-lg ${item.role === 'user' ? 'bg-gray-100' : 'bg-blue-50'}`}>
) : ( <p className="font-semibold capitalize text-sm text-gray-600 mb-1">{item.role === 'user' ? 'You' : 'AI'}</p>
<p>No audio transcript available for this session.</p> <p className="text-gray-800 whitespace-pre-wrap">{item.content}</p>
)} </div>
</Section> ))}
</div>
</Section>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -101,8 +101,8 @@ export default function ActivityPage() {
{deletingId === session.id ? 'Deleting...' : 'Delete'} {deletingId === session.id ? 'Deleting...' : 'Delete'}
</button> </button>
</div> </div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> <span className={`capitalize inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${session.session_type === 'listen' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'}`}>
Conversation {session.session_type || 'ask'}
</span> </span>
</div> </div>
))} ))}

View File

@ -7,78 +7,8 @@ const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.exec(` // The schema is now managed by the main Electron process on startup.
-- users // This file can assume the schema is correct and up-to-date.
CREATE TABLE IF NOT EXISTS users (
uid TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER,
api_key TEXT
);
-- sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
title TEXT,
started_at INTEGER,
ended_at INTEGER,
sync_state TEXT DEFAULT 'clean',
updated_at INTEGER
);
-- transcripts
CREATE TABLE IF NOT EXISTS transcripts (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
start_at INTEGER,
end_at INTEGER,
speaker TEXT,
text TEXT,
lang TEXT,
created_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
-- ai_messages
CREATE TABLE IF NOT EXISTS ai_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
sent_at INTEGER,
role TEXT,
content TEXT,
tokens INTEGER,
model TEXT,
created_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
-- summaries
CREATE TABLE IF NOT EXISTS summaries (
session_id TEXT PRIMARY KEY,
generated_at INTEGER,
model TEXT,
text TEXT,
tldr TEXT,
bullet_json TEXT,
action_json TEXT,
tokens_used INTEGER,
updated_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
-- prompt_presets
CREATE TABLE IF NOT EXISTS prompt_presets (
id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
title TEXT NOT NULL,
prompt TEXT NOT NULL,
is_default INTEGER NOT NULL,
created_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
`);
const defaultPresets = [ const defaultPresets = [
['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1], ['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1],

View File

@ -7,7 +7,7 @@ const validator = require('validator');
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
const sessions = db.prepare( const sessions = db.prepare(
"SELECT id, uid, title, started_at, ended_at, sync_state, updated_at FROM sessions WHERE uid = ? ORDER BY started_at DESC" "SELECT id, uid, title, session_type, started_at, ended_at, sync_state, updated_at FROM sessions WHERE uid = ? ORDER BY started_at DESC"
).all(req.uid); ).all(req.uid);
res.json(sessions); res.json(sessions);
} catch (error) { } catch (error) {

View File

@ -24,6 +24,7 @@ export interface Session {
id: string; id: string;
uid: string; uid: string;
title: string; title: string;
session_type: string;
started_at: number; started_at: number;
ended_at?: number; ended_at?: number;
sync_state: 'clean' | 'dirty'; sync_state: 'clean' | 'dirty';
@ -102,6 +103,7 @@ const convertFirestoreSession = (session: { id: string } & FirestoreSession, uid
id: session.id, id: session.id,
uid, uid,
title: session.title, title: session.title,
session_type: session.session_type,
started_at: timestampToUnix(session.startedAt), started_at: timestampToUnix(session.startedAt),
ended_at: session.endedAt ? timestampToUnix(session.endedAt) : undefined, ended_at: session.endedAt ? timestampToUnix(session.endedAt) : undefined,
sync_state: 'clean', sync_state: 'clean',
@ -387,6 +389,7 @@ export const createSession = async (title?: string): Promise<{ id: string }> =>
const uid = firebaseAuth.currentUser!.uid; const uid = firebaseAuth.currentUser!.uid;
const sessionId = await FirestoreSessionService.createSession(uid, { const sessionId = await FirestoreSessionService.createSession(uid, {
title: title || 'New Session', title: title || 'New Session',
session_type: 'ask',
endedAt: undefined endedAt: undefined
}); });
return { id: sessionId }; return { id: sessionId };

View File

@ -24,6 +24,7 @@ export interface FirestoreUserProfile {
export interface FirestoreSession { export interface FirestoreSession {
title: string; title: string;
session_type: string;
startedAt: Timestamp; startedAt: Timestamp;
endedAt?: Timestamp; endedAt?: Timestamp;
} }

View File

@ -51,7 +51,17 @@ export class PickleGlassApp extends LitElement {
this.currentView = urlParams.get('view') || 'listen'; this.currentView = urlParams.get('view') || 'listen';
this.currentResponseIndex = -1; this.currentResponseIndex = -1;
this.selectedProfile = localStorage.getItem('selectedProfile') || 'interview'; this.selectedProfile = localStorage.getItem('selectedProfile') || 'interview';
this.selectedLanguage = localStorage.getItem('selectedLanguage') || 'en-US';
// Language format migration for legacy users
let lang = localStorage.getItem('selectedLanguage') || 'en';
if (lang.includes('-')) {
const newLang = lang.split('-')[0];
console.warn(`[Migration] Correcting language format from "${lang}" to "${newLang}".`);
localStorage.setItem('selectedLanguage', newLang);
lang = newLang;
}
this.selectedLanguage = lang;
this.selectedScreenshotInterval = localStorage.getItem('selectedScreenshotInterval') || '5'; this.selectedScreenshotInterval = localStorage.getItem('selectedScreenshotInterval') || '5';
this.selectedImageQuality = localStorage.getItem('selectedImageQuality') || 'medium'; this.selectedImageQuality = localStorage.getItem('selectedImageQuality') || 'medium';
this._isClickThrough = false; this._isClickThrough = false;

View File

@ -0,0 +1,76 @@
const LATEST_SCHEMA = {
users: {
columns: [
{ name: 'uid', type: 'TEXT PRIMARY KEY' },
{ name: 'display_name', type: 'TEXT NOT NULL' },
{ name: 'email', type: 'TEXT NOT NULL' },
{ name: 'created_at', type: 'INTEGER' },
{ name: 'api_key', type: 'TEXT' }
]
},
sessions: {
columns: [
{ name: 'id', type: 'TEXT PRIMARY KEY' },
{ name: 'uid', type: 'TEXT NOT NULL' },
{ name: 'title', type: 'TEXT' },
{ name: 'session_type', type: 'TEXT DEFAULT \'ask\'' },
{ name: 'started_at', type: 'INTEGER' },
{ name: 'ended_at', type: 'INTEGER' },
{ name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' },
{ name: 'updated_at', type: 'INTEGER' }
]
},
transcripts: {
columns: [
{ name: 'id', type: 'TEXT PRIMARY KEY' },
{ name: 'session_id', type: 'TEXT NOT NULL' },
{ name: 'start_at', type: 'INTEGER' },
{ name: 'end_at', type: 'INTEGER' },
{ name: 'speaker', type: 'TEXT' },
{ name: 'text', type: 'TEXT' },
{ name: 'lang', type: 'TEXT' },
{ name: 'created_at', type: 'INTEGER' },
{ name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' }
]
},
ai_messages: {
columns: [
{ name: 'id', type: 'TEXT PRIMARY KEY' },
{ name: 'session_id', type: 'TEXT NOT NULL' },
{ name: 'sent_at', type: 'INTEGER' },
{ name: 'role', type: 'TEXT' },
{ name: 'content', type: 'TEXT' },
{ name: 'tokens', type: 'INTEGER' },
{ name: 'model', type: 'TEXT' },
{ name: 'created_at', type: 'INTEGER' },
{ name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' }
]
},
summaries: {
columns: [
{ name: 'session_id', type: 'TEXT PRIMARY KEY' },
{ name: 'generated_at', type: 'INTEGER' },
{ name: 'model', type: 'TEXT' },
{ name: 'text', type: 'TEXT' },
{ name: 'tldr', type: 'TEXT' },
{ name: 'bullet_json', type: 'TEXT' },
{ name: 'action_json', type: 'TEXT' },
{ name: 'tokens_used', type: 'INTEGER' },
{ name: 'updated_at', type: 'INTEGER' },
{ name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' }
]
},
prompt_presets: {
columns: [
{ name: 'id', type: 'TEXT PRIMARY KEY' },
{ name: 'uid', type: 'TEXT NOT NULL' },
{ name: 'title', type: 'TEXT NOT NULL' },
{ name: 'prompt', type: 'TEXT NOT NULL' },
{ name: 'is_default', type: 'INTEGER NOT NULL' },
{ name: 'created_at', type: 'INTEGER' },
{ name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' }
]
}
};
module.exports = LATEST_SCHEMA;

View File

@ -54,13 +54,11 @@ class DatabaseInitializer {
await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달 await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
// 연결 후 테이블 및 기본 데이터 초기화 // This single call will now synchronize the schema and then init default data.
await sqliteClient.initTables(); await sqliteClient.initTables();
const user = await sqliteClient.getUser(sqliteClient.defaultUserId);
if (!user) { // Clean up any orphaned sessions from previous versions
await sqliteClient.initDefaultData(); await sqliteClient.cleanupEmptySessions();
console.log('[DB] Default data initialized.');
}
this.isInitialized = true; this.isInitialized = true;
console.log('[DB] Database initialized successfully'); console.log('[DB] Database initialized successfully');
@ -141,6 +139,10 @@ class DatabaseInitializer {
try { try {
console.log('[DatabaseInitializer] Validating database integrity...'); console.log('[DatabaseInitializer] Validating database integrity...');
// The synchronizeSchema function handles table and column creation now.
// We just need to ensure default data is present.
await sqliteClient.synchronizeSchema();
const defaultUser = await sqliteClient.getUser(sqliteClient.defaultUserId); const defaultUser = await sqliteClient.getUser(sqliteClient.defaultUserId);
if (!defaultUser) { if (!defaultUser) {
console.log('[DatabaseInitializer] Default user not found - creating...'); console.log('[DatabaseInitializer] Default user not found - creating...');

View File

@ -1,5 +1,6 @@
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
const path = require('path'); const path = require('path');
const LATEST_SCHEMA = require('../config/schema');
class SQLiteClient { class SQLiteClient {
constructor() { constructor() {
@ -33,88 +34,123 @@ class SQLiteClient {
}); });
} }
async initTables() { async synchronizeSchema() {
console.log('[DB Sync] Starting schema synchronization...');
const tablesInDb = await this.getTablesFromDb();
for (const tableName of Object.keys(LATEST_SCHEMA)) {
const tableSchema = LATEST_SCHEMA[tableName];
if (!tablesInDb.includes(tableName)) {
// Table doesn't exist, create it
await this.createTable(tableName, tableSchema);
} else {
// Table exists, check for missing columns
await this.updateTable(tableName, tableSchema);
}
}
console.log('[DB Sync] Schema synchronization finished.');
}
async getTablesFromDb() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const schema = ` this.db.all("SELECT name FROM sqlite_master WHERE type='table'", (err, tables) => {
PRAGMA journal_mode = WAL; if (err) return reject(err);
resolve(tables.map(t => t.name));
CREATE TABLE IF NOT EXISTS users (
uid TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER,
api_key TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
title TEXT,
started_at INTEGER,
ended_at INTEGER,
sync_state TEXT DEFAULT 'clean',
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS transcripts (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
start_at INTEGER,
end_at INTEGER,
speaker TEXT,
text TEXT,
lang TEXT,
created_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
CREATE TABLE IF NOT EXISTS ai_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
sent_at INTEGER,
role TEXT,
content TEXT,
tokens INTEGER,
model TEXT,
created_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
CREATE TABLE IF NOT EXISTS summaries (
session_id TEXT PRIMARY KEY,
generated_at INTEGER,
model TEXT,
text TEXT,
tldr TEXT,
bullet_json TEXT,
action_json TEXT,
tokens_used INTEGER,
updated_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
CREATE TABLE IF NOT EXISTS prompt_presets (
id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
title TEXT NOT NULL,
prompt TEXT NOT NULL,
is_default INTEGER NOT NULL,
created_at INTEGER,
sync_state TEXT DEFAULT 'clean'
);
`;
this.db.exec(schema, (err) => {
if (err) {
console.error('Failed to create tables:', err);
return reject(err);
}
console.log('All tables are ready.');
this.initDefaultData().then(resolve).catch(reject);
}); });
}); });
} }
async createTable(tableName, tableSchema) {
return new Promise((resolve, reject) => {
const columnDefs = tableSchema.columns.map(col => `"${col.name}" ${col.type}`).join(', ');
const query = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columnDefs})`;
console.log(`[DB Sync] Creating table: ${tableName}`);
this.db.run(query, (err) => {
if (err) return reject(err);
resolve();
});
});
}
async updateTable(tableName, tableSchema) {
return new Promise((resolve, reject) => {
this.db.all(`PRAGMA table_info("${tableName}")`, async (err, existingColumns) => {
if (err) return reject(err);
const existingColumnNames = existingColumns.map(c => c.name);
const columnsToAdd = tableSchema.columns.filter(col => !existingColumnNames.includes(col.name));
if (columnsToAdd.length > 0) {
console.log(`[DB Sync] Updating table: ${tableName}. Adding columns: ${columnsToAdd.map(c=>c.name).join(', ')}`);
for (const column of columnsToAdd) {
const addColumnQuery = `ALTER TABLE "${tableName}" ADD COLUMN "${column.name}" ${column.type}`;
try {
await this.runQuery(addColumnQuery);
} catch (alterErr) {
return reject(alterErr);
}
}
}
resolve();
});
});
}
async runQuery(query, params = []) {
return new Promise((resolve, reject) => {
this.db.run(query, params, function(err) {
if (err) return reject(err);
resolve(this);
});
});
}
async cleanupEmptySessions() {
console.log('[DB Cleanup] Checking for empty sessions...');
const query = `
SELECT s.id FROM sessions s
LEFT JOIN transcripts t ON s.id = t.session_id
LEFT JOIN ai_messages a ON s.id = a.session_id
LEFT JOIN summaries su ON s.id = su.session_id
WHERE t.id IS NULL AND a.id IS NULL AND su.session_id IS NULL
`;
return new Promise((resolve, reject) => {
this.db.all(query, [], (err, rows) => {
if (err) {
console.error('[DB Cleanup] Error finding empty sessions:', err);
return reject(err);
}
if (rows.length === 0) {
console.log('[DB Cleanup] No empty sessions found.');
return resolve();
}
const idsToDelete = rows.map(r => r.id);
const placeholders = idsToDelete.map(() => '?').join(',');
const deleteQuery = `DELETE FROM sessions WHERE id IN (${placeholders})`;
console.log(`[DB Cleanup] Found ${idsToDelete.length} empty sessions. Deleting...`);
this.db.run(deleteQuery, idsToDelete, function(deleteErr) {
if (deleteErr) {
console.error('[DB Cleanup] Error deleting empty sessions:', deleteErr);
return reject(deleteErr);
}
console.log(`[DB Cleanup] Successfully deleted ${this.changes} empty sessions.`);
resolve();
});
});
});
}
async initTables() {
await this.synchronizeSchema();
await this.initDefaultData();
}
async initDefaultData() { async initDefaultData() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -244,18 +280,52 @@ class SQLiteClient {
}); });
} }
async createSession(uid) { async getSession(id) {
return new Promise((resolve, reject) => {
this.db.get('SELECT * FROM sessions WHERE id = ?', [id], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
async updateSessionType(id, type) {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const query = 'UPDATE sessions SET session_type = ?, updated_at = ? WHERE id = ?';
this.db.run(query, [type, now, id], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
async touchSession(id) {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const query = 'UPDATE sessions SET updated_at = ? WHERE id = ?';
this.db.run(query, [now, id], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
}
async createSession(uid, type = 'ask') {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const sessionId = require('crypto').randomUUID(); const sessionId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO sessions (id, uid, title, started_at, updated_at) VALUES (?, ?, ?, ?, ?)`; const query = `INSERT INTO sessions (id, uid, title, session_type, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`;
this.db.run(query, [sessionId, uid, `Session @ ${new Date().toLocaleTimeString()}`, now, now], function(err) { this.db.run(query, [sessionId, uid, `Session @ ${new Date().toLocaleTimeString()}`, type, now, now], function(err) {
if (err) { if (err) {
console.error('SQLite: Failed to create session:', err); console.error('SQLite: Failed to create session:', err);
reject(err); reject(err);
} else { } else {
console.log(`SQLite: Created session ${sessionId} for user ${uid}`); console.log(`SQLite: Created session ${sessionId} for user ${uid} (type: ${type})`);
resolve(sessionId); resolve(sessionId);
} }
}); });
@ -275,6 +345,7 @@ class SQLiteClient {
async addTranscript({ sessionId, speaker, text }) { async addTranscript({ sessionId, speaker, text }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
const transcriptId = require('crypto').randomUUID(); const transcriptId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO transcripts (id, session_id, start_at, speaker, text, created_at) VALUES (?, ?, ?, ?, ?, ?)`; const query = `INSERT INTO transcripts (id, session_id, start_at, speaker, text, created_at) VALUES (?, ?, ?, ?, ?, ?)`;
@ -287,6 +358,7 @@ class SQLiteClient {
async addAiMessage({ sessionId, role, content, model = 'gpt-4.1' }) { async addAiMessage({ sessionId, role, content, model = 'gpt-4.1' }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
const messageId = require('crypto').randomUUID(); const messageId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO ai_messages (id, session_id, sent_at, role, content, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`; const query = `INSERT INTO ai_messages (id, session_id, sent_at, role, content, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`;
@ -299,6 +371,7 @@ class SQLiteClient {
async saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) { async saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const query = ` const query = `
INSERT INTO summaries (session_id, generated_at, model, text, tldr, bullet_json, action_json, updated_at) INSERT INTO summaries (session_id, generated_at, model, text, tldr, bullet_json, action_json, updated_at)
@ -319,6 +392,35 @@ class SQLiteClient {
}); });
} }
async runMigrations() {
return new Promise((resolve, reject) => {
console.log('[DB Migration] Checking schema for `sessions` table...');
this.db.all("PRAGMA table_info(sessions)", (err, columns) => {
if (err) {
console.error('[DB Migration] Error checking sessions table schema:', err);
return reject(err);
}
const hasSessionTypeCol = columns.some(col => col.name === 'session_type');
if (!hasSessionTypeCol) {
console.log('[DB Migration] `session_type` column missing. Altering table...');
this.db.run("ALTER TABLE sessions ADD COLUMN session_type TEXT DEFAULT 'ask'", (alterErr) => {
if (alterErr) {
console.error('[DB Migration] Failed to add `session_type` column:', alterErr);
return reject(alterErr);
}
console.log('[DB Migration] `sessions` table updated successfully.');
resolve();
});
} else {
console.log('[DB Migration] Schema is up to date.');
resolve();
}
});
});
}
close() { close() {
if (this.db) { if (this.db) {
this.db.close((err) => { this.db.close((err) => {

View File

@ -270,7 +270,17 @@ export class CustomizeView extends LitElement {
super(); super();
this.selectedProfile = localStorage.getItem('selectedProfile') || 'school'; this.selectedProfile = localStorage.getItem('selectedProfile') || 'school';
this.selectedLanguage = localStorage.getItem('selectedLanguage') || 'en-US';
// Language format migration for legacy users
let lang = localStorage.getItem('selectedLanguage') || 'en';
if (lang.includes('-')) {
const newLang = lang.split('-')[0];
console.warn(`[Migration] Correcting language format from "${lang}" to "${newLang}".`);
localStorage.setItem('selectedLanguage', newLang);
lang = newLang;
}
this.selectedLanguage = lang;
this.selectedScreenshotInterval = localStorage.getItem('selectedScreenshotInterval') || '5000'; this.selectedScreenshotInterval = localStorage.getItem('selectedScreenshotInterval') || '5000';
this.selectedImageQuality = localStorage.getItem('selectedImageQuality') || '0.8'; this.selectedImageQuality = localStorage.getItem('selectedImageQuality') || '0.8';
this.layoutMode = localStorage.getItem('layoutMode') || 'stacked'; this.layoutMode = localStorage.getItem('layoutMode') || 'stacked';
@ -428,36 +438,31 @@ export class CustomizeView extends LitElement {
getLanguages() { getLanguages() {
return [ return [
{ value: 'en-US', name: 'English (US)' }, { value: 'en', name: 'English' },
{ value: 'en-GB', name: 'English (UK)' }, { value: 'de', name: 'German' },
{ value: 'en-AU', name: 'English (Australia)' }, { value: 'es', name: 'Spanish' },
{ value: 'en-IN', name: 'English (India)' }, { value: 'fr', name: 'French' },
{ value: 'de-DE', name: 'German (Germany)' }, { value: 'hi', name: 'Hindi' },
{ value: 'es-US', name: 'Spanish (United States)' }, { value: 'pt', name: 'Portuguese' },
{ value: 'es-ES', name: 'Spanish (Spain)' }, { value: 'ar', name: 'Arabic' },
{ value: 'fr-FR', name: 'French (France)' }, { value: 'id', name: 'Indonesian' },
{ value: 'fr-CA', name: 'French (Canada)' }, { value: 'it', name: 'Italian' },
{ value: 'hi-IN', name: 'Hindi (India)' }, { value: 'ja', name: 'Japanese' },
{ value: 'pt-BR', name: 'Portuguese (Brazil)' }, { value: 'tr', name: 'Turkish' },
{ value: 'ar-XA', name: 'Arabic (Generic)' }, { value: 'vi', name: 'Vietnamese' },
{ value: 'id-ID', name: 'Indonesian (Indonesia)' }, { value: 'bn', name: 'Bengali' },
{ value: 'it-IT', name: 'Italian (Italy)' }, { value: 'gu', name: 'Gujarati' },
{ value: 'ja-JP', name: 'Japanese (Japan)' }, { value: 'kn', name: 'Kannada' },
{ value: 'tr-TR', name: 'Turkish (Turkey)' }, { value: 'ml', name: 'Malayalam' },
{ value: 'vi-VN', name: 'Vietnamese (Vietnam)' }, { value: 'mr', name: 'Marathi' },
{ value: 'bn-IN', name: 'Bengali (India)' }, { value: 'ta', name: 'Tamil' },
{ value: 'gu-IN', name: 'Gujarati (India)' }, { value: 'te', name: 'Telugu' },
{ value: 'kn-IN', name: 'Kannada (India)' }, { value: 'nl', name: 'Dutch' },
{ value: 'ml-IN', name: 'Malayalam (India)' }, { value: 'ko', name: 'Korean' },
{ value: 'mr-IN', name: 'Marathi (India)' }, { value: 'zh', name: 'Chinese' },
{ value: 'ta-IN', name: 'Tamil (India)' }, { value: 'pl', name: 'Polish' },
{ value: 'te-IN', name: 'Telugu (India)' }, { value: 'ru', name: 'Russian' },
{ value: 'nl-NL', name: 'Dutch (Netherlands)' }, { value: 'th', name: 'Thai' },
{ value: 'ko-KR', name: 'Korean (South Korea)' },
{ value: 'cmn-CN', name: 'Mandarin Chinese (China)' },
{ value: 'pl-PL', name: 'Polish (Poland)' },
{ value: 'ru-RU', name: 'Russian (Russia)' },
{ value: 'th-TH', name: 'Thai (Thailand)' },
]; ];
} }

View File

@ -43,6 +43,8 @@ let myInactivityTimer = null;
let theirInactivityTimer = null; let theirInactivityTimer = null;
const INACTIVITY_TIMEOUT = 3000; const INACTIVITY_TIMEOUT = 3000;
const SESSION_IDLE_TIMEOUT_SECONDS = 30 * 60; // 30 minutes
let previousAnalysisResult = null; let previousAnalysisResult = null;
let analysisHistory = []; let analysisHistory = [];
@ -244,6 +246,16 @@ Keep all points concise and build upon previous analysis if provided.`,
console.log(`✅ Analysis response received: ${responseText}`); console.log(`✅ Analysis response received: ${responseText}`);
const structuredData = parseResponseText(responseText, previousAnalysisResult); const structuredData = parseResponseText(responseText, previousAnalysisResult);
if (currentSessionId) {
sqliteClient.saveSummary({
sessionId: currentSessionId,
tldr: structuredData.summary.join('\n'),
bullet_json: JSON.stringify(structuredData.topic.bullets),
action_json: JSON.stringify(structuredData.actions),
model: 'gpt-4.1'
}).catch(err => console.error('[DB] Failed to save summary:', err));
}
// 분석 결과 저장 // 분석 결과 저장
previousAnalysisResult = structuredData; previousAnalysisResult = structuredData;
analysisHistory.push({ analysisHistory.push({
@ -445,11 +457,52 @@ function getCurrentSessionData() {
} }
// Conversation management functions // Conversation management functions
async function getOrCreateActiveSession(requestedType = 'ask') {
// 1. Check for an existing, valid session
if (currentSessionId) {
const session = await sqliteClient.getSession(currentSessionId);
if (session && !session.ended_at) {
// Ask sessions can expire, Listen sessions can't (they are closed explicitly)
const isExpired = session.session_type === 'ask' &&
(Date.now() / 1000) - session.updated_at > SESSION_IDLE_TIMEOUT_SECONDS;
if (!isExpired) {
// Session is valid, potentially promote it
if (requestedType === 'listen' && session.session_type === 'ask') {
await sqliteClient.updateSessionType(currentSessionId, 'listen');
console.log(`[Session] Promoted session ${currentSessionId} to 'listen'.`);
} else {
await sqliteClient.touchSession(currentSessionId);
}
return currentSessionId;
} else {
console.log(`[Session] Ask session ${currentSessionId} expired. Closing it.`);
await sqliteClient.endSession(currentSessionId);
currentSessionId = null; // Important: clear the expired session ID
}
}
}
// 2. If no valid session, create a new one
console.log(`[Session] No active session found. Creating a new one with type: ${requestedType}`);
const uid = dataService.currentUserId;
currentSessionId = await sqliteClient.createSession(uid, requestedType);
// Clear old conversation data for the new session
conversationHistory = [];
myCurrentUtterance = '';
theirCurrentUtterance = '';
previousAnalysisResult = null;
analysisHistory = [];
return currentSessionId;
}
async function initializeNewSession() { async function initializeNewSession() {
try { try {
const uid = dataService.currentUserId; // Get current user (local or firebase) currentSessionId = await getOrCreateActiveSession('listen');
currentSessionId = await sqliteClient.createSession(uid); console.log(`[DB] New listen session ensured: ${currentSessionId}`);
console.log(`[DB] New session started in DB: ${currentSessionId}`);
conversationHistory = []; conversationHistory = [];
myCurrentUtterance = ''; myCurrentUtterance = '';
@ -484,12 +537,8 @@ async function initializeNewSession() {
async function saveConversationTurn(speaker, transcription) { async function saveConversationTurn(speaker, transcription) {
if (!currentSessionId) { if (!currentSessionId) {
console.log('No active session, initializing a new one first.'); console.error('[DB] Cannot save turn, no active session ID.');
const success = await initializeNewSession(); return;
if (!success) {
console.error('Could not save turn because session initialization failed.');
return;
}
} }
if (transcription.trim() === '') return; if (transcription.trim() === '') return;
@ -513,14 +562,6 @@ async function saveConversationTurn(speaker, transcription) {
timestamp: Date.now(), timestamp: Date.now(),
transcription: transcription.trim(), transcription: transcription.trim(),
}; };
sendToRenderer('update-live-transcription', { turn: conversationTurn });
if (conversationHistory.length % 5 === 0) {
console.log(`🔄 Auto-saving conversation session ${currentSessionId} (${conversationHistory.length} turns)`);
sendToRenderer('save-conversation-session', {
sessionId: currentSessionId,
conversationHistory: conversationHistory,
});
}
} catch (error) { } catch (error) {
console.error('Failed to save transcript to DB:', error); console.error('Failed to save transcript to DB:', error);
} }
@ -551,7 +592,7 @@ async function initializeLiveSummarySession(language = 'en') {
return false; return false;
} }
initializeNewSession(); await initializeNewSession();
try { try {
const handleMyMessage = message => { const handleMyMessage = message => {
@ -859,7 +900,7 @@ function setupLiveSummaryIpcHandlers() {
ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => { ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => {
console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`); console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`);
const success = await initializeLiveSummarySession(); const success = await initializeLiveSummarySession(language);
return success; return success;
}); });
@ -940,10 +981,36 @@ function setupLiveSummaryIpcHandlers() {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('save-ask-message', async (event, { userPrompt, aiResponse }) => {
try {
const sessionId = await getOrCreateActiveSession('ask');
if (!sessionId) {
throw new Error('Could not get or create a session for the ASK message.');
}
await sqliteClient.addAiMessage({
sessionId: sessionId,
role: 'user',
content: userPrompt
});
await sqliteClient.addAiMessage({
sessionId: sessionId,
role: 'assistant',
content: aiResponse
});
console.log(`[DB] Saved ask/answer pair to session ${sessionId}`);
return { success: true };
} catch(error) {
console.error('[IPC] Failed to save ask message:', error);
return { success: false, error: error.message };
}
});
} }
module.exports = { module.exports = {
initializeLiveSummarySession,
sendToRenderer, sendToRenderer,
initializeNewSession, initializeNewSession,
saveConversationTurn, saveConversationTurn,

View File

@ -1052,6 +1052,18 @@ async function sendMessage(userPrompt, options = {}) {
if (window.require) { if (window.require) {
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
ipcRenderer.send('ask-response-stream-end'); ipcRenderer.send('ask-response-stream-end');
// Save the full conversation to DB
ipcRenderer.invoke('save-ask-message', {
userPrompt: userPrompt.trim(),
aiResponse: fullResponse
}).then(result => {
if (result.success) {
console.log('Ask/answer pair saved successfully.');
} else {
console.error('Failed to save ask/answer pair:', result.error);
}
});
} }
return { success: true, response: fullResponse }; return { success: true, response: fullResponse };
} }
@ -1096,26 +1108,6 @@ async function initConversationStorage() {
} }
} }
async function saveConversationSession(sessionId, conversationHistory) {
try {
if (!apiClient) {
throw new Error('API client not available');
}
const response = await apiClient.client.post('/api/conversations', {
sessionId,
conversationHistory,
userId: apiClient.userId,
});
console.log('대화 세션 저장 완료:', sessionId);
return response.data;
} catch (error) {
console.error('대화 세션 저장 실패:', error);
throw error;
}
}
async function getConversationSession(sessionId) { async function getConversationSession(sessionId) {
try { try {
if (!apiClient) { if (!apiClient) {
@ -1144,27 +1136,6 @@ async function getAllConversationSessions() {
} }
} }
// Listen for conversation data from main process
ipcRenderer.on('save-conversation-turn', async (event, data) => {
try {
await saveConversationSession(data.sessionId, data.fullHistory);
console.log('Conversation session saved:', data.sessionId);
} catch (error) {
console.error('Error saving conversation session:', error);
}
});
// Listen for session save request from main process
ipcRenderer.on('save-conversation-session', async (event, data) => {
try {
console.log(`📥 Received conversation session save request: ${data.sessionId}`);
await saveConversationSession(data.sessionId, data.conversationHistory);
console.log(`✅ Conversation session saved successfully: ${data.sessionId}`);
} catch (error) {
console.error('❌ Error saving conversation session:', error);
}
});
// Initialize conversation storage when renderer loads // Initialize conversation storage when renderer loads
initConversationStorage().catch(console.error); initConversationStorage().catch(console.error);