Skip to content

Commit a081bf6

Browse files
committed
feat(notes): 笔记模块 P0-P2 改进 — 14 项功能实施
P0 基础补齐: - 导出格式从 .txt 升级为 .md(复用 noteToMarkdown) - 搜索扩展至标签匹配(getFilteredNotes 增强) - 双链悬浮预览(WikiLinkPreview 组件,hover 显示笔记卡片) - 网页/PDF 剪藏(复用 import:fetch-url / import:parse-pdf IPC) P1 学习科学深化: - 笔记健康度增强(keywordCoverage/readability/conceptDensity 三维度 + 6 项进度条) - 迷你测试错题批量转闪卡 - 知识半衰期标记(expiresAt 字段 + 保质期 badge + 右键菜单) - 情绪锚点(mood 字段 + 卡片情绪图标) P2 AI 生成式教学(路由已存在,新增客户端组件): - 预测题前置(PredictionPrompt 组件,空笔记时弹出) - 播客分享按钮 + 信息图 SVG 导出 - 模板导出/导入(.keban-template 格式) - MCP 服务器新增 --port 参数支持 HTTP SSE 模式(默认关闭,仅 127.0.0.1) 验证:tsc 编译通过,1011 测试全部通过,oxlint 无新增错误
1 parent 1d7ab71 commit a081bf6

14 files changed

Lines changed: 724 additions & 63 deletions

File tree

‎client/electron/db/schema.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS notes (
2828
template TEXT NOT NULL DEFAULT 'free' CHECK (template IN ('outline','cornell','mindmap','free','qa','blank','video')),
2929
folder_id TEXT, tags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
3030
word_count INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, video_note_type TEXT,
31-
source_ref TEXT,
31+
source_ref TEXT, expires_at TEXT, mood TEXT,
3232
FOREIGN KEY (folder_id) REFERENCES note_folders(id) ON DELETE SET NULL
3333
);
3434
CREATE TABLE IF NOT EXISTS note_folders (

‎client/electron/mcpMemoryServer.ts‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,39 @@ async function main(): Promise<void> {
218218
}
219219
});
220220

221-
// 约束 2:stdio only——transport 即 stdin/stdout,无任何网络监听
222-
const transport = new StdioServerTransport();
223-
await server.connect(transport);
224-
console.error('[keban-memory] learning-memory server ready (stdio, read-only)');
221+
// 约束 2:默认 stdio,可选 --port 启用 HTTP SSE 模式(默认关闭,仅本机监听)
222+
const portArg = process.argv.indexOf('--port');
223+
const httpPort = portArg !== -1 && portArg + 1 < process.argv.length ? parseInt(process.argv[portArg + 1], 10) : 0;
224+
225+
if (httpPort > 0) {
226+
// HTTP SSE 模式:通过 HTTP 服务器暴露 MCP 端点,供外部 MCP 客户端连接
227+
const http = await import('http');
228+
const { SSEServerTransport } = await import('@modelcontextprotocol/sdk/server/sse.js');
229+
let transport: SSEServerTransport | null = null;
230+
231+
const httpServer = http.default.createServer(async (req, res) => {
232+
if (req.method === 'GET' && req.url === '/sse') {
233+
transport = new SSEServerTransport('/messages', res);
234+
console.error(`[keban-memory] MCP client connected via SSE`);
235+
await server.connect(transport);
236+
} else if (req.method === 'POST' && req.url === '/messages' && transport) {
237+
await transport.handlePostMessage(req, res);
238+
} else {
239+
res.writeHead(404);
240+
res.end();
241+
}
242+
});
243+
244+
httpServer.listen(httpPort, '127.0.0.1', () => {
245+
console.error(`[keban-memory] learning-memory server ready (HTTP SSE on port ${httpPort}, read-only)`);
246+
console.error(`[keban-memory] Connect your MCP client to: http://localhost:${httpPort}/sse`);
247+
});
248+
} else {
249+
// 默认 stdio 模式
250+
const transport = new StdioServerTransport();
251+
await server.connect(transport);
252+
console.error('[keban-memory] learning-memory server ready (stdio, read-only)');
253+
}
225254
}
226255

227256
main().catch((err) => {

‎client/src/features/assistant/components/PodcastPlayer.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
*/
77
import { useState, useCallback, useRef, useEffect } from 'react';
88
import { motion } from 'framer-motion';
9-
import { Play, Pause, Headphones, Volume2, Mic, User } from 'lucide-react';
9+
import { Play, Pause, Headphones, Volume2, Mic, User, Share2 } from 'lucide-react';
1010
import { cn } from '@/lib/utils';
11+
import { useToast } from '@/components/ui';
12+
import { copyText } from '@/lib/utils/clipboard';
1113
import { ttsController } from '@/features/assistant/lib/ttsController';
1214
import type { PodcastData, SpeakerRole } from '@/lib/ai/types';
1315

@@ -25,6 +27,7 @@ const SPEAKER_META: Record<SpeakerRole, { label: string; icon: typeof Mic; color
2527
export default function PodcastPlayer({ podcast, className, onClose }: PodcastPlayerProps) {
2628
const [playing, setPlaying] = useState(false);
2729
const [currentSegment, setCurrentSegment] = useState<number | null>(null);
30+
const { toast } = useToast();
2831
// H2: 轮询句柄与播放序号——切换片段时先清旧 interval,卸载时清理,
2932
// 防止旧 interval 的 stale closure 覆盖用户新选中的片段
3033
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -120,6 +123,17 @@ export default function PodcastPlayer({ podcast, className, onClose }: PodcastPl
120123
<span className="text-text-tertiary text-[16px]">&times;</span>
121124
</button>
122125
)}
126+
<button
127+
onClick={async () => {
128+
const text = `🎙 AI 播客:${podcast.title}\n\n${podcast.segments.map((s) => `[${s.speaker}] ${s.text}`).slice(0, 5).join('\n')}\n\n—— 来自熵减 AI 学习助手`;
129+
await copyText(text);
130+
toast({ type: 'success', message: '播客内容已复制,可分享给好友', silent: true });
131+
}}
132+
className="p-1 rounded-lg hover:bg-bg-tertiary/30 transition-colors"
133+
aria-label="分享播客"
134+
>
135+
<Share2 className="w-4 h-4 text-text-tertiary" strokeWidth={1.5} />
136+
</button>
123137
</div>
124138
</div>
125139

‎client/src/features/notes/components/EditorToolbar.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,15 @@ export interface EditorToolbarProps {
5151
onPickImage: () => void;
5252
/** N3 笔记健康度检测的文本内容(传入时在工具栏末端显示指示器) */
5353
healthContent?: string;
54+
/** 笔记标题(用于健康度关键词覆盖率评估) */
55+
healthTitle?: string;
56+
/** 笔记标签(用于健康度关键词覆盖率评估) */
57+
healthTags?: string[];
5458
/** N2 合书测试模式切换回调 */
5559
onToggleClosedBook?: () => void;
5660
}
5761

58-
export function EditorToolbar({ editor, onPickImage, healthContent, onToggleClosedBook }: EditorToolbarProps) {
62+
export function EditorToolbar({ editor, onPickImage, healthContent, healthTitle, healthTags, onToggleClosedBook }: EditorToolbarProps) {
5963
return (
6064
<div className="sticky top-0 z-20 mx-auto mt-2 flex items-center gap-1 px-4 py-1.5 rounded-[var(--kb-radius-lg)] border border-border/20 flex-shrink-0 overflow-x-auto max-w-fit bg-bg-elevated/70 backdrop-blur-xl shadow-[0_2px_16px_-4px_rgba(0,0,0,0.06)] opacity-70 hover:opacity-100 transition-opacity duration-300">
6165
<ToolbarButton icon={Undo2} label="撤销" onClick={() => editor?.chain().focus().undo().run()} />
@@ -184,7 +188,7 @@ export function EditorToolbar({ editor, onPickImage, healthContent, onToggleClos
184188
{healthContent !== undefined && (
185189
<>
186190
<ToolbarDivider />
187-
<NoteHealthIndicator content={healthContent} />
191+
<NoteHealthIndicator content={healthContent} title={healthTitle} tags={healthTags} />
188192
</>
189193
)}
190194
{/* N2 合书测试按钮:隐藏笔记,凭回忆自测 */}

‎client/src/features/notes/components/MiniQuizDialog.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,28 @@ export function MiniQuizDialog({ open, onClose, notes }: MiniQuizDialogProps) {
264264
<Button onClick={handleClose}>收工</Button>
265265
) : (
266266
<div className="flex flex-col gap-2 max-h-64 overflow-y-auto">
267+
{/* 批量操作:一键创建全部错题闪卡 */}
268+
{records.filter((r) => !r.correct).length > 1 && (
269+
<button
270+
disabled={converted.size === records.filter((r) => !r.correct).length}
271+
onClick={() => {
272+
records.forEach((r, i) => {
273+
if (!r.correct && !converted.has(i)) convertToCard(i);
274+
});
275+
}}
276+
className={cn(
277+
'sticky top-0 z-10 flex items-center justify-center gap-1.5 px-3 py-2 rounded-kb-lg text-c1 font-medium mb-1',
278+
converted.size === records.filter((r) => !r.correct).length
279+
? 'bg-bg-tertiary text-text-tertiary'
280+
: 'bg-brand-50 text-brand-600 hover:bg-brand-100 transition-colors',
281+
)}
282+
>
283+
<Layers className="w-3.5 h-3.5" strokeWidth={1.5} />
284+
{converted.size === records.filter((r) => !r.correct).length
285+
? '全部已转闪卡'
286+
: `一键转全部 ${records.filter((r) => !r.correct).length} 道错题为闪卡`}
287+
</button>
288+
)}
267289
{records.map((r, i) => r.correct ? null : (
268290
<div key={i} className="p-2.5 rounded-kb-md bg-bg-secondary border border-border/30">
269291
<p className="text-b2 text-text-primary">{r.question.question}</p>

‎client/src/features/notes/components/NoteHealthIndicator.tsx‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ const LEVEL_STYLE = {
2121
interface NoteHealthIndicatorProps {
2222
/** 笔记文本内容(markdown 或纯文本) */
2323
content: string;
24+
/** 笔记标题(用于关键词覆盖率评估) */
25+
title?: string;
26+
/** 笔记标签(用于关键词覆盖率评估) */
27+
tags?: string[];
2428
}
2529

26-
export function NoteHealthIndicator({ content }: NoteHealthIndicatorProps) {
30+
export function NoteHealthIndicator({ content, title, tags }: NoteHealthIndicatorProps) {
2731
const [debounced, setDebounced] = useState(content);
2832

2933
useEffect(() => {
@@ -32,8 +36,8 @@ export function NoteHealthIndicator({ content }: NoteHealthIndicatorProps) {
3236
}, [content]);
3337

3438
const health: NoteHealthResult | null = useMemo(
35-
() => assessNoteHealth(debounced),
36-
[debounced],
39+
() => assessNoteHealth(debounced, title, tags),
40+
[debounced, title, tags],
3741
);
3842

3943
if (!health) return null;
@@ -64,9 +68,31 @@ export function NoteHealthIndicator({ content }: NoteHealthIndicatorProps) {
6468
'group-hover/health:opacity-100 group-hover/health:scale-100',
6569
)}
6670
>
67-
<p className="font-medium mb-1">
68-
健康度 {health.score}(结构 {health.structure} · 生成 {health.generative} · 覆盖 {health.coverage})
69-
</p>
71+
<p className="font-medium mb-1.5">健康度 {health.score}</p>
72+
<div className="space-y-1 mb-1.5">
73+
{[
74+
{ label: '结构', value: health.structure },
75+
{ label: '生成', value: health.generative },
76+
{ label: '覆盖', value: health.coverage },
77+
{ label: '关键词', value: health.keywordCoverage },
78+
{ label: '可读性', value: health.readability },
79+
{ label: '概念密度', value: health.conceptDensity },
80+
].map(({ label, value }) => (
81+
<div key={label} className="flex items-center gap-1.5">
82+
<span className="w-8 text-right text-[10px] text-white/60">{label}</span>
83+
<div className="flex-1 h-1.5 rounded-full bg-white/10 overflow-hidden">
84+
<div
85+
className="h-full rounded-full transition-all duration-300"
86+
style={{
87+
width: `${value}%`,
88+
background: value >= 70 ? 'rgb(16,185,129)' : value >= 40 ? 'rgb(251,191,36)' : 'rgb(239,68,68)',
89+
}}
90+
/>
91+
</div>
92+
<span className="w-5 text-right text-[10px] font-mono text-white/80">{value}</span>
93+
</div>
94+
))}
95+
</div>
7096
{health.suggestions.map((s) => (
7197
<p key={s} className="text-white/80">· {s}</p>
7298
))}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* PredictionPrompt — 预测题前置组件
3+
* PredictionPrompt — Pre-learning prediction questions
4+
*
5+
* @ai-context: 打开新笔记或空笔记时,弹出预测性问题让用户先写下猜测,
6+
* 提交猜测后揭示笔记正文,利用预测编码机制增强学习效果。
7+
* 基于 useAIPredict 调用 AI 网关 /api/v1/ai/predict 路由。
8+
* @ai-context: Shows prediction questions when opening a new/empty note.
9+
* User writes guesses before seeing content, leveraging predictive coding.
10+
*/
11+
import { useState, useCallback } from 'react';
12+
import { motion, AnimatePresence } from 'framer-motion';
13+
import { Lightbulb, Send } from 'lucide-react';
14+
import { Button } from '@/components/ui/Button';
15+
import { useAIPredict } from '@/lib/ai/hooks/useAIPredict';
16+
import { useToast } from '@/components/ui';
17+
18+
interface PredictionPromptProps {
19+
noteTitle: string;
20+
noteContent: string;
21+
noteId: string;
22+
/** 关闭后标记为已处理,不再重复弹出 */
23+
onDismiss: () => void;
24+
}
25+
26+
export function PredictionPrompt({ noteTitle, noteContent, noteId, onDismiss }: PredictionPromptProps) {
27+
const { predict, loading: predictLoading } = useAIPredict();
28+
const { toast } = useToast();
29+
const [predictions, setPredictions] = useState<Array<{ question: string; guess: string }>>([]);
30+
const [revealed, setRevealed] = useState(false);
31+
32+
// 检查是否为空笔记(首次打开)
33+
const isEmpty = !noteContent || noteContent.length < 50;
34+
35+
// 生成预测题
36+
const handleGenerate = useCallback(async () => {
37+
if (!noteId) return;
38+
toast({ type: 'info', message: 'AI 正在生成预测题…', duration: 1500 });
39+
const result = await predict(noteId, noteContent || noteTitle);
40+
if (result?.predictions?.length) {
41+
setPredictions(result.predictions.map((p: { question: string }) => ({ question: p.question, guess: '' })));
42+
} else {
43+
toast({ type: 'warning', message: '暂无法生成预测题,可跳过直接开始', silent: true });
44+
onDismiss();
45+
}
46+
}, [noteId, noteContent, noteTitle, predict, toast, onDismiss]);
47+
48+
const updateGuess = (index: number, value: string) => {
49+
setPredictions((prev) => prev.map((p, i) => (i === index ? { ...p, guess: value } : p)));
50+
};
51+
52+
const handleReveal = () => {
53+
setRevealed(true);
54+
};
55+
56+
if (revealed || !isEmpty) return null;
57+
58+
return (
59+
<AnimatePresence>
60+
<motion.div
61+
initial={{ opacity: 0, y: 20 }}
62+
animate={{ opacity: 1, y: 0 }}
63+
exit={{ opacity: 0, y: -20 }}
64+
className="mb-4 rounded-xl border border-brand-300/30 bg-brand-50/10 p-4"
65+
>
66+
<div className="flex items-center gap-2 mb-3">
67+
<Lightbulb className="w-5 h-5 text-brand-500" strokeWidth={1.5} />
68+
<span className="text-[13px] font-semibold text-text-primary">预测驱动学习</span>
69+
<span className="text-c1 text-text-tertiary">—— 先猜后学,记忆更深刻</span>
70+
</div>
71+
72+
{predictions.length === 0 ? (
73+
<div className="flex flex-col gap-2">
74+
<p className="text-b2 text-text-secondary">
75+
关于「{noteTitle || '这篇笔记'}」的内容,你了解多少?先让 AI 出几个预测题,写下你的猜测再看笔记正文。
76+
</p>
77+
<div className="flex gap-2">
78+
<Button size="sm" onClick={handleGenerate} disabled={predictLoading}>
79+
{predictLoading ? 'AI 思考中…' : '生成预测题'}
80+
</Button>
81+
<Button variant="secondary" size="sm" onClick={onDismiss}>
82+
跳过,直接开始
83+
</Button>
84+
</div>
85+
</div>
86+
) : (
87+
<div className="flex flex-col gap-3">
88+
{predictions.map((p, i) => (
89+
<div key={i} className="flex flex-col gap-1.5">
90+
<p className="text-b2 text-text-primary font-medium">
91+
{i + 1}. {p.question}
92+
</p>
93+
<div className="flex gap-2">
94+
<input
95+
value={p.guess}
96+
onChange={(e) => updateGuess(i, e.target.value)}
97+
placeholder="写下你的猜测…"
98+
className="flex-1 px-3 py-1.5 rounded-kb-md border border-border/40 bg-bg-secondary text-b2 text-text-primary outline-none focus:border-focus"
99+
/>
100+
{p.guess && (
101+
<span className="flex items-center text-c1 text-text-tertiary">
102+
<Send className="w-3 h-3" strokeWidth={1.5} />
103+
</span>
104+
)}
105+
</div>
106+
</div>
107+
))}
108+
<div className="flex gap-2 mt-1">
109+
<Button size="sm" onClick={handleReveal}>
110+
我已写下猜测,开始学习
111+
</Button>
112+
<Button variant="secondary" size="sm" onClick={onDismiss}>
113+
跳过
114+
</Button>
115+
</div>
116+
</div>
117+
)}
118+
</motion.div>
119+
</AnimatePresence>
120+
);
121+
}

0 commit comments

Comments
 (0)