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:
2026-07-07 18:26:29 +08:00
parent f331ddae86
commit a39e06ff07
51 changed files with 1436 additions and 89 deletions
@@ -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>
+20
View File
@@ -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
+2
View File
@@ -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 })
}
}))
+56
View File
@@ -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;