feat: 히스토리 자동 타이틀 + 카드 UI 분리

전사 완료 후 LLM이 10단어 이내 제목 자동 생성 (fire-and-forget).
DB에 title 컬럼 추가 + 마이그레이션.
HistoryEntryCard: 제목(볼드, ellipsis) + 내용(2줄 미리보기) 분리.
This commit is contained in:
Yun Chan 2026-04-08 11:54:37 +09:00
parent b452ebbe52
commit 239fb820b8
5 changed files with 68 additions and 1 deletions

View file

@ -191,6 +191,17 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
logger.warn('edited_transcript migration check failed:', err) logger.warn('edited_transcript migration check failed:', err)
} }
// Phase 15: history 테이블에 title 컬럼 마이그레이션
try {
const histCols = sqlite.pragma('table_info(history)') as Array<{ name: string }>
if (!histCols.some((c) => c.name === 'title')) {
sqlite.exec('ALTER TABLE history ADD COLUMN title TEXT')
logger.info('Migrated: added title column to history')
}
} catch (err) {
logger.warn('title migration check failed:', err)
}
// Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션 // Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션
try { try {
const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }> const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }>

View file

@ -8,6 +8,7 @@ export const history = sqliteTable(
'history', 'history',
{ {
id: text('id').primaryKey(), id: text('id').primaryKey(),
title: text('title'),
originalText: text('original_text').notNull(), originalText: text('original_text').notNull(),
polishedText: text('polished_text'), polishedText: text('polished_text'),
focusedApp: text('focused_app'), focusedApp: text('focused_app'),

View file

@ -36,6 +36,10 @@ class HistoryService {
this._updateStats(input.duration, input.wordCount) this._updateStats(input.duration, input.wordCount)
logger.info(`History entry created: ${id}`) logger.info(`History entry created: ${id}`)
// 비동기로 LLM 타이틀 자동 생성 (fire-and-forget)
this.generateTitle(id).catch(() => { /* ignore */ })
return this._toEntry(entry as History) return this._toEntry(entry as History)
} }
@ -182,9 +186,42 @@ class HistoryService {
.run() .run()
} }
/** 전사 완료 후 LLM으로 자동 타이틀 생성 */
async generateTitle(id: string): Promise<string | null> {
const entry = this.getById(id)
if (!entry) return null
const text = entry.polishedText || entry.originalText
if (!text || text.length < 10) return null
try {
const { getLocalLLMService } = await import('./LocalLLMService')
const llm = getLocalLLMService()
const result = await llm.generate(
text.slice(0, 2000),
{
systemPrompt: '다음 텍스트의 핵심 내용을 10단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.',
temperature: 0.3,
maxTokens: 50,
},
)
const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80)
if (title) {
const db = getDatabase()
db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
logger.info(`Auto title generated: ${id} → "${title}"`)
return title
}
} catch (err) {
logger.warn(`Auto title generation failed: ${err instanceof Error ? err.message : String(err)}`)
}
return null
}
private _toEntry(row: History): HistoryEntry { private _toEntry(row: History): HistoryEntry {
return { return {
id: row.id, id: row.id,
title: row.title ?? null,
originalText: row.originalText, originalText: row.originalText,
polishedText: row.polishedText, polishedText: row.polishedText,
focusedApp: row.focusedApp, focusedApp: row.focusedApp,
@ -204,7 +241,8 @@ class HistoryService {
llmLatencyMs: row.llmLatencyMs, llmLatencyMs: row.llmLatencyMs,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
appVersion: row.appVersion appVersion: row.appVersion,
summaryText: row.summaryText ?? null,
} }
} }
} }

View file

@ -100,13 +100,29 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} /> <Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
{/* 타이틀 */}
<Box <Box
sx={{ sx={{
fontSize: d3roTypo.compact.size, fontSize: d3roTypo.compact.size,
fontWeight: 600,
color: d3roPalette.text.primary, color: d3roPalette.text.primary,
lineHeight: d3roTypo.compact.line, lineHeight: d3roTypo.compact.line,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.5,
}}
>
{entry.title ?? displayText.slice(0, 60)}
</Box>
{/* 내용 미리보기 */}
<Box
sx={{
fontSize: d3roTypo.small.size,
color: d3roPalette.text.secondary,
lineHeight: 1.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 2, WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',

View file

@ -427,6 +427,7 @@ export interface ConfigChangedEvent {
export interface HistoryEntry { export interface HistoryEntry {
id: string id: string
title: string | null
originalText: string originalText: string
polishedText: string | null polishedText: string | null
focusedApp: string | null focusedApp: string | null