Skip to content

Commit b721398

Browse files
committed
feat(assistant,ai-gateway,db): 创新目录三/四/五部分增强落地
A1 情绪感知分级干预: behaviorMetrics 行为信号采集与三级模板规则 A2 语音对话闭环: useVoiceInput 拾音链路 + ChatInput 麦克风交互 A3 微进展叙述者: 网关 progress_narrative chain/router + Electron handler + 周统计聚合与离线降级 A4 实施意图教练: schema v6 implementation_intentions + 仓储 + 启动仪式 intention 步骤 + 定时提醒 A5 认知负荷监控: cognitiveLoad EMA 评估与 overload 事件规则 F2/F4/F5: 黄金错误周报、错误模式分析、中断恢复包; 仪式跳过菜单与杂项修复
1 parent 0cace68 commit b721398

46 files changed

Lines changed: 2343 additions & 80 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* A3 微进展叙述 Handler
3+
*
4+
* 处理 ai_progress_narrate IPC 请求,调用 AI 网关把本周学习统计
5+
* 叙述成一句温暖的进展文本;网关不可达时由渲染层离线模板兜底。
6+
*
7+
* @ai-context: A3 micro-progress narrator IPC handler——AIFeatureDef 注册表模式,
8+
* authToken 由渲染进程从 supabase session 显式注入透传(主进程不自动注入)。
9+
* 请求响应契约与网关 progress_narrative 路由的 Pydantic model 对齐。
10+
*/
11+
12+
import { safeHandle } from '../../ipcUtils.js';
13+
import { logger } from '../../logger.js';
14+
import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
15+
import { generateText } from '../ollama/OllamaProvider.js';
16+
17+
// ================================================================
18+
// IPC Handler
19+
// ================================================================
20+
21+
/**
22+
* ai_progress_narrate — POST /api/v1/ai/progress-narrative
23+
*/
24+
function register(): void {
25+
safeHandle(
26+
'ai_progress_narrate',
27+
async (
28+
_event,
29+
args: {
30+
statsText: string;
31+
authToken?: string;
32+
},
33+
) => {
34+
const startMs = Date.now();
35+
logger.info(`[AI] [progress-narrative] IPC received: stats_length=${args.statsText?.length ?? 0}, hasAuth=${!!args.authToken}`);
36+
37+
// 前端 camelCase → 后端 snake_case
38+
const reqBody = {
39+
stats_text: args.statsText ?? '',
40+
};
41+
42+
logger.info(`[AI] [progress-narrative] Target: ${gatewayUrl()}/api/v1/ai/progress-narrative`);
43+
44+
interface NarrativeGenResp {
45+
narrative: string;
46+
status: string;
47+
model: string;
48+
tokens_used: number;
49+
latency_ms: number;
50+
}
51+
52+
try {
53+
// 本地优先:Ollama 可用时本地生成(离线降级路径之一)
54+
const localHandler = async (): Promise<NarrativeGenResp> => {
55+
const prompt = `以下是用户本周的学习统计:\n${reqBody.stats_text}\n\n请把这段统计写成一句温暖、具体的微进展叙述(不超过两句话,正向语言),返回JSON: {"narrative": "..."}`;
56+
const result = await generateText(prompt, '你是一位温暖的学习教练,擅长把学习统计讲述成看得见的进步。请仅返回JSON。', { temperature: 0.7, maxTokens: 256 });
57+
const parsed = JSON.parse(result.content);
58+
return { narrative: parsed.narrative ?? '', status: 'success', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
59+
};
60+
61+
const { data: resp, source, requestId } = await callWithLocalFallback<typeof reqBody, NarrativeGenResp>(
62+
'/api/v1/ai/progress-narrative',
63+
reqBody,
64+
localHandler,
65+
args.authToken,
66+
30000,
67+
);
68+
69+
const elapsed = Date.now() - startMs;
70+
logger.info(`[AI] [progress-narrative] ✔ Success (${source}): status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
71+
return {
72+
narrative: resp.narrative,
73+
status: resp.status,
74+
model: resp.model,
75+
tokensUsed: resp.tokens_used,
76+
latencyMs: resp.latency_ms,
77+
requestId,
78+
source,
79+
};
80+
} catch (err) {
81+
const elapsed = Date.now() - startMs;
82+
const error = err instanceof Error ? err : new Error(String(err));
83+
logger.error(`[AI] [progress-narrative] ✖ Failed after ${elapsed}ms: ${error.message}`);
84+
throw error;
85+
}
86+
},
87+
);
88+
}
89+
90+
// ================================================================
91+
// 功能定义导出
92+
// ================================================================
93+
94+
export const feature: AIFeatureDef = {
95+
id: 'ai_progress_narrate',
96+
name: 'A3 微进展叙述',
97+
version: '1.0.0',
98+
register,
99+
};

‎client/electron/ai/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { feature as visionExtractFeature } from './handlers/visionExtractHandler
3232
import { feature as sessionAnalyzeFeature } from './handlers/sessionAnalyzeHandler.js';
3333
import { feature as videoAnalyzeFeature } from './handlers/videoAnalyzeHandler.js';
3434
import { feature as mergeNotesFeature } from './handlers/mergeNotesHandler.js';
35+
import { feature as errorPatternFeature } from './handlers/errorPatternHandler.js';
36+
import { feature as progressNarrativeFeature } from './handlers/progressNarratorHandler.js';
3537

3638
// ================================================================
3739
// 功能注册表
@@ -54,6 +56,8 @@ const features: AIFeatureDef[] = [
5456
sessionAnalyzeFeature,
5557
videoAnalyzeFeature,
5658
mergeNotesFeature,
59+
errorPatternFeature,
60+
progressNarrativeFeature,
5761
];
5862

5963
// ================================================================

‎client/electron/db/dbIpcHandlers.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const ALLOWED_TABLES = new Set([
3838
'crdt_docs',
3939
// crdt_changes — CRDT 同步引擎变更日志(v3 迁移新增)
4040
'crdt_changes',
41+
// implementation_intentions — A4 实施意图(v6 迁移新增)
42+
'implementation_intentions',
4143
]);
4244

4345
/** camelCase → snake_case(用于表名映射) */
@@ -71,6 +73,8 @@ const TABLE_NAME_MAP: Record<string, string> = {
7173
assistantTriggers: 'assistant_triggers',
7274
crdtDocs: 'crdt_docs',
7375
crdtChanges: 'crdt_changes',
76+
// v6 新增:A4 实施意图
77+
implementationIntentions: 'implementation_intentions',
7478
};
7579

7680
function resolveTable(table: string): string {

‎client/electron/db/schema.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import type Database from 'better-sqlite3';
88

9-
export const SCHEMA_VERSION = 5;
9+
export const SCHEMA_VERSION = 6;
1010

1111
export const SCHEMA_DDL = /* sql */ `
1212
CREATE TABLE IF NOT EXISTS pomodoro_sessions (
@@ -180,6 +180,15 @@ CREATE TABLE IF NOT EXISTS assistant_triggers (
180180
CREATE INDEX IF NOT EXISTS idx_asst_msg_session ON assistant_messages(session_id, created_at);
181181
CREATE INDEX IF NOT EXISTS idx_asst_sess_active ON assistant_sessions(is_archived, updated_at DESC);
182182
CREATE INDEX IF NOT EXISTS idx_asst_trig_rule ON assistant_triggers(rule_id, triggered_at);
183+
CREATE TABLE IF NOT EXISTS implementation_intentions (
184+
id TEXT PRIMARY KEY,
185+
if_clause TEXT NOT NULL DEFAULT '',
186+
then_clause TEXT NOT NULL DEFAULT '',
187+
trigger_at TEXT,
188+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','completed','skipped')),
189+
created_at TEXT NOT NULL
190+
);
191+
CREATE INDEX IF NOT EXISTS idx_impl_intention_status ON implementation_intentions(status, trigger_at);
183192
`;
184193

185194
/** v3 迁移 DDL:CRDT 同步引擎元数据表(条件执行) */
@@ -234,5 +243,8 @@ export function initializeSchema(db: Database.Database): void {
234243
// v5 迁移:AI 助手会话/消息/触发表(CREATE IF NOT EXISTS 幂等)
235244
// 表 DDL 已包含在 SCHEMA_DDL 中,此处无需额外操作
236245

246+
// v6 迁移:A4 实施意图表 implementation_intentions
247+
// 表 DDL 已包含在 SCHEMA_DDL 中(CREATE IF NOT EXISTS 幂等),此处无需额外操作
248+
237249
db.pragma(`user_version = ${SCHEMA_VERSION}`);
238250
}

‎client/electron/preload.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ const ALLOWED_CHANNELS = [
2626
'ai_socratic_deepening',
2727
'ai_predict',
2828
'ai_rescue',
29+
'ai_error_pattern',
2930
'ai_vision_extract',
3031
'ai_session_analyze',
3132
'ai_video_analyze',
3233
'ai_merge_notes',
34+
// A3 微进展叙述(每周一次的统计叙述生成)
35+
'ai_progress_narrate',
3336
'ai:set-gateway-url',
3437
// AI 学伴对话 IPC channel
3538
'ai:chat:send',
@@ -45,6 +48,8 @@ const ALLOWED_CHANNELS = [
4548
'audio_list_sources',
4649
'audio_capture_start',
4750
'audio_capture_stop',
51+
// A2 语音对话:启动前查询是否已有活跃采集(互斥保护,避免误伤课堂采集)
52+
'audio_capture_status',
4853
'get-app-version',
4954
'dialog:selectDirectory',
5055
'get-default-storage-path',

‎client/src/env.d.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,23 @@ declare global {
7777
deleteModel: (modelName: string) => Promise<{ success: boolean }>;
7878
onPullProgress: (callback: (progress: OllamaPullProgress) => void) => () => void;
7979
};
80+
// ── A2 语音输入(新增) ──
81+
audio_capture_status: () => Promise<{ active: boolean }>;
82+
local_asr_stream_available: () => Promise<{ available: boolean }>;
83+
audio_capture_start: (options: { microphone: boolean; chunkDurationMs?: number; sampleRate?: number; channels?: number }) => Promise<{ success: boolean; error?: string }>;
84+
local_asr_stream_start: (options: { sampleRate?: number }) => Promise<{ success: boolean; error?: string }>;
85+
local_asr_stream_stop: () => Promise<{ success: boolean }>;
86+
audio_capture_stop: () => Promise<{ success: boolean }>;
87+
// ── A3 微进展叙述(新增) ──
88+
ai_progress_narrate: (args: { statsText: string; authToken?: string }) => Promise<{
89+
narrative: string;
90+
status: string;
91+
model: string;
92+
tokensUsed: number;
93+
latencyMs: number;
94+
requestId?: string;
95+
source?: string;
96+
}>;
8097
};
8198
}
8299
}

‎client/src/features/assistant/AssistantRoot.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { useAssistantStore } from './store/useAssistantStore';
1010
import { useChat } from './hooks/useChat';
1111
import { useProactiveEngine, reportBubbleDismissed, reportBubbleResponded } from './hooks/useProactiveEngine';
1212
import { useAssistantAudio } from './hooks/useAssistantAudio';
13+
import { useBehaviorSignals } from './hooks/useBehaviorSignals';
14+
import { useBedtimeReminder } from './hooks/useBedtimeReminder';
15+
import { useIntentionCoach } from './hooks/useIntentionCoach';
1316
import { CreatureAvatar } from './components/CreatureAvatar';
1417
import { ConversationPanel } from './components/ConversationPanel';
1518

@@ -22,6 +25,12 @@ export function AssistantRoot() {
2225
const { sendMessage, retryLastMessage, dismissError } = useChat();
2326
const { playSound } = useAssistantAudio();
2427
useProactiveEngine();
28+
// A1/A5 行为信号源:情绪分级与认知负荷共用,只发事件不干预
29+
useBehaviorSignals();
30+
// F3 睡前复习推荐:晚间窗口检测到期卡,只发事件由引擎决策是否触发
31+
useBedtimeReminder();
32+
// A4 实施意图教练:周期扫描到期意图,发 intention:due 事件
33+
useIntentionCoach();
2534

2635
if (!enabled) return null;
2736

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

Lines changed: 72 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
* 设计原则:可逆 > 不可逆——TTS 开关随时可切换。
66
*/
77
import { useState, useRef } from 'react';
8-
import { Volume2, VolumeX } from 'lucide-react';
8+
import { Volume2, VolumeX, Mic, MicOff } from 'lucide-react';
99
import { useAssistantStore } from '../store/useAssistantStore';
10+
import { useVoiceInput } from '../hooks/useVoiceInput';
1011

1112
interface Props {
1213
onSend: (text: string) => void;
@@ -20,6 +21,15 @@ export function ChatInput({ onSend }: Props) {
2021
const preferences = useAssistantStore(s => s.preferences);
2122
const inputRef = useRef<HTMLTextAreaElement>(null);
2223

24+
// A2 语音输入:麦克风按钮状态与控制
25+
const { listening, partialText, error, toggle, clearError } = useVoiceInput({
26+
onFinalText: (final) => {
27+
if (final.trim()) onSend(final);
28+
clearError();
29+
inputRef.current?.focus();
30+
},
31+
});
32+
2333
const handleSend = () => {
2434
const trimmed = text.trim();
2535
if (!trimmed || isStreaming) return;
@@ -42,42 +52,67 @@ export function ChatInput({ onSend }: Props) {
4252
};
4353

4454
return (
45-
<div className="border-t border-border/50 px-3 py-2.5 flex items-end gap-2">
46-
<textarea
47-
ref={inputRef}
48-
value={text}
49-
onChange={e => setText(e.target.value)}
50-
onKeyDown={handleKeyDown}
51-
placeholder="输入消息..."
52-
rows={1}
53-
className="flex-1 resize-none rounded-xl bg-bg-tertiary/50 px-3 py-2 text-sm text-text-primary
54-
placeholder:text-text-tertiary outline-none focus:ring-1 focus:ring-cyber/40
55-
max-h-[100px] overflow-y-auto"
56-
/>
57-
{/* TTS 开关:图标 + 文字标签 + aria-pressed,状态一目了然 */}
58-
<button
59-
onClick={toggleTts}
60-
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors flex-shrink-0
61-
${ttsEnabled
62-
? 'bg-cyber/15 text-cyber ring-1 ring-cyber/40'
63-
: 'bg-bg-tertiary/50 text-text-tertiary hover:text-text-secondary'}`}
64-
title={ttsEnabled ? '语音朗读已开启(点击关闭)' : '语音朗读已关闭(点击开启)'}
65-
aria-pressed={ttsEnabled}
66-
aria-label={ttsEnabled ? '关闭语音朗读' : '开启语音朗读'}
67-
>
68-
{ttsEnabled ? <Volume2 className="w-3.5 h-3.5" strokeWidth={1.5} /> : <VolumeX className="w-3.5 h-3.5" strokeWidth={1.5} />}
69-
{ttsEnabled ? '朗读开' : '朗读关'}
70-
</button>
71-
{/* 发送 */}
72-
<button
73-
onClick={handleSend}
74-
disabled={isStreaming || !text.trim()}
75-
className="p-2 rounded-lg bg-brand-600/80 text-white text-sm disabled:opacity-40
76-
hover:bg-brand-500/80 transition-colors"
77-
aria-label="发送消息"
78-
>
79-
➤
80-
</button>
55+
<div className="border-t border-border/50 px-3 py-2.5">
56+
{/* A2 语音输入错误提示(互斥/模型缺失等)——可关闭时自动消失 */}
57+
{error && (
58+
<div className="mb-1.5 text-xs text-orange-400 flex items-center justify-between gap-2">
59+
<span>{error}</span>
60+
<button onClick={clearError} className="text-text-tertiary hover:text-text-secondary flex-shrink-0" aria-label="关闭提示">✕</button>
61+
</div>
62+
)}
63+
<div className="flex items-end gap-2">
64+
<textarea
65+
ref={inputRef}
66+
// A2 语音输入:拾音时输入框展示实时 partial 转写(受控态不覆盖已键入文本)
67+
value={listening ? partialText : text}
68+
onChange={e => setText(e.target.value)}
69+
onKeyDown={handleKeyDown}
70+
placeholder={listening ? '正在听你说…' : '输入消息...'}
71+
rows={1}
72+
className="flex-1 resize-none rounded-xl bg-bg-tertiary/50 px-3 py-2 text-sm text-text-primary
73+
placeholder:text-text-tertiary outline-none focus:ring-1 focus:ring-cyber/40
74+
max-h-[100px] overflow-y-auto"
75+
/>
76+
{/* TTS 开关:图标 + 文字标签 + aria-pressed,状态一目了然 */}
77+
<button
78+
onClick={toggleTts}
79+
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors flex-shrink-0
80+
${ttsEnabled
81+
? 'bg-cyber/15 text-cyber ring-1 ring-cyber/40'
82+
: 'bg-bg-tertiary/50 text-text-tertiary hover:text-text-secondary'}`}
83+
title={ttsEnabled ? '语音朗读已开启(点击关闭)' : '语音朗读已关闭(点击开启)'}
84+
aria-pressed={ttsEnabled}
85+
aria-label={ttsEnabled ? '关闭语音朗读' : '开启语音朗读'}
86+
>
87+
{ttsEnabled ? <Volume2 className="w-3.5 h-3.5" strokeWidth={1.5} /> : <VolumeX className="w-3.5 h-3.5" strokeWidth={1.5} />}
88+
{ttsEnabled ? '朗读开' : '朗读关'}
89+
</button>
90+
{/* A2 语音输入:麦克风按钮(拾音中脉冲高亮,防回声:流式回复中禁用) */}
91+
<button
92+
onClick={toggle}
93+
disabled={isStreaming}
94+
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors flex-shrink-0
95+
${listening
96+
? 'bg-orange-500/15 text-orange-500 ring-1 ring-orange-500/40 animate-pulse'
97+
: 'bg-bg-tertiary/50 text-text-tertiary hover:text-text-secondary'}`}
98+
title={listening ? '正在录音中(点击停止)' : '语音输入(点击开始)'}
99+
aria-pressed={listening}
100+
aria-label={listening ? '停止录音' : '开始录音'}
101+
>
102+
{listening ? <MicOff className="w-3.5 h-3.5" strokeWidth={1.5} /> : <Mic className="w-3.5 h-3.5" strokeWidth={1.5} />}
103+
{listening ? '录音中' : '说话'}
104+
</button>
105+
{/* 发送 */}
106+
<button
107+
onClick={handleSend}
108+
disabled={isStreaming || !text.trim() || listening}
109+
className="p-2 rounded-lg bg-brand-600/80 text-white text-sm disabled:opacity-40
110+
hover:bg-brand-500/80 transition-colors"
111+
aria-label="发送消息"
112+
>
113+
➤
114+
</button>
115+
</div>
81116
</div>
82117
);
83118
}

0 commit comments

Comments
 (0)