Skip to content

Commit 39ba8b6

Browse files
committed
Merge branch 'dev'
2 parents f2a51b5 + 8b078c6 commit 39ba8b6

767 files changed

Lines changed: 63030 additions & 13872 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ SUPABASE_JWT_SECRET=
4747
# Supabase 项目 URL(用于后端验证 JWT issuer)
4848
SUPABASE_URL=https://your-project-id.supabase.co
4949

50+
# 开发者白名单(AI 网关):逗号分隔的 Supabase user_id 或邮箱,
51+
# 命中的账号完全豁免平台配额(不限流/不限费用)并赋予 lifetime 最高身份,
52+
# 仅供开发者自测,切勿在生产环境加入普通用户。
53+
# DEV_USER_IDS=dev@example.com,uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
54+
5055
# 服务端口
5156
SYNC_SERVICE_PORT=8080
5257
AI_GATEWAY_PORT=8000

‎.superdesign/design-system.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
| 累计专注 | 珊瑚地形 |
1616
| 灵感数 | 萤火 |
1717

18-
品牌美学 DNA:「深海生物发光美学」——深海萤火 / 认知琥珀金 / 赛博青。
18+
品牌美学 DNA:「深海生物发光美学」——深海萤火 / 磷光青绿 / 认知琥珀金。
19+
AI 专用色:深海磷光蓝 `#4A9BD9` / `#2B5F9E`(仅 AI 功能)。
1920

2021
**第二条 焦虑防线**:零负向语言、零倒计时(禁止红色倒计时压力)、零赤字、一切可关闭、回归奖赏。
2122

@@ -30,10 +31,10 @@
3031
| | 深色「深海意识」(主用) | 浅色「晨曦穹顶」 |
3132
|--|--|--|
3233
| 背景 | `#0C1524` / `#12203A` / `#182A48` | `#FAF8F5` / `#F2EDE6` / `#EBE5DC` |
33-
| 品牌主色 | 靛蓝 `brand-400 #6366F1` / `brand-500 #818CF8` | 蓝 `brand-500 #3B82F6` / `brand-600 #2563EB` |
34-
| 品牌辅色 | 赛博青 `accent-500 #06B6D4` / `accent-600 #22D3EE`(AI 呼吸灯) | 琥珀金 `accent-500 #F59E0B` |
34+
| 品牌主色 | 磷光青绿 `brand-400 #40AB92` / `brand-500 #57C6A9` | 晨光琥珀金 `brand-500 #B05E12` / `brand-600 #C9761F` |
35+
| 品牌辅色 | 认知琥珀金 `accent-500 #D18A2A` / `accent-600 #E8A74A` | 晨空蓝 `accent-500 #4A7DB0` / `accent-600 #3B689A` |
3536
| 文字 | `#E0E6F0` / `#90A0B8` / `#607088` | `#2C2720` / `#7A7570` / `#9C9590` |
36-
| 功能色 | `--kb-amber #FBBF24`(顿悟)、moss `#82C9A3`(进展)、cyber-cyan `#22D3EE`(AI) | 同族浅色 |
37+
| 功能色 | `--kb-amber #FBBF24`(顿悟)、moss `#82C9A3`(进展)、深海磷光蓝 `#4A9BD9`(AI) | 同族浅色 |
3738

3839
模块色(Tailwind 级):pomodoro `#5B8A72`(深潜)、note `#6B9BD2`(结礁)、flashcard `#7BC4B8`(反衰减呼吸)、feynman `#C4956A`(浮出水面)、classroom `#14B8A6`(回声定位)。
3940

@@ -56,14 +57,14 @@
5657
## 组件风格约束
5758

5859
- 毛玻璃面板:`bg-bg-secondary/60 backdrop-blur-xl border border-border/40`
59-
- 按钮:primary `bg-brand-600` / secondary 边框 / ghost / ai 渐变 `from-accent-500 to-brand-500`;active:scale-0.97
60+
- 按钮:primary 双光源材质(kb-btn-primary-material:环境光+自发光+微噪点+按压点亮)/ secondary 边框 / ghost / ai 磷光蓝材质(kb-btn-ai-material);active:scale-0.97
6061
- 图标:Lucide 线性 1.5 stroke;尺寸 14/16/20/24/32
6162
- 卡片 hoverable:-translate-y-0.5 + shadow 增强
6263
- 布局三层:Layer 0 3D 场景(z-0)/ Layer 1 功能面板(毛玻璃)/ Layer 2 标题栏
6364

6465
## 已知设计缺口(改进方向线索)
6566

66-
1. **硬编码色**:InspirationPage 用 `from-purple-400 to-purple-600`、`purple-500 to-cyan-500`、slate 灰,偏离品牌靛蓝/赛博青令牌
67+
1. ~~**硬编码色**:InspirationPage 用 `from-purple-400 to-purple-600`、`purple-500 to-cyan-500`、slate 灰,偏离品牌靛蓝/赛博青令牌~~ ✅ 2026-08-08 已归位(生物发光色板 + 材质类落地)
6768
2. **3D 场景仍为概念原型**:深海缺粒子系统/生物发光体/焦散光落地
6869
3. **动效分布不均**:灵感激活动效集中在 inspiration 模块;仪表盘统计卡 `kb-stat-breathe` 为唯一常驻动效
6970
4. **叙事弱化**:部分状态直接暴露内部数据(如星座 title=sortStatus),未做叙事化翻译

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
本项目所有值得关注的变更都会记录在此文件中。
44
版本号遵循 [语义化版本 SemVer](https://semver.org/lang/zh-CN/),提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/)。
55

6+
## [0.35.2](https://github.com/Aparencia/Entropydecrease/compare/v0.35.1...v0.35.2) (2026-08-04)
7+
8+
### 🐛 缺陷修复
9+
10+
* **client:** 内测反馈五连修——导航竞态/剪贴板IPC/音频资源路径/番茄钟预设/ASR兼容 ([e54257d](https://github.com/Aparencia/Entropydecrease/commit/e54257d5f776e2d05deb74dddbdf113c7981a41b))
11+
612
## [0.35.1](https://github.com/Aparencia/Entropydecrease/compare/v0.35.0...v0.35.1) (2026-08-04)
713

814
### 🐛 缺陷修复

‎client/electron/ai/gatewayHttp.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { randomUUID } from 'crypto';
1313
import { logger } from '../logger.js';
1414
import { gatewayUrl } from './gatewayConfig.js';
1515
import { isLocalInferenceEnabled } from './ollama/config.js';
16-
import { isOllamaAvailable } from './ollama/OllamaService.js';
16+
import { isOllamaAvailable, getOllamaStatus } from './ollama/OllamaService.js';
1717

1818
/** 构建公共请求头(JSON 模式含 Content-Type,multipart 由 fetch 自动生成) */
1919
function buildHeaders(clientRequestId: string, json: boolean, authToken?: string): Record<string, string> {
@@ -156,8 +156,23 @@ export async function callWithLocalFallback<TReq, TRes>(
156156
authToken?: string,
157157
timeoutMs: number = 60000,
158158
): Promise<{ data: TRes; source: 'local' | 'remote'; requestId?: string }> {
159-
// 检查本地 Ollama 是否可用
160-
if (isLocalInferenceEnabled() && isOllamaAvailable()) {
159+
// CL-M11: 本地探测缓存过期时先刷新一次再决策——isOllamaAvailable 对过期
160+
// 缓存直接返回 false,若仅因缓存过期而静默跳过本地推理,用户以为在用本地
161+
// 模型实际全走云端(隐私与成本预期偏差,且不可观测)
162+
let localAvailable = false;
163+
if (isLocalInferenceEnabled()) {
164+
localAvailable = isOllamaAvailable();
165+
if (!localAvailable) {
166+
try {
167+
const fresh = await getOllamaStatus(true);
168+
localAvailable = fresh.running;
169+
} catch {
170+
localAvailable = false; // 探测异常时安全降级远程
171+
}
172+
}
173+
}
174+
175+
if (localAvailable) {
161176
try {
162177
const localResult = await localHandler();
163178
logger.info(`[AI] ← Local Ollama success for ${apiPath}`);

‎client/electron/ai/gatewayStream.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,18 @@ export async function* postJsonStream<TReq>(
8787
let buffer = '';
8888
/** SSE 缓冲最大字节数,防止恶意服务端无分隔符输出耗尽内存 */
8989
const MAX_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
90+
/** CL-M2: 读取阶段无数据超时(ms)——原超时仅覆盖 fetch 建连阶段,
91+
* 服务端建连后挂起不发数据会导致读取永久等待 */
92+
const READ_TIMEOUT_MS = 30_000;
9093

9194
try {
9295
while (true) {
93-
const { done, value } = await reader.read();
96+
// CL-M2: 外部取消信号检查——abort 后立即停止消费,避免继续拉取
97+
if (externalSignal?.aborted) {
98+
break;
99+
}
100+
101+
let { done, value } = await readWithTimeout(reader, READ_TIMEOUT_MS);
94102
if (done) break;
95103

96104
buffer += decoder.decode(value, { stream: true });
@@ -136,6 +144,37 @@ export async function* postJsonStream<TReq>(
136144
}
137145
}
138146
} finally {
147+
// CL-M2: 所有退出路径([DONE] 提前 return/异常/取消)必须 cancel 响应流,
148+
// 否则底层 TCP 连接保持打开直到 GC(连接泄漏);cancel 对已自然结束的流为 no-op
149+
try {
150+
await reader.cancel();
151+
} catch {
152+
// 流已关闭时 cancel 抛错可忽略
153+
}
139154
reader.releaseLock();
140155
}
141156
}
157+
158+
/**
159+
* CL-M2: 带超时的流读取——读取阶段无数据超时(30s)时取消流并抛错,
160+
* 防止服务端建连后挂起导致调用方永久等待;超时同时 cancel reader
161+
* 释放底层 TCP 连接(否则挂起的 read() 永远不结束)。
162+
*/
163+
async function readWithTimeout(
164+
reader: ReadableStreamDefaultReader<Uint8Array>,
165+
timeoutMs: number,
166+
): Promise<ReadableStreamReadResult<Uint8Array>> {
167+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
168+
const timeoutPromise = new Promise<never>((_, reject) => {
169+
timeoutId = setTimeout(() => {
170+
// 取消流:让挂起的 read() 立即结束,并释放底层连接
171+
reader.cancel().catch(() => {});
172+
reject(new Error(`Stream read timeout after ${timeoutMs}ms`));
173+
}, timeoutMs);
174+
});
175+
try {
176+
return await Promise.race([reader.read(), timeoutPromise]);
177+
} finally {
178+
if (timeoutId) clearTimeout(timeoutId);
179+
}
180+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* P1 今日学习计划 Handler
3+
*
4+
* 处理 ai_learning_plan IPC 请求,调用 AI 网关根据客户端聚合的学习状态
5+
* 生成今日任务计划;本地 Ollama 可用时本地生成(离线降级路径之一),
6+
* 全部不可用时由渲染层本地规则规划兜底(degraded 语义)。
7+
*
8+
* @ai-context: P1 learning-plan IPC handler——AIFeatureDef 注册表模式,
9+
* authToken 由渲染进程从 supabase session 显式注入透传(主进程不自动注入)。
10+
* 请求响应契约与网关 learning_plan 路由的 Pydantic model 对齐。
11+
*/
12+
13+
import { safeHandle } from '../../ipcUtils.js';
14+
import { logger } from '../../logger.js';
15+
import { callWithLocalFallback, gatewayUrl, parseModelJson, type AIFeatureDef } from '../utils.js';
16+
import { generateText } from '../ollama/OllamaProvider.js';
17+
18+
// ================================================================
19+
// IPC Handler
20+
// ================================================================
21+
22+
/**
23+
* ai_learning_plan — POST /api/v1/ai/learning-plan
24+
*/
25+
function register(): void {
26+
safeHandle(
27+
'ai_learning_plan',
28+
async (
29+
_event,
30+
args: {
31+
masterySummary?: string;
32+
dueCounts?: Record<string, number>;
33+
peakHours?: number[];
34+
weeklyGoalMinutes?: number;
35+
todayMinutes?: number;
36+
authToken?: string;
37+
},
38+
) => {
39+
// 所有字段均可选(服务端 Pydantic 模型均为 Optional):
40+
// masterySummary 允许空串(新用户无掌握度数据时 AI 仍可生成入门计划),
41+
// 其他字段为 undefined 时后端跳过拼接。仅校验类型,避免 requireText
42+
// 拒绝空串导致新用户永远回退本地规划(AI 路径被静默跳过)。
43+
if (args?.masterySummary !== undefined && typeof args.masterySummary !== 'string') {
44+
throw new Error('IPC 入参错误: masterySummary 必须为字符串');
45+
}
46+
if (args?.dueCounts !== undefined && (typeof args.dueCounts !== 'object' || args.dueCounts === null || Array.isArray(args.dueCounts))) {
47+
throw new Error('IPC 入参错误: dueCounts 必须为对象');
48+
}
49+
const startMs = Date.now();
50+
logger.info(
51+
`[AI] [learning-plan] IPC received: mastery_len=${args.masterySummary?.length ?? 0}, due_decks=${Object.keys(args.dueCounts ?? {}).length}, hasAuth=${!!args.authToken}`,
52+
);
53+
54+
// 前端 camelCase → 后端 snake_case
55+
const reqBody = {
56+
mastery_summary: args.masterySummary ?? '',
57+
due_counts: args.dueCounts ?? undefined,
58+
peak_hours: args.peakHours ?? undefined,
59+
weekly_goal_minutes: args.weeklyGoalMinutes ?? undefined,
60+
today_minutes: args.todayMinutes ?? undefined,
61+
};
62+
63+
logger.info(`[AI] [learning-plan] Target: ${gatewayUrl()}/api/v1/ai/learning-plan`);
64+
65+
interface PlanItemResp {
66+
module: string;
67+
title: string;
68+
minutes: number;
69+
task: string;
70+
reason: string;
71+
order: number;
72+
}
73+
74+
interface LearningPlanResp {
75+
date: string;
76+
items: PlanItemResp[];
77+
note: string;
78+
status: string;
79+
model: string;
80+
tokens_used: number;
81+
latency_ms: number;
82+
}
83+
84+
try {
85+
// 本地优先:Ollama 可用时本地生成(离线降级路径之一)
86+
const localHandler = async (): Promise<LearningPlanResp> => {
87+
const contextLines = [
88+
args.masterySummary ? `掌握度摘要:${args.masterySummary}` : '',
89+
args.dueCounts && Object.keys(args.dueCounts).length > 0
90+
? `今日到期卡片:${Object.entries(args.dueCounts).map(([k, v]) => `${k} ${v} 张`).join('、')}`
91+
: '',
92+
args.peakHours?.length ? `个人高峰时段:${args.peakHours.join(',')} 点` : '',
93+
args.weeklyGoalMinutes != null ? `周目标:${args.weeklyGoalMinutes} 分钟` : '',
94+
args.todayMinutes != null ? `今日已学习:${args.todayMinutes} 分钟` : '',
95+
].filter(Boolean).join('\n') || '(无历史数据,生成一份轻量入门计划)';
96+
97+
const prompt = `用户今日学习状态:\n${contextLines}\n\n请生成今日学习计划。任务模块仅限:pomodoro(深潜番茄钟)/notes(结礁笔记)/flashcards(闪卡复习)/feynman(费曼讲解)/inspiration(灵感沉淀)。到期卡片优先复习;单任务10-60分钟,共2-4项,总时长不超过90分钟;高峰时段安排深度学习。返回JSON: {"date":"YYYY-MM-DD","items":[{"module":"...","title":"...","minutes":30,"task":"具体做什么","reason":"为什么安排这个","order":1}],"note":"一句鼓励语"}`;
98+
const result = await generateText(prompt, '你是一位学习规划教练,只输出 JSON。', { temperature: 0.6, maxTokens: 1024 });
99+
const parsed = parseModelJson<Partial<LearningPlanResp>>(result.content, {});
100+
const items = Array.isArray(parsed.items) ? parsed.items : [];
101+
return {
102+
date: parsed.date ?? new Date().toISOString().slice(0, 10),
103+
items,
104+
note: parsed.note ?? '',
105+
status: items.length > 0 ? 'success' : 'degraded',
106+
model: result.model,
107+
tokens_used: result.tokens_used,
108+
latency_ms: result.latency_ms,
109+
};
110+
};
111+
112+
const { data: resp, source, requestId } = await callWithLocalFallback<typeof reqBody, LearningPlanResp>(
113+
'/api/v1/ai/learning-plan',
114+
reqBody,
115+
localHandler,
116+
args.authToken,
117+
30000,
118+
);
119+
120+
const elapsed = Date.now() - startMs;
121+
logger.info(
122+
`[AI] [learning-plan] ✔ Success (${source}): items=${resp.items?.length ?? 0}, status=${resp.status}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`,
123+
);
124+
return {
125+
date: resp.date,
126+
items: resp.items ?? [],
127+
note: resp.note ?? '',
128+
status: resp.status,
129+
model: resp.model,
130+
tokensUsed: resp.tokens_used,
131+
latencyMs: resp.latency_ms,
132+
requestId,
133+
source,
134+
};
135+
} catch (err) {
136+
const elapsed = Date.now() - startMs;
137+
const error = err instanceof Error ? err : new Error(String(err));
138+
logger.error(`[AI] [learning-plan] ✖ Failed after ${elapsed}ms: ${error.message}`);
139+
throw error;
140+
}
141+
},
142+
);
143+
}
144+
145+
// ================================================================
146+
// 功能定义导出
147+
// ================================================================
148+
149+
export const feature: AIFeatureDef = {
150+
id: 'ai_learning_plan',
151+
name: 'P1 今日学习计划',
152+
version: '1.0.0',
153+
register,
154+
};

0 commit comments

Comments
 (0)