feat: ship v0.8.0 with writing logs and AI knowledge extraction
Deliver P5.2 writing day logs, cockpit heatmap, streak tracking, and chapter knowledge auto-extraction with merge review. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||
import { WritingHeatmap } from '@renderer/components/cockpit/WritingHeatmap'
|
||||
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
||||
|
||||
interface CockpitModalProps {
|
||||
@@ -60,6 +61,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
||||
{loading || !summary ? (
|
||||
<div className="sidebar-empty">{t('cockpit.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="cockpit-grid">
|
||||
<div className="cockpit-card" data-testid="cockpit-total-words">
|
||||
<div className="cockpit-card-label">{t('cockpit.totalWords')}</div>
|
||||
@@ -86,6 +88,24 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{summary.writingStats && summary.writingStats.dailyGoal > 0 && (
|
||||
<div className="cockpit-writing-section">
|
||||
<div className="cockpit-today-progress" data-testid="cockpit-today-progress">
|
||||
{t('cockpit.todayProgress', {
|
||||
current: summary.writingStats.todayWords,
|
||||
goal: summary.writingStats.dailyGoal
|
||||
})}
|
||||
</div>
|
||||
{summary.writingStats.streakDays > 0 && (
|
||||
<div className="cockpit-streak" data-testid="cockpit-streak">
|
||||
{t('cockpit.streak', { days: summary.writingStats.streakDays })}
|
||||
</div>
|
||||
)}
|
||||
<div className="cockpit-heatmap-label">{t('cockpit.heatmap')}</div>
|
||||
<WritingHeatmap heatmap={summary.writingStats.heatmap} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer cockpit-actions">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { WritingDayLog } from '@shared/types'
|
||||
|
||||
interface WritingHeatmapProps {
|
||||
heatmap: WritingDayLog[]
|
||||
}
|
||||
|
||||
function levelFor(count: number, max: number): number {
|
||||
if (count <= 0) return 0
|
||||
if (max <= 0) return 1
|
||||
const ratio = count / max
|
||||
if (ratio < 0.25) return 1
|
||||
if (ratio < 0.5) return 2
|
||||
if (ratio < 0.75) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
export function WritingHeatmap({ heatmap }: WritingHeatmapProps): React.JSX.Element {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const recent = heatmap.slice(-91)
|
||||
const max = Math.max(...recent.map((d) => d.wordCount), 1)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const cols = 13
|
||||
const rows = 7
|
||||
const cell = 12
|
||||
const gap = 2
|
||||
canvas.width = cols * (cell + gap)
|
||||
canvas.height = rows * (cell + gap)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const colors = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']
|
||||
for (let i = 0; i < recent.length; i++) {
|
||||
const col = Math.floor(i / rows)
|
||||
const row = i % rows
|
||||
const lvl = levelFor(recent[i].wordCount, max)
|
||||
ctx.fillStyle = colors[lvl]
|
||||
ctx.fillRect(col * (cell + gap), row * (cell + gap), cell, cell)
|
||||
}
|
||||
}, [recent, max])
|
||||
|
||||
return (
|
||||
<div className="cockpit-heatmap-wrap" data-testid="cockpit-heatmap">
|
||||
<canvas ref={canvasRef} className="cockpit-heatmap-canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,8 +29,14 @@ export function KnowledgeEditorDialog({
|
||||
const entries = useKnowledgeStore((s) => s.entries)
|
||||
const create = useKnowledgeStore((s) => s.create)
|
||||
const update = useKnowledgeStore((s) => s.update)
|
||||
const approveMerge = useKnowledgeStore((s) => s.approveMerge)
|
||||
const saveMergeAsNew = useKnowledgeStore((s) => s.saveMergeAsNew)
|
||||
|
||||
const existing = entryId ? entries.find((e) => e.id === entryId) : null
|
||||
const mergeTarget = existing?.mergeTargetId
|
||||
? entries.find((e) => e.id === existing.mergeTargetId)
|
||||
: undefined
|
||||
const isMergeMode = Boolean(existing?.mergeTargetId && mergeTarget)
|
||||
|
||||
const [type, setType] = useState<KnowledgeType>('fact')
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -179,6 +185,30 @@ export function KnowledgeEditorDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
{isMergeMode && bookId && existing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="knowledge-approve-merge"
|
||||
onClick={() => {
|
||||
void approveMerge(bookId, existing.id).then(onClose)
|
||||
}}
|
||||
>
|
||||
{t('knowledge.approveMerge')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="knowledge-save-as-new"
|
||||
onClick={() => {
|
||||
void saveMergeAsNew(bookId, existing.id).then(onClose)
|
||||
}}
|
||||
>
|
||||
{t('knowledge.saveAsNew')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||
import {
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
export function KnowledgePanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||
const extractThreshold = useSettingsStore((s) => s.knowledgeExtractConfidenceThreshold)
|
||||
const {
|
||||
entries,
|
||||
stats,
|
||||
@@ -25,8 +28,11 @@ export function KnowledgePanel(): React.JSX.Element {
|
||||
approve,
|
||||
reject,
|
||||
deleteEntry,
|
||||
batchApprove
|
||||
batchApprove,
|
||||
extractChapter,
|
||||
batchApproveHighConfidence
|
||||
} = useKnowledgeStore()
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [namingOpen, setNamingOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
@@ -71,6 +77,30 @@ export function KnowledgePanel(): React.JSX.Element {
|
||||
>
|
||||
{t('naming.open')}
|
||||
</button>
|
||||
{tab === 'pending' && bookId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="knowledge-batch-high-confidence"
|
||||
onClick={() => void batchApproveHighConfidence(bookId, extractThreshold)}
|
||||
>
|
||||
{t('knowledge.batchHighConfidence')}
|
||||
</button>
|
||||
)}
|
||||
{bookId && selectedChapterId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="knowledge-extract-current"
|
||||
disabled={extracting}
|
||||
onClick={() => {
|
||||
setExtracting(true)
|
||||
void extractChapter(bookId, selectedChapterId).finally(() => setExtracting(false))
|
||||
}}
|
||||
>
|
||||
{extracting ? t('knowledge.extracting') : t('knowledge.extractFromCurrent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="knowledge-tabs">
|
||||
<button
|
||||
@@ -134,10 +164,29 @@ export function KnowledgePanel(): React.JSX.Element {
|
||||
/>
|
||||
)}
|
||||
<div className="kb-item-main">
|
||||
<div className="kb-item-title">{entry.title}</div>
|
||||
<div className="kb-item-title">
|
||||
{entry.title}
|
||||
{entry.extractionConfidence != null && (
|
||||
<span className="kb-confidence-badge">
|
||||
{Math.round(entry.extractionConfidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="kb-item-meta">
|
||||
{t(`knowledge.type.${entry.type}`)} · {t(`knowledge.status.${entry.status}`)}
|
||||
</div>
|
||||
{entry.mergeTargetId && (
|
||||
<div
|
||||
className="kb-merge-hint"
|
||||
data-testid={`knowledge-merge-review-${entry.id}`}
|
||||
>
|
||||
{t('knowledge.mergeSuggest', {
|
||||
title:
|
||||
entries.find((e) => e.id === entry.mergeTargetId)?.title ??
|
||||
entry.mergeTargetId
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{entry.content && <div className="kb-item-content">{entry.content}</div>}
|
||||
</div>
|
||||
<div className="kb-item-actions">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useOutlineStore } from '@renderer/stores/useOutlineStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
@@ -90,6 +91,9 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const setTarget = useEditStore((s) => s.setTarget)
|
||||
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
|
||||
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
|
||||
const writingStats = useWritingStatsStore((s) => s.stats)
|
||||
const refreshWritingStats = useWritingStatsStore((s) => s.refresh)
|
||||
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
|
||||
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
@@ -122,6 +126,11 @@ export function EditorLayout(): React.JSX.Element {
|
||||
}
|
||||
}, [currentBookId, target?.kind, target?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentBookId) return
|
||||
void refreshWritingStats(currentBookId)
|
||||
}, [currentBookId, lastSaved, refreshWritingStats])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentBookId) return
|
||||
void ipcCall(() =>
|
||||
@@ -344,6 +353,19 @@ export function EditorLayout(): React.JSX.Element {
|
||||
))}
|
||||
{activePanel === 'chapters' && (
|
||||
<div className="sidebar-footer">
|
||||
{selectedChapterId && currentBookId && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chapter-extract-knowledge"
|
||||
onClick={() =>
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.knowledge.extractChapter(currentBookId, selectedChapterId)
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('knowledge.extractChapter')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => void handleNewChapter()}>
|
||||
+ {t('editor.newChapter')}
|
||||
</button>
|
||||
@@ -405,6 +427,23 @@ export function EditorLayout(): React.JSX.Element {
|
||||
/>
|
||||
<div id="editor-statusbar">
|
||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||
{dailyWordGoal > 0 && writingStats && (
|
||||
<span
|
||||
className={`status-daily-goal ${
|
||||
writingStats.goalProgress >= 1
|
||||
? 'goal-met'
|
||||
: writingStats.goalProgress >= 0.8
|
||||
? 'goal-near'
|
||||
: ''
|
||||
}`}
|
||||
data-testid="status-daily-goal"
|
||||
>
|
||||
{t('status.dailyGoal', {
|
||||
current: writingStats.todayWords,
|
||||
goal: dailyWordGoal
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{showStockWarning && (
|
||||
<span className="status-stock-warning" data-testid="status-stock-warning">
|
||||
{t('stock.warning', {
|
||||
|
||||
@@ -131,13 +131,61 @@ export function SettingsPage(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<span>{t('settings.dailyWordGoal')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
data-testid="settings-daily-word-goal"
|
||||
className="form-control form-control--narrow"
|
||||
value={settings.dailyWordGoal}
|
||||
onChange={(e) =>
|
||||
void settings.update({
|
||||
dailyWordGoal: Math.max(0, Number(e.target.value) || 0)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="settings-knowledge-auto-extract"
|
||||
checked={settings.knowledgeAutoExtract !== false}
|
||||
onChange={(e) =>
|
||||
void settings.update({ knowledgeAutoExtract: e.target.checked })
|
||||
}
|
||||
/>
|
||||
{t('settings.knowledgeAutoExtract')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<span>{t('settings.extractThreshold')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
data-testid="settings-extract-threshold"
|
||||
className="form-control form-control--narrow"
|
||||
value={settings.knowledgeExtractConfidenceThreshold ?? 0.8}
|
||||
onChange={(e) =>
|
||||
void settings.update({
|
||||
knowledgeExtractConfidenceThreshold: Math.min(
|
||||
1,
|
||||
Math.max(0, Number(e.target.value) || 0.8)
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{section === 'ai' && <AiSettingsPage />}
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v0.7.0
|
||||
{t('app.name')} v0.8.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -32,6 +32,10 @@ interface KnowledgeState {
|
||||
approve: (bookId: string, id: string) => Promise<void>
|
||||
reject: (bookId: string, id: string) => Promise<void>
|
||||
batchApprove: (bookId: string, ids: string[]) => Promise<void>
|
||||
extractChapter: (bookId: string, chapterId: string) => Promise<void>
|
||||
approveMerge: (bookId: string, pendingId: string) => Promise<void>
|
||||
saveMergeAsNew: (bookId: string, pendingId: string) => Promise<void>
|
||||
batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise<void>
|
||||
filteredEntries: () => KnowledgeEntry[]
|
||||
}
|
||||
|
||||
@@ -82,6 +86,22 @@ export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
|
||||
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
|
||||
await get().load(bookId)
|
||||
},
|
||||
extractChapter: async (bookId, chapterId) => {
|
||||
await ipcCall(() => window.electronAPI.knowledge.extractChapter(bookId, chapterId))
|
||||
await get().load(bookId)
|
||||
},
|
||||
approveMerge: async (bookId, pendingId) => {
|
||||
await ipcCall(() => window.electronAPI.knowledge.approveMerge(bookId, pendingId))
|
||||
await get().load(bookId)
|
||||
},
|
||||
saveMergeAsNew: async (bookId, pendingId) => {
|
||||
await ipcCall(() => window.electronAPI.knowledge.saveMergeAsNew(bookId, pendingId))
|
||||
await get().load(bookId)
|
||||
},
|
||||
batchApproveHighConfidence: async (bookId, threshold) => {
|
||||
await ipcCall(() => window.electronAPI.knowledge.batchApproveHighConfidence(bookId, threshold))
|
||||
await get().load(bookId)
|
||||
},
|
||||
filteredEntries: () => {
|
||||
const { entries, tab, forgottenOnly } = get()
|
||||
let list = entries
|
||||
|
||||
@@ -24,6 +24,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
namingIgnoreWords: [] as string[],
|
||||
knowledgeAutoSuggest: true,
|
||||
knowledgeSuggestTopN: 8,
|
||||
knowledgeAutoExtract: true,
|
||||
knowledgeExtractConfidenceThreshold: 0.8,
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { create } from 'zustand'
|
||||
import type { WritingStats } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface WritingStatsState {
|
||||
stats: WritingStats | null
|
||||
refresh: (bookId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useWritingStatsStore = create<WritingStatsState>((set) => ({
|
||||
stats: null,
|
||||
refresh: async (bookId) => {
|
||||
const stats = await ipcCall(() => window.electronAPI.writing.getStats(bookId))
|
||||
set({ stats })
|
||||
}
|
||||
}))
|
||||
@@ -868,6 +868,62 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cockpit-writing-section {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cockpit-today-progress {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cockpit-streak {
|
||||
color: var(--accent-light);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cockpit-heatmap-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cockpit-heatmap-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.status-daily-goal {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-daily-goal.goal-near {
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.status-daily-goal.goal-met {
|
||||
color: #3fb950;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kb-confidence-badge {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.kb-merge-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.bridge-body {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
|
||||
Reference in New Issue
Block a user