From 08b3eb1eed0445fe267db1198a22a40c81950910 Mon Sep 17 00:00:00 2001 From: Huangdingcheng Date: Tue, 21 Apr 2026 19:36:48 +0800 Subject: [PATCH] feat: upgrade ThinkFlow flashcard generation and study UX - add flashcard generation controls for difficulty, count, topic, and test focus - persist generation config and show it when reopening flashcard outputs - add structured card citations with inline citation preview and source switching - map outputs-v2 flashcard citations back to real source files instead of generation_input.md - support flashcard and quiz outputs importing back as knowledge sources - improve Chinese and English flashcard study UI with animated flip cards - fix flashcard back-face leakage, source preview behavior, and dark-card readability - document the final flashcard design and implementation plan --- .../2026-04-20-thinkflow-flashcard-upgrade.md | 81 +++ ...4-20-thinkflow-flashcard-upgrade-design.md | 536 ++++++++++++++++++ fastapi_app/config/settings.py | 8 +- fastapi_app/routers/kb.py | 106 +++- fastapi_app/routers/kb_outputs_v2.py | 2 + fastapi_app/schemas.py | 26 +- fastapi_app/services/flashcard_service.py | 161 +++++- fastapi_app/services/output_v2_service.py | 98 +++- .../components/flashcards/FlashcardViewer.tsx | 219 ++++++- frontend_en/src/pages/NotebookView.tsx | 75 ++- .../components/ThinkFlowAddSourceModal.tsx | 12 +- .../components/ThinkFlowFlashcardStudy.tsx | 196 ++++++- .../src/components/ThinkFlowWorkspace.css | 413 +++++++++++++- .../src/components/ThinkFlowWorkspace.tsx | 270 ++++++++- frontend_zh/src/components/thinkflow-types.ts | 17 + 15 files changed, 2099 insertions(+), 121 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-20-thinkflow-flashcard-upgrade.md create mode 100644 docs/superpowers/specs/2026-04-20-thinkflow-flashcard-upgrade-design.md diff --git a/docs/superpowers/plans/2026-04-20-thinkflow-flashcard-upgrade.md b/docs/superpowers/plans/2026-04-20-thinkflow-flashcard-upgrade.md new file mode 100644 index 0000000..84c3192 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-thinkflow-flashcard-upgrade.md @@ -0,0 +1,81 @@ +# ThinkFlow Flashcard Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement flashcard generation controls, structured citations, persisted generation settings, and synced CN/EN flashcard UX. + +**Architecture:** Extend the backend flashcard schema and persistence first so both direct API generation and outputs-v2 generation emit the same `generation_config` and card-level `citations`. Then update the Chinese workspace flow and English flashcard modal to consume the same structure and reuse existing source preview logic. + +**Tech Stack:** FastAPI, Pydantic, React, TypeScript, CSS, Framer Motion + +--- + +### Task 1: Backend flashcard schema and generation metadata + +**Files:** +- Modify: `fastapi_app/schemas.py` +- Modify: `fastapi_app/services/flashcard_service.py` +- Modify: `fastapi_app/routers/kb.py` + +- [x] **Step 1: Extend flashcard models with citation and generation config fields** + +- [x] **Step 2: Update LLM prompt and response parser to preserve `[1][2]` answers plus structured `citations`** + +- [x] **Step 3: Persist `generation_config` in `flashcards.json` and API response payload** + +### Task 2: outputs-v2 flashcard config threading + +**Files:** +- Modify: `fastapi_app/routers/kb_outputs_v2.py` +- Modify: `fastapi_app/services/output_v2_service.py` + +- [x] **Step 1: Let outputs-v2 outline requests accept `flashcard_config`** + +- [x] **Step 2: Save `flashcard_config` in output items and forward it into flashcard generation** + +- [x] **Step 3: Preserve legacy flashcard `generation_config` when scanning old outputs** + +### Task 3: Chinese flashcard study UX + +**Files:** +- Modify: `frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx` +- Modify: `frontend_zh/src/components/ThinkFlowWorkspace.tsx` +- Modify: `frontend_zh/src/components/ThinkFlowWorkspace.css` +- Modify: `frontend_zh/src/components/thinkflow-types.ts` + +- [x] **Step 1: Parse flashcard citations and generation config from output results** + +- [x] **Step 2: Add generation controls to the flashcard direct-output confirmation flow** + +- [x] **Step 3: Render interactive citations, citation preview panel, open-full-source action, and upgraded card visuals** + +### Task 4: English flashcard sync + +**Files:** +- Modify: `frontend_en/src/pages/NotebookView.tsx` +- Modify: `frontend_en/src/components/flashcards/FlashcardViewer.tsx` + +- [x] **Step 1: Extend flashcard settings panel with difficulty, card count, topic, and test focus** + +- [x] **Step 2: Forward new settings to `/generate-flashcards` and load persisted `generation_config`** + +- [x] **Step 3: Render interactive citations and generation settings in the English flashcard viewer** + +### Task 5: Verification + +**Files:** +- Verify: `fastapi_app/schemas.py` +- Verify: `fastapi_app/services/flashcard_service.py` +- Verify: `fastapi_app/routers/kb.py` +- Verify: `fastapi_app/routers/kb_outputs_v2.py` +- Verify: `fastapi_app/services/output_v2_service.py` +- Verify: `frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx` +- Verify: `frontend_zh/src/components/ThinkFlowWorkspace.tsx` +- Verify: `frontend_en/src/pages/NotebookView.tsx` +- Verify: `frontend_en/src/components/flashcards/FlashcardViewer.tsx` + +- [ ] **Step 1: Run focused Python compile checks** + +- [ ] **Step 2: Run frontend TypeScript/build checks where available** + +- [ ] **Step 3: Review diff for CN/EN parity and remaining compatibility risks** diff --git a/docs/superpowers/specs/2026-04-20-thinkflow-flashcard-upgrade-design.md b/docs/superpowers/specs/2026-04-20-thinkflow-flashcard-upgrade-design.md new file mode 100644 index 0000000..4c903a2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-thinkflow-flashcard-upgrade-design.md @@ -0,0 +1,536 @@ +# ThinkFlow 闪卡功能升级设计 + +> 当前状态:本文末尾已按 2026-04-21 实际落地版本补充“当前落地设计”。补充内容覆盖生成配置、真实来源路径、`generation_input.md` 纠偏、引用预览、回流来源、翻卡动效与可读性修复。 + +## 1. 背景与目标 + +当前 ThinkFlow 闪卡存在三个明确问题: + +1. 闪卡答案里可能出现来源编号引用,如 `[1]`、`[2]`,但当前无法点击,也无法简略查看对应知识来源。 +2. 闪卡生成配置能力不足,缺少难度等级、卡片数量、主题、测试内容等控制项。 +3. 闪卡展示风格偏朴素,不符合“闪卡”这种偏记忆强化工具的产品气质。 + +本次升级目标: + +- 让闪卡答案中的来源编号可点击,并支持卡片内简版来源预览与跳转完整来源。 +- 为闪卡增加生成配置,且将配置保存到这组闪卡结果中。 +- 对闪卡进行更有表现力的视觉升级。 +- 中文前端与英文前端同步支持,不允许只改单端。 + +## 2. 用户确认的行为约束 + +### 2.1 来源引用 + +- 答案中的 `[1]`、`[2]`、`[3]` 等编号都代表来源引用,不限于 `[1]`。 +- 一张卡片中若存在多个引用,例如 `[1][2]`,这些引用需要分别可点。 +- 点击某个引用后: + - 先在卡片背面展开当前引用的简版来源预览。 + - 如果该卡还有其他引用,用户可以在预览区中切换到其他引用。 + - 当前预览区提供“打开完整来源”按钮,复用现有来源详情能力。 + +### 2.2 生成配置 + +- 难度等级为单选: + - `基础` + - `进阶` + - `挑战` +- 用户选择某一难度后,生成整组同一难度的闪卡。 +- 卡片数量允许用户设置。 +- 如果用户不设置卡片数量,则沿用当前默认生成逻辑。 +- 主题、测试内容为自由文本输入。 +- 主题、测试内容使用“自由文本 + 示例占位提示”。 +- 这些生成配置需要保存到该组闪卡结果中。 +- 重新打开该组闪卡时,用户可以看到当时的生成条件。 +- 但这些值不作为下一次生成的默认值。 + +### 2.3 视觉风格 + +- 闪卡需要更炫酷、更有记忆工具感。 +- 保持翻卡交互,但视觉层次、动画、难度差异需要增强。 + +## 3. 现状分析 + +### 3.1 后端数据结构现状 + +当前闪卡 schema 定义见: + +- `fastapi_app/schemas.py` + +现有 `Flashcard` 仅包含: + +- `question` +- `answer` +- `difficulty` +- `source_file` +- `source_excerpt` +- `tags` + +问题: + +- 没有显式保存卡片级引用映射,无法稳定支持 `[1][2]` 点击。 +- 没有保存整组闪卡的生成配置。 + +### 3.2 中文前端现状 + +当前中文前端闪卡组件见: + +- `frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx` +- `frontend_zh/src/components/ThinkFlowWorkspace.tsx` +- `frontend_zh/src/components/ThinkFlowWorkspace.css` + +问题: + +- 背面答案是普通文本,没有 citation 解析。 +- 只有 `source_file` / `source_excerpt` 的静态显示,没有“多引用 -> 多来源预览”的交互层。 +- 当前卡片已有翻转结构,但视觉表达仍偏基础。 + +### 3.3 英文前端现状 + +当前英文前端闪卡链路见: + +- `frontend_en/src/pages/NotebookView.tsx` +- `frontend_en/src/components/flashcards/FlashcardViewer.tsx` + +问题: + +- 英文前端与中文前端并非同一套组件,需要同步改造。 +- 当前英文闪卡也没有 citation 点击能力。 +- 当前英文闪卡配置能力与本次需求不匹配。 + +## 4. 设计方案 + +### 4.1 总体方案 + +采用“前后端一起补齐元数据”的方案,而不是仅做前端补丁。 + +原因: + +- 闪卡历史记录需要可复现。 +- 同一张卡片可能有多个引用,必须保存稳定映射。 +- 生成条件需要跟随这组闪卡一起持久化。 +- 中英前端都需要消费同一套结构化数据。 + +### 4.2 后端结构扩展 + +#### 4.2.1 请求结构扩展 + +扩展闪卡生成请求: + +- `difficulty_level: Optional[str]` +- `card_count: Optional[int]` +- `topic: Optional[str]` +- `test_focus: Optional[str]` + +说明: + +- `difficulty_level` 取值限定为: + - `basic` + - `intermediate` + - `advanced` +- 前端展示使用中文文案,但后端内部建议使用稳定英文枚举。 +- `card_count` 若为空,则走当前默认逻辑。 +- `topic`、`test_focus` 可为空。 + +#### 4.2.2 闪卡结果结构扩展 + +新增卡片级引用结构: + +- `citations: List[FlashcardCitation]` + +其中每个 citation 建议包含: + +- `source_number` +- `file_name` +- `file_path` +- `preview` +- `chunk_index` + +新增整组配置结构: + +- `generation_config: FlashcardGenerationConfig` + +建议字段: + +- `difficulty_level` +- `card_count` +- `topic` +- `test_focus` +- `language` +- `generated_at` + +#### 4.2.3 兼容策略 + +旧闪卡数据兼容原则: + +- 没有 `generation_config` 时,前端不显示“本组生成条件”。 +- 没有 `citations` 时,前端不把 `[1]` 渲染成可点击交互。 +- 老字段 `source_file` / `source_excerpt` 保留,作为兼容性兜底展示。 + +### 4.3 闪卡生成逻辑 + +闪卡生成链路在后端应新增以下能力: + +- 将 `difficulty_level`、`topic`、`test_focus` 进入 prompt。 +- 将 `card_count` 显式传递给生成逻辑。 +- 生成结果中: + - `answer` 保留 `[1][2]` 这种文本编号。 + - 同时结构化保存 `citations`,供前端渲染点击逻辑。 + +引用映射来源应优先复用现有知识问答链路中的来源编号语义,而不是由前端自行猜测。 + +### 4.4 中文前端交互设计 + +#### 4.4.1 生成前配置区 + +在当前闪卡生成入口附近增加配置区,字段如下: + +- 难度等级:单选 +- 卡片数量:数字输入 +- 主题:自由文本输入,带示例占位提示 +- 测试内容:自由文本输入,带示例占位提示 + +示例占位提示: + +- 主题:`例如:Transformer 结构、实验结果对比、核心术语` +- 测试内容:`例如:只考概念理解、偏实验结论、重点记忆公式` + +交互规则: + +- 不填写卡片数量时,后端走默认值。 +- 不填写主题或测试内容时,不额外注入限制。 + +#### 4.4.2 闪卡展示区 + +卡片正面: + +- 问题 +- 难度标识 +- 卡片类型 + +卡片背面: + +- 答案 +- 可点击来源引用 +- 来源预览区 +- 标签区 + +#### 4.4.3 来源引用交互 + +答案中的 `[1]`、`[2]` 等标记将被解析为 citation token。 + +行为: + +- 点击某个 token 后,背面展开来源预览区。 +- 默认展示当前被点击引用的预览。 +- 如果这张卡还有其他引用,在预览区顶部显示可切换引用标签。 +- 每个引用预览区显示: + - 来源编号 + - 文件名 + - 片段预览 + - “打开完整来源”按钮 + +“打开完整来源”行为: + +- 复用现有来源详情打开逻辑。 +- 若已存在来源详情弹层或侧栏能力,则直接复用,不新增第二套来源查看器。 + +#### 4.4.4 历史闪卡 + +重新打开一组闪卡时: + +- 在闪卡视图顶部展示“本组生成条件”。 +- 包括: + - 难度 + - 卡片数量 + - 主题 + - 测试内容 + - 生成时间 + +该信息只展示,不回填到新建闪卡配置表单中。 + +### 4.5 英文前端同步设计 + +英文前端同步改造原则: + +- 与中文前端能力对齐 +- 交互一致 +- 仅文案英文化,不做双轨功能差异 + +需要同步的能力: + +- 闪卡生成配置区 +- 历史闪卡生成条件展示 +- citation 点击与简版来源预览 +- 打开完整来源按钮 +- 更强的翻卡视觉表现 + +### 4.6 视觉升级方案 + +#### 4.6.1 视觉方向 + +保持翻卡核心模式,但增强以下元素: + +- 3D 翻转深度 +- 卡面渐变与光泽层 +- 边缘高光与更明显阴影 +- 难度等级的视觉区分 +- 来源预览区的轻量展开动画 + +#### 4.6.2 难度视觉映射 + +- `基础` + - 明亮、清晰、轻压力 +- `进阶` + - 对比更强、层次更深 +- `挑战` + - 更锐利、更深色、更强聚焦 + +#### 4.6.3 动效边界 + +动效原则: + +- 有记忆点,但不影响连续刷卡效率 +- 不做重型粒子或过度动画 +- 确保移动端和桌面端都能稳定运行 + +## 5. 文件级改动范围 + +### 5.1 后端 + +- `fastapi_app/schemas.py` +- `fastapi_app/services/output_v2_service.py` +- 闪卡生成相关 service / workflow 实现文件 + +### 5.2 中文前端 + +- `frontend_zh/src/components/ThinkFlowWorkspace.tsx` +- `frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx` +- `frontend_zh/src/components/ThinkFlowWorkspace.css` + +### 5.3 英文前端 + +- `frontend_en/src/pages/NotebookView.tsx` +- `frontend_en/src/components/flashcards/FlashcardViewer.tsx` +- 如有必要,同步英文侧闪卡生成入口组件 + +## 6. 验证方案 + +至少验证以下场景: + +1. 新生成一组闪卡时,四个生成配置字段都能正确提交。 +2. 不填写卡片数量时,仍按默认逻辑生成。 +3. 单卡包含多个引用时,例如 `[1][2]`,两个编号都能点击。 +4. 点击某个引用后,先展开当前预览,再能切换其他引用。 +5. “打开完整来源”可跳转到现有来源详情。 +6. 重新打开同一组闪卡时,能够看到保存下来的生成条件。 +7. 老闪卡数据仍可正常打开,不因缺少 `generation_config` / `citations` 报错。 +8. 中文前端与英文前端功能一致。 + +## 7. 风险与注意事项 + +- 闪卡引用 `[1]` 的点击能力不能仅靠答案字符串猜测,必须以结构化 `citations` 为准。 +- 中英文前端必须同步修改,避免功能漂移。 +- 历史兼容不能破坏旧闪卡读取。 +- 本次需求只针对闪卡,不顺手重构 quiz 全链路。 + +## 8. 当前落地设计(2026-04-21) + +本节记录当前已经落地并经过多轮修正后的完整设计。若前文与本节有差异,以本节为准。 + +### 8.1 生成前自选配置 + +闪卡在“确认本次闪卡来源”弹窗中提供生成前配置,不再直接使用固定默认值。 + +配置项: + +- 难度等级:单选,取值为 `basic` / `intermediate` / `advanced`,中文显示为“基础 / 进阶 / 挑战”。 +- 卡片数量:数字输入,范围在前端限制为 1-50;为空时后端沿用默认逻辑。 +- 主题:自由文本输入,用于限定生成主题,例如“Transformer 结构、实验结果对比、核心术语”。 +- 测试内容:自由文本输入,用于限定考察重点,例如“只考概念理解、偏实验结论、重点记忆公式”。 + +行为规则: + +- 配置在确认弹窗内可编辑,点击“确认并开始生成”时随 `flashcard_config` 提交。 +- 配置会保存到本组闪卡结果的 `generation_config` / output 的 `flashcard_config`。 +- 重新打开历史闪卡时,顶部展示“本组生成条件”。 +- 历史条件只展示,不回填下一次生成表单。 +- 成功开始生成后,前端重置本次草稿配置,避免污染下一次生成。 + +### 8.2 后端数据结构 + +闪卡卡片结构包含: + +- `question` +- `answer` +- `type` +- `difficulty` +- `source_file` +- `source_excerpt` +- `tags` +- `citations` +- `created_at` + +`citations` 是卡片级结构化引用数组,每项包含: + +- `source_number` +- `file_name` +- `file_path` +- `preview` +- `chunk_index` + +整组生成配置结构为 `FlashcardGenerationConfig`: + +- `difficulty_level` +- `card_count` +- `topic` +- `test_focus` +- `language` +- `generated_at` + +### 8.3 outputs-v2 真实来源策略 + +outputs-v2 生成闪卡时会先创建聚合输入文件 `generation_input.md`。该文件只作为 LLM 的综合上下文,不应作为用户可见来源。 + +真实来源策略: + +- output 创建时保存真实来源快照: + - `source_paths` + - `source_names` +- 生成闪卡时,`file_paths=[generation_input.md]` 继续用于提取综合文本。 +- 同时额外传入: + - `citation_source_paths=item.source_paths` + - `citation_source_names=item.source_names` +- 后端使用 `citation_source_paths/source_names` 构造可见 citation 映射。 +- 如果真实 PDF 路径解析失败,也不能回退显示 `generation_input.md`;至少保留真实 PDF 文件名。 +- 前端展示层额外做历史兼容:如果旧结果里 `source_file` 或 `citations[].file_name/file_path` 包含 `generation_input.md`,会按 `source_number` 使用 `activeOutput.source_names/source_paths` 替换展示。 + +示例: + +- `source_names[0] = 2025.findings-emnlp.342.pdf` +- `source_names[1] = 2601.22139v1.pdf` +- 答案中的 `[1]` 展示为第一个 PDF。 +- 答案中的 `[2]` 展示为第二个 PDF。 + +### 8.4 引用交互 + +答案中的 `[1]`、`[2]` 等编号会被解析成可点击 citation token。 + +交互规则: + +- 点击编号后,在卡片背面展开来源预览区。 +- 一张卡片有多个引用时,预览区顶部显示引用切换 tabs。 +- 每个引用预览区展示: + - 来源编号 + - 文件名 + - 片段预览 + - chunk 信息(如果存在) +- “打开完整来源”按钮只在 citation 具备可定位来源文件时展示。 +- 如果 citation 只有 preview、无法定位完整文件,则不展示“打开完整来源”,避免点开后看到重复内容。 +- 如果按钮存在但最终匹配不到左侧文件,前端提示:“没有找到可打开的完整来源文件,当前仅能查看卡片内来源片段。” + +完整来源匹配规则: + +- `filePath` 与左侧来源 URL 完全相等。 +- 左侧来源 URL 以后缀形式匹配 `filePath`。 +- `filePath` 以后缀形式匹配左侧来源 URL。 +- `decodeURIComponent` 后相等。 +- `fileName` 与左侧来源名称相等。 + +### 8.5 视觉与动效 + +闪卡保留正反面翻卡交互,并增强视觉表现。 + +整体卡面: + +- 3D perspective 翻转。 +- 渐变背景。 +- radial glow。 +- 流光 sheen。 +- scan 光带。 +- hover 轻微浮起与 3D 倾斜。 + +难度视觉: + +- 基础:浅蓝、清晰、轻压力。 +- 进阶:浅紫层次,答案框使用高对比深色文字。 +- 挑战:深色卡面,答案框、依据框、引用框使用深色玻璃态背景与浅色文字。 + +翻转稳定性: + +- 未激活 face 设置 `opacity: 0`。 +- 未激活 face 设置 `pointer-events: none`。 +- 使用 `backface-visibility` 与 `-webkit-backface-visibility`。 +- 使用 `z-index` 保证只有当前面可见可交互。 +- 修复过“卡片未翻转时,鼠标移到底部会出现镜像来源框”的问题。 + +可读性要求: + +- 答案文本与答案框背景必须有明确对比。 +- 深色难度不能出现白字白底。 +- 进阶卡片不能出现浅字浅底。 + +### 8.6 回流来源 + +“回流来源”用于将当前产出重新导入为知识来源,供后续继续复用。 + +规则: + +- 有现成产物文件时,优先导入现有文件。 +- 闪卡和测验这类结构化 JSON 结果如果没有可导入文件,后端生成 Markdown 后导入。 +- 闪卡回流 Markdown 包含: + - 标题 + - 生成条件 + - 卡片问题 + - 卡片答案 + - 难度 + - 来源文件 + - 来源摘录 + +### 8.7 主要文件职责 + +后端: + +- `fastapi_app/schemas.py`:定义闪卡、引用、生成配置 schema。 +- `fastapi_app/services/flashcard_service.py`:构造 prompt、解析 LLM JSON、生成结构化 citations。 +- `fastapi_app/routers/kb.py`:`/generate-flashcards` 接收生成配置和真实 citation 来源参数。 +- `fastapi_app/services/output_v2_service.py`:保存 `flashcard_config`,传递真实 `source_paths/source_names`,支持闪卡/测验回流来源。 + +中文前端: + +- `frontend_zh/src/components/ThinkFlowWorkspace.tsx`: + - 管理生成前配置草稿。 + - 提交 `flashcard_config`。 + - 解析闪卡结果。 + - 对 `generation_input.md` 做来源显示纠偏。 + - 打开完整来源或显示错误提示。 +- `frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx`: + - 展示翻卡学习 UI。 + - 渲染 citation token。 + - 展示 citation tabs 与来源片段。 + - 按条件展示“打开完整来源”按钮。 +- `frontend_zh/src/components/ThinkFlowWorkspace.css`: + - 卡片渐变、动效、翻面层级。 + - 三种难度视觉主题。 + - 答案区、来源区可读性。 + +英文前端: + +- `frontend_en/src/components/flashcards/FlashcardViewer.tsx` +- `frontend_en/src/pages/NotebookView.tsx` + +### 8.8 当前验证清单 + +需要持续验证: + +1. 生成前可编辑难度、卡片数量、主题、测试内容。 +2. 不填数量时仍按默认逻辑生成。 +3. 历史闪卡顶部能展示本组生成条件。 +4. `[1]`、`[2]` 可点击并展开来源预览。 +5. 多引用卡片可在 tabs 间切换。 +6. 选择两个 PDF 生成闪卡时,来源显示真实 PDF 文件名,不显示 `generation_input.md`。 +7. 旧结果里写死的 `generation_input.md` 能被前端纠偏展示。 +8. 只有 preview 无真实文件时,不显示“打开完整来源”按钮。 +9. 有真实文件时,“打开完整来源”能打开现有来源详情。 +10. “回流来源”能把闪卡结果导入为 Markdown 来源。 +11. 翻转前背面不会漏出镜像内容。 +12. 进阶/挑战卡片答案面文字清晰可读。 diff --git a/fastapi_app/config/settings.py b/fastapi_app/config/settings.py index 64270ab..98c4fcd 100644 --- a/fastapi_app/config/settings.py +++ b/fastapi_app/config/settings.py @@ -13,6 +13,8 @@ _CONFIG_DIR = Path(__file__).resolve().parent _APP_DIR = _CONFIG_DIR.parent _ENV_FILE = _APP_DIR / ".env" +_PROJECT_ROOT = _APP_DIR.parent +_ROOT_ENV_FILE = _PROJECT_ROOT / "env" class AppSettings(BaseSettings): @@ -126,7 +128,11 @@ class AppSettings(BaseSettings): MINERU_API_TOKEN: Optional[str] = None class Config: - env_file = str(_ENV_FILE) + env_file = tuple( + str(path) + for path in (_ENV_FILE, _ROOT_ENV_FILE) + if path.exists() + ) env_file_encoding = "utf-8" case_sensitive = True diff --git a/fastapi_app/routers/kb.py b/fastapi_app/routers/kb.py index a28b846..25c98ef 100644 --- a/fastapi_app/routers/kb.py +++ b/fastapi_app/routers/kb.py @@ -22,7 +22,7 @@ log = get_logger(__name__) from fastapi_app.config import settings -from fastapi_app.schemas import Paper2PPTRequest +from fastapi_app.schemas import FlashcardGenerationConfig, Paper2PPTRequest from fastapi_app.utils import _from_outputs_url, _to_outputs_url from fastapi_app.services.wa_paper2ppt import _init_state_from_request from fastapi_app.dependencies.auth import get_supabase_admin_client @@ -2753,10 +2753,21 @@ async def generate_flashcards( api_key: Optional[str] = Body(None, embed=True), model: str = Body("deepseek-v3.2", embed=True), language: str = Body("zh", embed=True), - card_count: int = Body(20, embed=True), + card_count: Optional[int] = Body(20, embed=True), + difficulty_level: Optional[str] = Body(None, embed=True), + topic: Optional[str] = Body(None, embed=True), + test_focus: Optional[str] = Body(None, embed=True), + citation_source_paths: Optional[List[str]] = Body(None, embed=True), + citation_source_names: Optional[List[str]] = Body(None, embed=True), ): """从知识库文件生成闪卡""" try: + language = str(_unwrap_fastapi_body_default(language, "zh") or "zh") + model = str(_unwrap_fastapi_body_default(model, "deepseek-v3.2") or "deepseek-v3.2") + card_count = _unwrap_fastapi_body_default(card_count, 20) + difficulty_level = _unwrap_fastapi_body_default(difficulty_level, None) + topic = _unwrap_fastapi_body_default(topic, None) + test_focus = _unwrap_fastapi_body_default(test_focus, None) api_url, api_key = _require_llm_config(api_url, api_key) from fastapi_app.services.flashcard_service import generate_flashcards_with_llm @@ -2775,19 +2786,104 @@ async def generate_flashcards( if not local_paths: raise HTTPException(status_code=400, detail="No valid files provided") + normalized_card_count = card_count if isinstance(card_count, int) and card_count > 0 else 20 text_content = _extract_text_from_files(local_paths, max_chars=50000) if not text_content.strip(): raise HTTPException(status_code=400, detail="No text content extracted") log.info("[generate-flashcards] text_len=%d, files=%d", len(text_content), len(local_paths)) + citation_entries: List[Dict[str, Any]] = [] + citation_source_paths = _unwrap_fastapi_body_default(citation_source_paths, None) or [] + citation_source_names = _unwrap_fastapi_body_default(citation_source_names, None) or [] + if isinstance(citation_source_paths, list): + for index, f in enumerate(citation_source_paths): + ps = str(f or "").strip() + if not ps: + continue + display_name = "" + if isinstance(citation_source_names, list) and index < len(citation_source_names): + display_name = str(citation_source_names[index] or "").strip() + display_name = display_name or Path(ps).name or f"来源 {index + 1}" + resolved_path: Optional[Path] = None + if ps.startswith("http://") or ps.startswith("https://"): + local_md = _resolve_link_to_local_md(email, notebook_id, ps) + if local_md and local_md.exists(): + resolved_path = local_md + else: + local_path = _resolve_local_path(ps) + if local_path.exists(): + resolved_path = local_path + citation_entries.append( + { + "source_path": str(resolved_path) if resolved_path else "", + "original_path": ps, + "file_name": display_name, + } + ) + if not citation_entries: + citation_entries = [ + { + "source_path": source_path, + "original_path": source_path, + "file_name": Path(source_path).name, + } + for source_path in local_paths + ] + + citation_sources: List[Dict[str, Any]] = [] + for index, entry in enumerate(citation_entries): + source_path = str(entry.get("source_path") or "").strip() + original_path = str(entry.get("original_path") or source_path).strip() + file_name = str(entry.get("file_name") or Path(source_path or original_path).name or f"来源 {index + 1}").strip() + excerpt = "" + if source_path: + try: + excerpt = _extract_text_from_files([source_path], max_chars=320) + except Exception: + excerpt = "" + citation_sources.append( + { + "source_number": index + 1, + "file_name": file_name, + "file_path": _to_outputs_url(source_path) if source_path else original_path, + "preview": excerpt[:240] if excerpt else "", + "chunk_index": None, + } + ) + log.info( + "[generate-flashcards] citation_sources=%s", + [ + { + "file_name": item.get("file_name"), + "file_path": item.get("file_path"), + "has_preview": bool(item.get("preview")), + } + for item in citation_sources + ], + ) + + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + generation_config = FlashcardGenerationConfig( + difficulty_level=difficulty_level if difficulty_level in {"basic", "intermediate", "advanced"} else None, + card_count=card_count if isinstance(card_count, int) and card_count > 0 else None, + topic=(topic or "").strip() or None, + test_focus=(test_focus or "").strip() or None, + language=language, + generated_at=generated_at, + ) + flashcards = await generate_flashcards_with_llm( text_content=text_content, api_url=api_url, api_key=api_key, model=model, language=language, - card_count=card_count, + card_count=normalized_card_count, + difficulty_level=generation_config.difficulty_level, + topic=generation_config.topic, + test_focus=generation_config.test_focus, + citation_sources=citation_sources, ) if not flashcards: raise HTTPException(status_code=500, detail="Failed to generate flashcards") @@ -2805,9 +2901,10 @@ async def generate_flashcards( "id": flashcard_set_id, "notebook_id": notebook_id, "flashcards": [fc.dict() for fc in flashcards], - "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "created_at": generated_at, "source_files": file_paths, "total_count": len(flashcards), + "generation_config": generation_config.dict(), } (output_dir / "flashcards.json").write_text( json.dumps(flashcard_data, ensure_ascii=False, indent=2), encoding="utf-8" @@ -2832,6 +2929,7 @@ async def generate_flashcards( "flashcard_set_id": flashcard_set_id, "total_count": len(flashcards), "result_path": _to_outputs_url(str(output_dir)), + "generation_config": generation_config.dict(), } except HTTPException: raise diff --git a/fastapi_app/routers/kb_outputs_v2.py b/fastapi_app/routers/kb_outputs_v2.py index da89bce..49d3c84 100644 --- a/fastapi_app/routers/kb_outputs_v2.py +++ b/fastapi_app/routers/kb_outputs_v2.py @@ -29,6 +29,7 @@ class OutlineRequest(BaseModel): api_key: Optional[str] = None model: Optional[str] = None enable_images: Optional[bool] = None + flashcard_config: Optional[Dict[str, Any]] = None class SaveOutlineRequest(BaseModel): @@ -127,6 +128,7 @@ async def create_outline(request: OutlineRequest) -> Dict[str, Any]: api_key=request.api_key, model=request.model, enable_images=request.enable_images, + flashcard_config=request.flashcard_config, ) return {"success": True, "output": item} diff --git a/fastapi_app/schemas.py b/fastapi_app/schemas.py index 72fb0cd..2d306d4 100644 --- a/fastapi_app/schemas.py +++ b/fastapi_app/schemas.py @@ -304,6 +304,25 @@ class Paper2PPTResponse(BaseModel): # ===================== Flashcard 闪卡相关 ===================== +class FlashcardCitation(BaseModel): + """闪卡引用来源""" + source_number: int + file_name: Optional[str] = None + file_path: Optional[str] = None + preview: Optional[str] = None + chunk_index: Optional[int] = None + + +class FlashcardGenerationConfig(BaseModel): + """闪卡整组生成配置""" + difficulty_level: Optional[Literal["basic", "intermediate", "advanced"]] = None + card_count: Optional[int] = None + topic: Optional[str] = None + test_focus: Optional[str] = None + language: Optional[str] = None + generated_at: Optional[str] = None + + class Flashcard(BaseModel): """单个闪卡""" id: str @@ -314,6 +333,7 @@ class Flashcard(BaseModel): source_file: Optional[str] = None source_excerpt: Optional[str] = None tags: List[str] = [] + citations: List[FlashcardCitation] = [] created_at: Optional[str] = None @@ -327,7 +347,10 @@ class GenerateFlashcardsRequest(BaseModel): api_key: str model: str = "deepseek-v3.2" language: str = "zh" - card_count: int = 20 + card_count: Optional[int] = 20 + difficulty_level: Optional[Literal["basic", "intermediate", "advanced"]] = None + topic: Optional[str] = None + test_focus: Optional[str] = None class GenerateFlashcardsResponse(BaseModel): @@ -337,6 +360,7 @@ class GenerateFlashcardsResponse(BaseModel): flashcard_set_id: str = "" total_count: int = 0 result_path: str = "" + generation_config: Optional[FlashcardGenerationConfig] = None # ===================== Quiz 相关模型 ===================== diff --git a/fastapi_app/services/flashcard_service.py b/fastapi_app/services/flashcard_service.py index 6cdd19d..11b1426 100644 --- a/fastapi_app/services/flashcard_service.py +++ b/fastapi_app/services/flashcard_service.py @@ -6,11 +6,10 @@ import re import time import httpx -from typing import List, Dict, Any -from pathlib import Path +from typing import List, Dict, Any, Optional from workflow_engine.logger import get_logger -from fastapi_app.schemas import Flashcard +from fastapi_app.schemas import Flashcard, FlashcardCitation log = get_logger(__name__) @@ -22,6 +21,10 @@ async def generate_flashcards_with_llm( model: str, language: str, card_count: int, + difficulty_level: Optional[str] = None, + topic: Optional[str] = None, + test_focus: Optional[str] = None, + citation_sources: Optional[List[Dict[str, Any]]] = None, ) -> List[Flashcard]: """ 使用 LLM 从文本内容生成闪卡 @@ -43,7 +46,15 @@ async def generate_flashcards_with_llm( text_content = text_content[:max_chars] + "..." # 构建 Prompt - prompt = _build_flashcard_prompt(text_content, language, card_count) + prompt = _build_flashcard_prompt( + text_content=text_content, + language=language, + card_count=card_count, + difficulty_level=difficulty_level, + topic=topic, + test_focus=test_focus, + citation_sources=citation_sources or [], + ) log.info(f"[flashcard_service] 开始调用 LLM 生成闪卡,模型: {model}, 数量: {card_count}") @@ -73,7 +84,11 @@ async def generate_flashcards_with_llm( # 解析 LLM 返回的内容 content = result["choices"][0]["message"]["content"] - flashcards = _parse_flashcards_from_llm_response(content, card_count) + flashcards = _parse_flashcards_from_llm_response( + content=content, + card_count=card_count, + citation_sources=citation_sources or [], + ) log.info(f"[flashcard_service] 成功生成 {len(flashcards)} 张闪卡") return flashcards @@ -83,9 +98,27 @@ async def generate_flashcards_with_llm( raise Exception(f"生成闪卡失败: {str(e)}") -def _build_flashcard_prompt(text_content: str, language: str, card_count: int) -> str: +def _build_flashcard_prompt( + *, + text_content: str, + language: str, + card_count: int, + difficulty_level: Optional[str], + topic: Optional[str], + test_focus: Optional[str], + citation_sources: List[Dict[str, Any]], +) -> str: """构建生成闪卡的 Prompt""" lang_name = "中文" if language == "zh" else "English" + difficulty_label = { + "basic": "基础", + "intermediate": "进阶", + "advanced": "挑战", + }.get(str(difficulty_level or "").strip(), "未指定") + source_lines = [ + f"[{index}] {source.get('file_name') or source.get('file_path') or f'来源 {index}'}" + for index, source in enumerate(citation_sources, start=1) + ] prompt = f"""你是一个专业的教育内容专家,擅长从学习材料中提取关键知识点并制作闪卡。 @@ -97,6 +130,17 @@ def _build_flashcard_prompt(text_content: str, language: str, card_count: int) - 3. 优先选择核心概念、定义、重要事实、关键术语 4. 问题和答案使用{lang_name} 5. 可以包含不同类型的问题(概念解释、填空、问答等) +6. 如果答案引用了来源,必须在答案中保留 [1]、[2] 这种编号,可多个并列如 [1][2] +7. citations 字段必须是结构化引用列表,source_number 要和答案中的编号一致 +8. 如果未给出可用来源,不要凭空编造 citations + +生成条件: +- 难度等级:{difficulty_label} +- 主题:{topic or "未指定"} +- 测试内容:{test_focus or "未指定"} + +可用来源列表: +{chr(10).join(source_lines) if source_lines else "未提供独立来源列表"} 内容: {text_content} @@ -106,14 +150,23 @@ def _build_flashcard_prompt(text_content: str, language: str, card_count: int) - - answer: 答案内容 - type: 类型(qa/concept/fill_blank) - source_excerpt: 相关原文摘录(可选,最多100字) +- citations: 引用数组(可选),每项包含: + - source_number: 来源编号整数 + - preview: 对应来源的简短预览(可选,最多120字) 示例格式: [ {{ "question": "什么是机器学习?", - "answer": "机器学习是人工智能的一个分支,通过算法让计算机从数据中学习规律。", + "answer": "机器学习是人工智能的一个分支,通过算法让计算机从数据中学习规律。[1]", "type": "qa", - "source_excerpt": "机器学习(Machine Learning)是..." + "source_excerpt": "机器学习(Machine Learning)是...", + "citations": [ + {{ + "source_number": 1, + "preview": "机器学习(Machine Learning)是..." + }} + ] }} ] @@ -162,7 +215,67 @@ def _try_parse_json_array(json_str: str): raise json.JSONDecodeError("No valid JSON array found", json_str, 0) -def _parse_flashcards_from_llm_response(content: str, card_count: int) -> List[Flashcard]: +def _build_flashcard_citations( + card_data: Dict[str, Any], + citation_sources: List[Dict[str, Any]], + fallback_preview: Optional[str], +) -> List[FlashcardCitation]: + raw_citations = card_data.get("citations") + citations: List[FlashcardCitation] = [] + if isinstance(raw_citations, list): + for item in raw_citations: + if not isinstance(item, dict): + continue + source_number = item.get("source_number") + try: + source_number_int = int(source_number) + except (TypeError, ValueError): + continue + source_meta = citation_sources[source_number_int - 1] if 0 < source_number_int <= len(citation_sources) else {} + citations.append( + FlashcardCitation( + source_number=source_number_int, + file_name=str(item.get("file_name") or source_meta.get("file_name") or "") or None, + file_path=str(item.get("file_path") or source_meta.get("file_path") or "") or None, + preview=str(item.get("preview") or fallback_preview or source_meta.get("preview") or "")[:240] or None, + chunk_index=item.get("chunk_index"), + ) + ) + + if citations: + deduped: Dict[int, FlashcardCitation] = {} + for citation in citations: + deduped[citation.source_number] = citation + return [deduped[number] for number in sorted(deduped)] + + answer = str(card_data.get("answer") or "") + source_numbers = [] + for match in re.findall(r"\[(\d+)\]", answer): + try: + source_numbers.append(int(match)) + except ValueError: + continue + deduped_numbers = sorted(set(number for number in source_numbers if number > 0)) + fallback_citations: List[FlashcardCitation] = [] + for source_number in deduped_numbers: + source_meta = citation_sources[source_number - 1] if source_number <= len(citation_sources) else {} + fallback_citations.append( + FlashcardCitation( + source_number=source_number, + file_name=str(source_meta.get("file_name") or "") or None, + file_path=str(source_meta.get("file_path") or "") or None, + preview=str(fallback_preview or source_meta.get("preview") or "")[:240] or None, + chunk_index=source_meta.get("chunk_index"), + ) + ) + return fallback_citations + + +def _parse_flashcards_from_llm_response( + content: str, + card_count: int, + citation_sources: List[Dict[str, Any]], +) -> List[Flashcard]: """ 解析 LLM 返回的闪卡数据 @@ -195,14 +308,28 @@ def _parse_flashcards_from_llm_response(content: str, card_count: int) -> List[F if not question or not answer: continue - flashcards.append(Flashcard( - id=f"card_{int(time.time())}_{i}", - question=question, - answer=answer, - type=card_data.get("type", "qa"), - source_excerpt=card_data.get("source_excerpt", "")[:200] if card_data.get("source_excerpt") else None, - created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - )) + source_excerpt = card_data.get("source_excerpt", "")[:200] if card_data.get("source_excerpt") else None + citations = _build_flashcard_citations( + card_data=card_data, + citation_sources=citation_sources, + fallback_preview=source_excerpt, + ) + primary_citation = citations[0] if citations else None + + flashcards.append( + Flashcard( + id=f"card_{int(time.time())}_{i}", + question=question, + answer=answer, + type=card_data.get("type", "qa"), + difficulty=card_data.get("difficulty"), + source_file=primary_citation.file_name if primary_citation and primary_citation.file_name else card_data.get("source_file"), + source_excerpt=source_excerpt, + tags=[str(tag) for tag in card_data.get("tags", [])] if isinstance(card_data.get("tags"), list) else [], + citations=citations, + created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + ) return flashcards diff --git a/fastapi_app/services/output_v2_service.py b/fastapi_app/services/output_v2_service.py index c1b1b39..7c32ddd 100644 --- a/fastapi_app/services/output_v2_service.py +++ b/fastapi_app/services/output_v2_service.py @@ -475,6 +475,7 @@ def _build_legacy_item( result[key] = data[key] result["total_count"] = data.get("total_count", 0) result["source_files"] = data.get("source_files", []) + result["generation_config"] = data.get("generation_config") result["download_url"] = _to_outputs_url(str(data_file)) # Preserve created_at from data if available created_at = data.get("created_at") or created_at @@ -556,6 +557,7 @@ def _build_legacy_item( "source_names": [], "bound_document_ids": [], "enable_images": False, + "flashcard_config": data.get("generation_config") if feature == "flashcard" else {}, "created_at": created_at, "updated_at": created_at, "result": result, @@ -1634,6 +1636,7 @@ async def create_outline( api_key: Optional[str] = None, model: Optional[str] = None, enable_images: Optional[bool] = None, + flashcard_config: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: if target_type not in self.SUPPORTED_TYPES: raise HTTPException(status_code=400, detail="Unsupported output type") @@ -1737,6 +1740,7 @@ async def create_outline( "bound_document_ids": bound_document_ids or [], "bound_document_titles": [doc.get("title") or "参考文档" for doc in bound_documents], "enable_images": normalized_enable_images, + "flashcard_config": flashcard_config or {}, "created_at": now, "updated_at": now, "result": result_payload, @@ -1948,9 +1952,12 @@ async def _generate_via_existing_endpoint( notebook_title: str, prompt: str, page_count: int, - api_url: Optional[str], - api_key: Optional[str], - model: Optional[str], + citation_source_paths: Optional[List[str]] = None, + citation_source_names: Optional[List[str]] = None, + flashcard_config: Optional[Dict[str, Any]] = None, + api_url: Optional[str] = None, + api_key: Optional[str] = None, + model: Optional[str] = None, ) -> Dict[str, Any]: from fastapi_app.routers.kb import ( generate_flashcards, @@ -1988,6 +1995,7 @@ async def _generate_via_existing_endpoint( language="zh", ) if target_type == "flashcard": + flashcard_config = flashcard_config or {} return await generate_flashcards( file_paths=[str(md_path)], email=email, @@ -1997,7 +2005,12 @@ async def _generate_via_existing_endpoint( api_url=api_url, api_key=api_key, model=payload_model, - card_count=page_count, + card_count=flashcard_config.get("card_count") if flashcard_config.get("card_count") is not None else page_count, + difficulty_level=flashcard_config.get("difficulty_level"), + topic=flashcard_config.get("topic"), + test_focus=flashcard_config.get("test_focus"), + citation_source_paths=citation_source_paths or [], + citation_source_names=citation_source_names or [], ) if target_type == "quiz": return await generate_quiz( @@ -2151,6 +2164,9 @@ async def generate_output( notebook_title=notebook_title, prompt=str(item.get("prompt") or ""), page_count=int(item.get("page_count") or 8), + citation_source_paths=item.get("source_paths") or [], + citation_source_names=item.get("source_names") or [], + flashcard_config=item.get("flashcard_config") or {}, api_url=resolved_api_url, api_key=resolved_api_key, model=model, @@ -2428,6 +2444,80 @@ async def import_output_to_source( if maybe_path.exists() and maybe_path.is_file(): local_file = maybe_path break + if local_file is None: + target_type = str(item.get("target_type") or "").strip() + output_dir = self._item_dir(notebook_id, notebook_title, user_id, output_id) + if target_type in {"flashcard", "quiz"}: + structured_file = output_dir / f"{target_type}.md" + lines: List[str] = [f"# {item.get('title') or target_type}", ""] + generation_config = result.get("generation_config") or item.get("flashcard_config") or {} + + if target_type == "flashcard": + if generation_config: + lines.append("## 生成条件") + lines.append("") + difficulty = str(generation_config.get("difficulty_level") or "").strip() + card_count = generation_config.get("card_count") + topic = str(generation_config.get("topic") or "").strip() + test_focus = str(generation_config.get("test_focus") or "").strip() + generated_at = str(generation_config.get("generated_at") or "").strip() + if difficulty: + lines.append(f"- 难度:{difficulty}") + if card_count: + lines.append(f"- 卡片数量:{card_count}") + if topic: + lines.append(f"- 主题:{topic}") + if test_focus: + lines.append(f"- 测试内容:{test_focus}") + if generated_at: + lines.append(f"- 生成时间:{generated_at}") + lines.extend(["", "## 卡片内容", ""]) + flashcards = result.get("flashcards") or [] + for index, card in enumerate(flashcards, start=1): + question = str(card.get("question") or "").strip() + answer = str(card.get("answer") or "").strip() + difficulty = str(card.get("difficulty") or "").strip() + source_excerpt = str(card.get("source_excerpt") or "").strip() + source_file = str(card.get("source_file") or "").strip() + lines.append(f"### 卡片 {index}") + if question: + lines.append(f"- 问题:{question}") + if answer: + lines.append(f"- 答案:{answer}") + if difficulty: + lines.append(f"- 难度:{difficulty}") + if source_file: + lines.append(f"- 来源文件:{source_file}") + if source_excerpt: + lines.append(f"- 来源摘录:{source_excerpt}") + lines.append("") + else: + questions = result.get("questions") or result.get("quiz") or [] + lines.append("## 题目内容") + lines.append("") + for index, question in enumerate(questions, start=1): + stem = str(question.get("question") or "").strip() + answer = str(question.get("correct_answer") or "").strip() + explanation = str(question.get("explanation") or "").strip() + lines.append(f"### 题目 {index}") + if stem: + lines.append(stem) + options = question.get("options") or [] + if isinstance(options, list): + for option in options: + label = str(option.get("label") or "").strip() + text = str(option.get("text") or "").strip() + if label or text: + lines.append(f"- {label}: {text}".rstrip(": ")) + if answer: + lines.append(f"- 正确答案:{answer}") + if explanation: + lines.append(f"- 解析:{explanation}") + lines.append("") + + structured_file.write_text("\n".join(lines).strip() + "\n", encoding="utf-8") + local_file = structured_file + if local_file is None: raise HTTPException(status_code=400, detail="No generated file can be imported as source") paths = get_notebook_paths(notebook_id, notebook_title, user_id) diff --git a/frontend_en/src/components/flashcards/FlashcardViewer.tsx b/frontend_en/src/components/flashcards/FlashcardViewer.tsx index 1640520..f5a59fd 100644 --- a/frontend_en/src/components/flashcards/FlashcardViewer.tsx +++ b/frontend_en/src/components/flashcards/FlashcardViewer.tsx @@ -1,31 +1,124 @@ -import React, { useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { RotateCw, ChevronLeft, ChevronRight } from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import { RotateCw, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'; + +interface CitationReference { + fileName?: string; + filePath?: string; + preview?: string; + chunkIndex?: number | null; + sourceNumber?: string; +} + +interface FlashcardCitation { + source_number?: number; + file_name?: string | null; + file_path?: string | null; + preview?: string | null; + chunk_index?: number | null; +} interface Flashcard { id: string; question: string; answer: string; type: string; + difficulty?: string | null; source_excerpt?: string; + source_file?: string | null; + citations?: FlashcardCitation[]; } interface FlashcardViewerProps { flashcards: Flashcard[]; + generationConfig?: { + difficulty_level?: 'basic' | 'intermediate' | 'advanced' | null; + card_count?: number | null; + topic?: string | null; + test_focus?: string | null; + generated_at?: string | null; + } | null; + onOpenCitation?: (reference: CitationReference) => void; onClose: () => void; } -const springFlip = { type: 'spring', stiffness: 300, damping: 25 }; +const springFlip = { type: 'spring', stiffness: 280, damping: 24 }; + +const difficultyLabelMap: Record = { + basic: 'Basic', + intermediate: 'Intermediate', + advanced: 'Advanced', +}; + +function normalizeCitations(card: Flashcard) { + const raw = Array.isArray(card.citations) ? card.citations : []; + if (raw.length > 0) { + return raw + .map((item) => ({ + sourceNumber: + item?.source_number !== undefined && item?.source_number !== null ? String(item.source_number) : '', + fileName: item?.file_name || undefined, + filePath: item?.file_path || undefined, + preview: item?.preview || card.source_excerpt || undefined, + chunkIndex: item?.chunk_index ?? null, + })) + .filter((item) => item.sourceNumber); + } + const numbers = Array.from(new Set([...String(card.answer || '').matchAll(/\[(\d+)\]/g)].map((match) => match[1]))); + return numbers.map((sourceNumber) => ({ + sourceNumber, + fileName: card.source_file || undefined, + preview: card.source_excerpt || undefined, + chunkIndex: null, + })); +} + +function renderAnswer( + answer: string, + citations: ReturnType, + activeCitation: string | null, + onSelect: (value: string) => void, +) { + const citationSet = new Set(citations.map((item) => item.sourceNumber)); + return answer.split(/(\[\d+\])/g).map((part, index) => { + const match = part.match(/^\[(\d+)\]$/); + if (!match || !citationSet.has(match[1])) return {part}; + return ( + + ); + }); +} export const FlashcardViewer: React.FC = ({ flashcards, + generationConfig, + onOpenCitation, onClose, }) => { const [currentIndex, setCurrentIndex] = useState(0); const [isFlipped, setIsFlipped] = useState(false); + const [activeCitation, setActiveCitation] = useState(null); const currentCard = flashcards[currentIndex]; const progress = ((currentIndex + 1) / flashcards.length) * 100; + const citations = useMemo(() => normalizeCitations(currentCard), [currentCard]); + const selectedCitation = citations.find((item) => item.sourceNumber === activeCitation) || citations[0] || null; + + useEffect(() => { + setActiveCitation(citations[0]?.sourceNumber || null); + }, [currentIndex, citations]); const handleNext = () => { if (currentIndex < flashcards.length - 1) { @@ -41,13 +134,8 @@ export const FlashcardViewer: React.FC = ({ } }; - const handleFlip = () => { - setIsFlipped(!isFlipped); - }; - return ( -
- {/* Header */} +

Flashcard Study

= ({
- {/* iOS Progress Bar */} + {generationConfig ? ( +
+
+ Generation Settings + {generationConfig.generated_at ? {generationConfig.generated_at} : null} +
+
+ Difficulty: {difficultyLabelMap[generationConfig.difficulty_level || ''] || 'Default'} + {generationConfig.card_count ? Card count: {generationConfig.card_count} : null} + {generationConfig.topic ? Topic: {generationConfig.topic} : null} + {generationConfig.test_focus ? Focus: {generationConfig.test_focus} : null} +
+
+ ) : null} +

{currentIndex + 1} / {flashcards.length} @@ -74,12 +176,11 @@ export const FlashcardViewer: React.FC = ({

- {/* Card Area */}
setIsFlipped(!isFlipped)} + style={{ perspective: '1400px' }} > = ({ transition={springFlip} style={{ transformStyle: 'preserve-3d' }} > - {/* Front - Question */}
-

{currentCard.question}

-
+
+ + {currentCard.type === 'fill_blank' ? 'Fill Blank' : currentCard.type === 'concept' ? 'Concept' : 'Q&A'} + + + {difficultyLabelMap[String(currentCard.difficulty || generationConfig?.difficulty_level || '').toLowerCase()] || 'Flexible'} + +
+
#{String(currentIndex + 1).padStart(2, '0')}
+

{currentCard.question}

+
Click to flip and see answer
- {/* Back - Answer */}
-

{currentCard.answer}

- {currentCard.source_excerpt && ( +
+ Answer + {currentCard.source_file ? ( + + {currentCard.source_file} + + ) : null} +
+
+

+ {renderAnswer(String(currentCard.answer || ''), citations, activeCitation, setActiveCitation)} +

+
+ {selectedCitation ? ( +
+
+ {citations.map((citation) => ( + + ))} +
+
+

{selectedCitation.fileName || `Source [${selectedCitation.sourceNumber}]`}

+

{selectedCitation.preview || currentCard.source_excerpt || 'No preview available.'}

+ {onOpenCitation ? ( + + ) : null} +
+
+ ) : currentCard.source_excerpt ? (

Source Excerpt:

{currentCard.source_excerpt}

- )} + ) : null}
- {/* Navigation Buttons */}
void const [flashcards, setFlashcards] = useState([]); const [showFlashcardViewer, setShowFlashcardViewer] = useState(false); const [flashcardSetId, setFlashcardSetId] = useState(''); + const [flashcardGenerationConfig, setFlashcardGenerationConfig] = useState(null); // Quiz state const [quizQuestions, setQuizQuestions] = useState([]); @@ -274,7 +275,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void ppt: { llmModel: 'deepseek-v3.2', genFigModel: 'gemini-2.5-flash-image', stylePreset: 'modern', stylePrompt: '', language: 'zh', page_count: '10' }, mindmap: { llmModel: 'deepseek-v3.2', mindmapStyle: 'default' }, drawio: { llmModel: 'deepseek-v3.2', diagramType: 'auto', diagramStyle: 'default', language: 'zh' }, - flashcard: { llmModel: 'deepseek-v3.2', language: 'zh', cardCount: '20' }, + flashcard: { llmModel: 'deepseek-v3.2', language: 'zh', cardCount: '', difficultyLevel: '', topic: '', testFocus: '' }, quiz: { llmModel: 'deepseek-v3.2', language: 'zh', questionCount: '10' }, podcast: { llmModel: 'deepseek-v3.2', ttsType: 'gemini-tts-online', ttsModel: 'gemini-2.5-pro-preview-tts', voiceName: 'Puck', voiceNameB: 'Charon', podcastMode: 'monologue', podcastLanguage: 'zh' }, video: { llmModel: 'deepseek-v3.2' }, @@ -299,7 +300,15 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void setStudioConfigByTool((prev) => { const next = { ...prev, [tool]: { ...(prev[tool] || defaultByTool[tool]), ...patch } }; try { - localStorage.setItem(STORAGE_STUDIO_CONFIG, JSON.stringify(next)); + const persistable = { ...next }; + persistable.flashcard = { + ...persistable.flashcard, + difficultyLevel: '', + topic: '', + testFocus: '', + cardCount: '', + }; + localStorage.setItem(STORAGE_STUDIO_CONFIG, JSON.stringify(persistable)); } catch (_) {} return next; }); @@ -404,6 +413,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void if (item.type === 'flashcard') { setFlashcards(data.flashcards || []); setFlashcardSetId(data.id || ''); + setFlashcardGenerationConfig(data.generation_config || null); setShowFlashcardViewer(true); } else { setQuizQuestions(data.questions || []); @@ -1834,12 +1844,16 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void }; } else if (tool === 'flashcard') { const cfg = getStudioConfig('flashcard'); + const parsedCardCount = parseInt(String(cfg.cardCount || ''), 10); bodyData = { ...baseBody, file_paths: selectedFileUrls, model: cfg.llmModel || 'deepseek-v3.2', language: cfg.language || 'zh', - card_count: Math.max(5, Math.min(50, parseInt(String(cfg.cardCount || '20'), 10) || 20)), + card_count: Number.isNaN(parsedCardCount) ? null : Math.max(1, Math.min(50, parsedCardCount)), + difficulty_level: cfg.difficultyLevel || null, + topic: (cfg.topic || '').trim() || null, + test_focus: (cfg.testFocus || '').trim() || null, }; } else if (tool === 'quiz') { const cfg = getStudioConfig('quiz'); @@ -1941,6 +1955,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void } else if (tool === 'flashcard') { setFlashcards(data.flashcards || []); setFlashcardSetId(data.flashcard_set_id || ''); + setFlashcardGenerationConfig(data.generation_config || null); if (data.flashcards?.length) setShowFlashcardViewer(true); const fcSetId = (data.flashcard_set_id || '').replace('flashcard_', ''); setOutputFeed(prev => [ @@ -3111,23 +3126,52 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void { const v = e.target.value.replace(/\D/g, ''); if (v === '') { setStudioConfigForTool('flashcard', { cardCount: '' }); return; } const n = parseInt(v, 10); - if (!Number.isNaN(n)) setStudioConfigForTool('flashcard', { cardCount: String(Math.max(5, Math.min(50, n))) }); + if (!Number.isNaN(n)) setStudioConfigForTool('flashcard', { cardCount: String(Math.max(1, Math.min(50, n))) }); }} - onBlur={(e) => { - const n = parseInt(e.target.value || '20', 10); - if (Number.isNaN(n) || n < 5 || n > 50) setStudioConfigForTool('flashcard', { cardCount: '20' }); - }} - placeholder="5–50" + placeholder="Leave empty for default" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" + /> +

Leave empty to keep the current default behavior.

+
+
+ + +
+
+ + setStudioConfigForTool('flashcard', { topic: e.target.value })} + placeholder="e.g. Transformer architecture, experiment results, core terms" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setStudioConfigForTool('flashcard', { testFocus: e.target.value })} + placeholder="e.g. concept recall, experimental conclusions, formula memory" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" /> -

5–50 张卡片

@@ -3949,6 +3993,13 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void > { + const targetFile = findFileForCitation(reference as CitationReference); + if (targetFile) { + void openSourceDetail(targetFile, reference as CitationReference); + } + }} onClose={() => setShowFlashcardViewer(false)} /> diff --git a/frontend_zh/src/components/ThinkFlowAddSourceModal.tsx b/frontend_zh/src/components/ThinkFlowAddSourceModal.tsx index 6d839d8..e13f6b1 100644 --- a/frontend_zh/src/components/ThinkFlowAddSourceModal.tsx +++ b/frontend_zh/src/components/ThinkFlowAddSourceModal.tsx @@ -85,12 +85,12 @@ export function ThinkFlowAddSourceModal({ // ── File Upload ── const uploadFiles = useCallback( - async (fileList: FileList) => { - if (fileList.length === 0) return; + async (filesToUpload: File[]) => { + if (filesToUpload.length === 0) return; setLoading(true); resetMessages(); try { - for (const file of Array.from(fileList)) { + for (const file of filesToUpload) { const formData = new FormData(); formData.append('file', file); formData.append('email', effectiveEmail); @@ -100,7 +100,7 @@ export function ThinkFlowAddSourceModal({ const res = await apiFetch('/api/v1/kb/upload', { method: 'POST', body: formData }); await parseJson(res); } - setSuccess(`已上传 ${fileList.length} 个文件`); + setSuccess(`已上传 ${filesToUpload.length} 个文件`); onSourceAdded(); } catch (err: any) { setError(err?.message || '上传失败'); @@ -113,7 +113,7 @@ export function ThinkFlowAddSourceModal({ const handleFileChange = useCallback( (e: React.ChangeEvent) => { - if (e.target.files) void uploadFiles(e.target.files); + if (e.target.files) void uploadFiles(Array.from(e.target.files)); e.target.value = ''; }, [uploadFiles], @@ -123,7 +123,7 @@ export function ThinkFlowAddSourceModal({ (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); - if (e.dataTransfer.files.length > 0) void uploadFiles(e.dataTransfer.files); + if (e.dataTransfer.files.length > 0) void uploadFiles(Array.from(e.dataTransfer.files)); }, [uploadFiles], ); diff --git a/frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx b/frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx index da67800..eb9cf6c 100644 --- a/frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx +++ b/frontend_zh/src/components/ThinkFlowFlashcardStudy.tsx @@ -1,32 +1,130 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { ChevronLeft, ChevronRight, RotateCw } from 'lucide-react'; - -type FlashcardItem = { - id?: string; - question?: string; - answer?: string; - type?: string; - difficulty?: string | null; - source_file?: string | null; - source_excerpt?: string | null; - tags?: string[]; -}; +import { ChevronLeft, ChevronRight, ExternalLink, RotateCw } from 'lucide-react'; +import type { CitationReference, FlashcardGenerationConfig, FlashcardItem } from './thinkflow-types'; type Props = { cards: FlashcardItem[]; + generationConfig?: FlashcardGenerationConfig | null; + onOpenCitation?: (reference: CitationReference) => void; +}; + +type CitationMeta = { + sourceNumber: string; + fileName?: string; + filePath?: string; + preview?: string; + chunkIndex?: number | null; +}; + +const difficultyLabelMap: Record = { + basic: '基础', + intermediate: '进阶', + advanced: '挑战', +}; + +const difficultyToneMap: Record = { + basic: 'is-basic', + intermediate: 'is-intermediate', + advanced: 'is-advanced', }; -export function ThinkFlowFlashcardStudy({ cards }: Props) { +function getCardKindLabel(type?: string) { + if (type === 'fill_blank') return '填空卡'; + if (type === 'concept') return '概念卡'; + return '问答卡'; +} + +function getDifficultyLabel(value?: string | null) { + const normalized = String(value || '').trim().toLowerCase(); + return difficultyLabelMap[normalized] || value || '自由难度'; +} + +function normalizeCardCitations(card?: FlashcardItem | null): CitationMeta[] { + if (!card) return []; + const raw = Array.isArray(card.citations) ? card.citations : []; + const fromStructured = raw + .map((item) => { + const sourceNumber = item?.source_number; + if (sourceNumber === undefined || sourceNumber === null) return null; + return { + sourceNumber: String(sourceNumber), + fileName: item?.file_name || undefined, + filePath: item?.file_path || undefined, + preview: item?.preview || card.source_excerpt || undefined, + chunkIndex: item?.chunk_index ?? null, + } satisfies CitationMeta; + }) + .filter(Boolean) as CitationMeta[]; + if (fromStructured.length > 0) return fromStructured; + + const answer = String(card.answer || ''); + const numbers = Array.from(new Set([...answer.matchAll(/\[(\d+)\]/g)].map((match) => match[1]))); + return numbers.map((sourceNumber) => ({ + sourceNumber, + fileName: card.source_file || undefined, + preview: card.source_excerpt || undefined, + chunkIndex: null, + })); +} + +function renderAnswerWithCitations( + answer: string, + citations: CitationMeta[], + activeCitation: string | null, + onSelectCitation: (value: string) => void, +) { + const citationMap = new Map(citations.map((item) => [item.sourceNumber, item])); + const parts = answer.split(/(\[\d+\])/g); + return parts.map((part, index) => { + const match = part.match(/^\[(\d+)\]$/); + if (!match) return {part}; + const sourceNumber = match[1]; + const hasCitation = citationMap.has(sourceNumber); + if (!hasCitation) return {part}; + return ( + + ); + }); +} + +export function ThinkFlowFlashcardStudy({ cards, generationConfig, onOpenCitation }: Props) { const [currentIndex, setCurrentIndex] = useState(0); const [flipped, setFlipped] = useState(false); + const [activeCitation, setActiveCitation] = useState(null); useEffect(() => { setCurrentIndex(0); setFlipped(false); + setActiveCitation(null); }, [cards]); const currentCard = useMemo(() => cards[currentIndex] || null, [cards, currentIndex]); const progress = cards.length > 0 ? ((currentIndex + 1) / cards.length) * 100 : 0; + const citations = useMemo(() => normalizeCardCitations(currentCard), [currentCard]); + const selectedCitation = citations.find((item) => item.sourceNumber === activeCitation) || citations[0] || null; + const canOpenFullCitation = Boolean( + selectedCitation && onOpenCitation && (selectedCitation.filePath || selectedCitation.fileName), + ); + const difficultyKey = String( + currentCard?.difficulty || generationConfig?.difficulty_level || '', + ) + .trim() + .toLowerCase(); + const difficultyTone = difficultyToneMap[difficultyKey] || ''; + + useEffect(() => { + setActiveCitation(citations[0]?.sourceNumber || null); + }, [currentIndex, citations]); if (!currentCard) return null; @@ -43,7 +141,7 @@ export function ThinkFlowFlashcardStudy({ cards }: Props) { }; return ( -
+
学习卡片 @@ -57,22 +155,37 @@ export function ThinkFlowFlashcardStudy({ cards }: Props) {
+ {generationConfig ? ( +
+
+ 本组生成条件 + {generationConfig.generated_at ? {generationConfig.generated_at} : null} +
+
+ 难度:{getDifficultyLabel(generationConfig.difficulty_level)} + {generationConfig.card_count ? 数量:{generationConfig.card_count} : null} + {generationConfig.topic ? 主题:{generationConfig.topic} : null} + {generationConfig.test_focus ? 测试内容:{generationConfig.test_focus} : null} +
+
+ ) : null} +
+ ))} +
+
+ {selectedCitation.fileName || `来源 [${selectedCitation.sourceNumber}]`} +

{selectedCitation.preview || currentCard.source_excerpt || '暂无来源预览'}

+ {selectedCitation.chunkIndex !== null && selectedCitation.chunkIndex !== undefined ? ( + Chunk #{selectedCitation.chunkIndex + 1} + ) : null} + {canOpenFullCitation && selectedCitation ? ( + + ) : null} +
+
+ ) : currentCard.source_excerpt ? (
依据

{currentCard.source_excerpt}

diff --git a/frontend_zh/src/components/ThinkFlowWorkspace.css b/frontend_zh/src/components/ThinkFlowWorkspace.css index eb9b64c..c276aee 100644 --- a/frontend_zh/src/components/ThinkFlowWorkspace.css +++ b/frontend_zh/src/components/ThinkFlowWorkspace.css @@ -4381,12 +4381,14 @@ .thinkflow-flashcard-stage { position: relative; width: 100%; - min-height: 280px; - perspective: 1000px; + min-height: 420px; + perspective: 1400px; border: 0; background: transparent; cursor: pointer; text-align: left; + transform-style: preserve-3d; + transition: transform 240ms ease, filter 240ms ease; } .thinkflow-flashcard-face { @@ -4394,43 +4396,104 @@ inset: 0; display: flex; flex-direction: column; - gap: 14px; - padding: 20px; - border-radius: 18px; - border: 1px solid rgba(223, 230, 240, 0.98); - background: var(--tf-bg-secondary); - box-shadow: 0 12px 30px rgba(25, 34, 52, 0.06); + gap: 16px; + padding: 24px; + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.38); + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 34%), + linear-gradient(145deg, rgba(248, 250, 252, 0.96), rgba(233, 239, 246, 0.94)); + box-shadow: + 0 24px 60px rgba(15, 23, 42, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.65); backface-visibility: hidden; - transition: transform 500ms ease; + -webkit-backface-visibility: hidden; + overflow: hidden; + opacity: 0; + pointer-events: none; + transform-style: preserve-3d; + transition: + transform 560ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 120ms linear; + isolation: isolate; +} + +.thinkflow-flashcard-stage:hover { + transform: translateY(-4px) rotateX(1.5deg) rotateY(-1.5deg); + filter: saturate(1.06); +} + +.thinkflow-flashcard-face::before { + content: ''; + position: absolute; + inset: -24%; + background: + conic-gradient(from 180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0)); + opacity: 0.7; + transform: translate3d(-18%, -12%, 0) rotate(8deg); + animation: thinkflow-flashcard-sheen 7.2s linear infinite; + pointer-events: none; + z-index: 0; +} + +.thinkflow-flashcard-face::after { + content: ''; + position: absolute; + inset: auto -14% 14% 48%; + height: 140px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(96, 165, 250, 0.22), rgba(255, 255, 255, 0)); + filter: blur(12px); + opacity: 0.78; + animation: thinkflow-flashcard-scan 4.8s ease-in-out infinite; + pointer-events: none; + z-index: 0; } .thinkflow-flashcard-face.is-front { transform: rotateY(0deg); + opacity: 1; + pointer-events: auto; + z-index: 2; } .thinkflow-flashcard-face.is-back { transform: rotateY(180deg); + opacity: 0; + pointer-events: none; + z-index: 1; } .thinkflow-flashcard-stage.is-flipped .thinkflow-flashcard-face.is-front { transform: rotateY(-180deg); + opacity: 0; + pointer-events: none; + z-index: 1; } .thinkflow-flashcard-stage.is-flipped .thinkflow-flashcard-face.is-back { transform: rotateY(0deg); + opacity: 1; + pointer-events: auto; + z-index: 2; } .thinkflow-flashcard-face h3 { margin: 0; - font-size: 18px; - line-height: 1.5; + font-size: 22px; + line-height: 1.55; color: var(--tf-text); + position: relative; + z-index: 1; } .thinkflow-flashcard-face-top { display: flex; align-items: center; + justify-content: space-between; + flex-wrap: wrap; gap: 8px; + position: relative; + z-index: 1; } .thinkflow-flashcard-hint { @@ -4439,12 +4502,338 @@ gap: 6px; margin-top: auto; font-size: 12px; + color: rgba(15, 23, 42, 0.62); + position: relative; + z-index: 1; +} + +.thinkflow-study-shell-flashcard { + gap: 18px; +} + +.thinkflow-flashcard-face-glow { + position: absolute; + inset: auto -10% 58% 40%; + height: 180px; + background: radial-gradient(circle, rgba(79, 70, 229, 0.24), rgba(79, 70, 229, 0)); + filter: blur(14px); + pointer-events: none; + animation: thinkflow-flashcard-glow 5.2s ease-in-out infinite; +} + +.thinkflow-flashcard-stage.is-basic .thinkflow-flashcard-face { + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0) 34%), + linear-gradient(145deg, rgba(244, 250, 255, 0.98), rgba(225, 243, 255, 0.96)); +} + +.thinkflow-flashcard-stage.is-intermediate .thinkflow-flashcard-face { + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0) 34%), + linear-gradient(145deg, rgba(246, 243, 255, 0.98), rgba(229, 220, 255, 0.94)); +} + +.thinkflow-flashcard-stage.is-intermediate .thinkflow-flashcard-face h3, +.thinkflow-flashcard-stage.is-intermediate .thinkflow-study-card-answer p, +.thinkflow-flashcard-stage.is-intermediate .thinkflow-study-card-quote p { + color: #1e1b4b; +} + +.thinkflow-flashcard-stage.is-intermediate .thinkflow-study-card-answer, +.thinkflow-flashcard-stage.is-intermediate .thinkflow-study-card-quote, +.thinkflow-flashcard-stage.is-intermediate .thinkflow-flashcard-citation-card { + background: rgba(255, 255, 255, 0.82); + border-color: rgba(109, 40, 217, 0.16); +} + +.thinkflow-flashcard-stage.is-intermediate .thinkflow-study-card-answer span, +.thinkflow-flashcard-stage.is-intermediate .thinkflow-study-card-quote strong, +.thinkflow-flashcard-stage.is-intermediate .thinkflow-flashcard-citation-card strong { + color: #4338ca; +} + +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-face { + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 30%), + linear-gradient(145deg, rgba(26, 32, 44, 0.98), rgba(51, 65, 85, 0.96)); + color: #f8fafc; +} + +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-face h3, +.thinkflow-flashcard-stage.is-advanced .thinkflow-study-card-answer p, +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-config-grid, +.thinkflow-flashcard-stage.is-advanced .thinkflow-study-card-quote p { + color: #f8fafc; +} + +.thinkflow-flashcard-stage.is-advanced .thinkflow-study-card-answer, +.thinkflow-flashcard-stage.is-advanced .thinkflow-study-card-quote, +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-citation-card { + background: rgba(15, 23, 42, 0.62); + border-color: rgba(148, 163, 184, 0.24); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 14px 30px rgba(2, 6, 23, 0.22); +} + +.thinkflow-flashcard-stage.is-advanced .thinkflow-study-card-answer span, +.thinkflow-flashcard-stage.is-advanced .thinkflow-study-card-quote strong, +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-citation-card strong { + color: #bae6fd; +} + +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-citation-card p, +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-citation-card span { + color: rgba(248, 250, 252, 0.86); +} + +.thinkflow-flashcard-stage.is-advanced .thinkflow-study-card-chip { + background: rgba(226, 232, 240, 0.14); + color: #e0f2fe; +} + +.thinkflow-flashcard-stage.is-advanced .thinkflow-flashcard-face::after { + background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(34, 211, 238, 0.28), rgba(255, 255, 255, 0)); +} + +.thinkflow-flashcard-front-index { + font-size: 54px; + font-weight: 700; + letter-spacing: -0.04em; + color: rgba(15, 23, 42, 0.08); + position: absolute; + top: 52px; + right: 24px; + z-index: 0; +} + +.thinkflow-study-inline-citation { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 2px; + padding: 1px 8px; + border: 0; + border-radius: 999px; + background: rgba(59, 130, 246, 0.14); + color: #1d4ed8; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.thinkflow-study-inline-citation.is-active { + background: rgba(59, 130, 246, 0.24); + box-shadow: inset 0 0 0 1px rgba(29, 78, 216, 0.22); +} + +.thinkflow-flashcard-citation-panel { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; + position: relative; + z-index: 1; +} + +.thinkflow-flashcard-citation-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.thinkflow-flashcard-citation-tab { + border: 0; + border-radius: 999px; + padding: 6px 12px; + background: rgba(148, 163, 184, 0.16); + color: var(--tf-text); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.thinkflow-flashcard-citation-tab.is-active { + background: rgba(59, 130, 246, 0.16); + color: #1d4ed8; +} + +.thinkflow-flashcard-citation-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.62); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14); + backdrop-filter: blur(8px); +} + +.thinkflow-flashcard-citation-card strong { + font-size: 13px; + color: var(--tf-text); +} + +.thinkflow-flashcard-citation-card p { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--tf-text-soft); +} + +.thinkflow-flashcard-citation-card span { + font-size: 12px; + color: var(--tf-muted); +} + +.thinkflow-flashcard-open-source-btn { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + border: 0; + border-radius: 999px; + padding: 8px 12px; + background: rgba(15, 23, 42, 0.86); + color: #fff; + cursor: pointer; +} + +.thinkflow-flashcard-config-summary { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(99, 102, 241, 0.12)); + border: 1px solid rgba(129, 140, 248, 0.12); +} + +.thinkflow-flashcard-config-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.thinkflow-flashcard-config-head strong { + font-size: 13px; + color: var(--tf-text); +} + +.thinkflow-flashcard-config-head span { + font-size: 12px; + color: var(--tf-muted); +} + +.thinkflow-flashcard-config-grid { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + font-size: 12px; + color: var(--tf-text-soft); +} + +.thinkflow-flashcard-config-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.thinkflow-flashcard-config-row, +.thinkflow-flashcard-config-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.thinkflow-flashcard-config-row > span, +.thinkflow-flashcard-config-field label { + font-size: 12px; + font-weight: 600; color: var(--tf-muted); } +.thinkflow-flashcard-difficulty-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.thinkflow-flashcard-difficulty-btn { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 999px; + padding: 8px 14px; + background: rgba(255, 255, 255, 0.92); + color: var(--tf-text); + cursor: pointer; +} + +.thinkflow-flashcard-difficulty-btn.is-active { + border-color: rgba(59, 130, 246, 0.38); + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; +} + +.thinkflow-flashcard-config-field input { + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 14px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.92); + color: var(--tf-text); +} + +@keyframes thinkflow-flashcard-glow { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(0.96); + opacity: 0.55; + } + 50% { + transform: translate3d(-10px, 10px, 0) scale(1.14); + opacity: 0.92; + } +} + +@keyframes thinkflow-flashcard-sheen { + 0% { + transform: translate3d(-24%, -18%, 0) rotate(8deg); + } + 100% { + transform: translate3d(18%, 14%, 0) rotate(8deg); + } +} + +@keyframes thinkflow-flashcard-scan { + 0%, + 100% { + transform: translate3d(-18px, 0, 0); + opacity: 0.2; + } + 50% { + transform: translate3d(16px, -8px, 0); + opacity: 0.82; + } +} + +@media (max-width: 768px) { + .thinkflow-flashcard-stage { + min-height: 500px; + transform: none; + } + + .thinkflow-flashcard-face { + padding: 18px; + } + + .thinkflow-flashcard-front-index { + font-size: 42px; + } +} + /* ═══════════════════════════════════════════ Source Re-embed Button ═══════════════════════════════════════════ */ /* (source-reembed-btn removed — now uses thinkflow-file-action-icon + Upload icon) */ - diff --git a/frontend_zh/src/components/ThinkFlowWorkspace.tsx b/frontend_zh/src/components/ThinkFlowWorkspace.tsx index 14cf05d..a2afb61 100644 --- a/frontend_zh/src/components/ThinkFlowWorkspace.tsx +++ b/frontend_zh/src/components/ThinkFlowWorkspace.tsx @@ -69,6 +69,7 @@ type CitationReference = { filePath?: string; preview?: string; chunkIndex?: number | null; + sourceNumber?: string; }; type ThinkFlowDocument = { @@ -175,6 +176,14 @@ type ThinkFlowOutput = { enable_images?: boolean; page_reviews?: PptPageReview[]; page_versions?: PptPageVersion[]; + flashcard_config?: { + difficulty_level?: 'basic' | 'intermediate' | 'advanced' | null; + card_count?: number | null; + topic?: string | null; + test_focus?: string | null; + language?: string | null; + generated_at?: string | null; + }; created_at: string; updated_at: string; }; @@ -188,6 +197,13 @@ type FlashcardItem = { source_file?: string | null; source_excerpt?: string | null; tags?: string[]; + citations?: Array<{ + source_number?: number; + file_name?: string | null; + file_path?: string | null; + preview?: string | null; + chunk_index?: number | null; + }>; created_at?: string | null; }; @@ -274,6 +290,12 @@ type DirectOutputIntent = { sourceIds: string[]; sourcePaths: string[]; sourceNames: string[]; + flashcardConfig?: { + difficulty_level?: 'basic' | 'intermediate' | 'advanced' | null; + card_count?: number | null; + topic?: string | null; + test_focus?: string | null; + }; loading?: boolean; errorMessage?: string; }; @@ -738,6 +760,16 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: const [outputContexts, setOutputContexts] = useState>({}); const [pptSourceLockIntent, setPptSourceLockIntent] = useState(null); const [directOutputIntent, setDirectOutputIntent] = useState(null); + const [flashcardDifficultyLevel, setFlashcardDifficultyLevel] = useState<'basic' | 'intermediate' | 'advanced' | ''>(''); + const [flashcardCardCount, setFlashcardCardCount] = useState(''); + const [flashcardTopic, setFlashcardTopic] = useState(''); + const [flashcardTestFocus, setFlashcardTestFocus] = useState(''); + const resetFlashcardDraftConfig = () => { + setFlashcardDifficultyLevel(''); + setFlashcardCardCount(''); + setFlashcardTopic(''); + setFlashcardTestFocus(''); + }; const [chatMessages, setChatMessages] = useState([ { @@ -1412,6 +1444,22 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: } }; + const openCitationPreview = (reference: CitationReference, fallbackName?: string) => { + const preview = String(reference?.preview || '').trim(); + const sourceName = + String(reference?.fileName || fallbackName || reference?.filePath?.split('/').pop() || '').trim() || '来源预览'; + setSourcePreviewFile({ + id: `citation-preview-${reference?.sourceNumber || sourceName}`, + name: sourceName, + type: 'doc', + uploadTime: '', + url: reference?.filePath || '', + }); + setSourcePreviewContent(preview || '暂无来源预览'); + setSourcePreviewLoading(false); + setSourcePreviewOpen(true); + }; + const handleDeleteSource = async (file: KnowledgeFile) => { // 乐观删除:先从前端列表移除,再异步调后端 setFiles((prev) => prev.filter((f) => f.id !== file.id)); @@ -1736,6 +1784,15 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: }; const openDirectOutputIntent = async (targetType: Exclude) => { + const nextFlashcardConfig = + targetType === 'flashcard' + ? { + difficulty_level: flashcardDifficultyLevel || null, + card_count: flashcardCardCount ? Math.max(1, Math.min(50, Number(flashcardCardCount) || 0)) || null : null, + topic: flashcardTopic.trim() || null, + test_focus: flashcardTestFocus.trim() || null, + } + : undefined; setGlobalError(''); setDirectOutputIntent({ targetType, @@ -1749,6 +1806,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: sourceIds: [], sourcePaths: [], sourceNames: [], + flashcardConfig: nextFlashcardConfig, loading: true, errorMessage: '', }); @@ -1801,7 +1859,11 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: sourceIdsOverride: intent.sourceIds, sourcePathsOverride: intent.sourcePaths, sourceNamesOverride: intent.sourceNames, + flashcardConfigOverride: intent.flashcardConfig, }); + if (intent.targetType === 'flashcard') { + resetFlashcardDraftConfig(); + } }; const openExistingOutput = async (output: ThinkFlowOutput) => { @@ -1861,7 +1923,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: candidateNames.some((name) => file.name === name || resolveFileUrl(file).includes(name)), ); - if (!target) return; + if (!target) return false; setLeftTab('materials'); setSelectedIds((previous) => { @@ -1870,6 +1932,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: next.add(target.id); return next; }); + return true; }; const renderSourceTooltip = (title: string, preview: string, reference?: CitationReference) => { @@ -1896,7 +1959,12 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: key={`cite_${part.value}_${index}`} type="button" className={`thinkflow-citation ${hasMeta ? 'has-tooltip' : ''}`} - onClick={() => focusSourceByReference(reference, title)} + onClick={() => { + const focused = focusSourceByReference(reference, title); + if (!focused && (preview || reference?.filePath)) { + openCitationPreview(reference || {}, title); + } + }} > [{part.value}] {renderSourceTooltip(title, preview, reference)} @@ -2900,6 +2968,12 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: sourceIdsOverride?: string[]; sourcePathsOverride?: string[]; sourceNamesOverride?: string[]; + flashcardConfigOverride?: { + difficulty_level?: 'basic' | 'intermediate' | 'advanced' | null; + card_count?: number | null; + topic?: string | null; + test_focus?: string | null; + }; }, ) => { setGlobalError(''); @@ -2935,6 +3009,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: source_names: resolvedSourceNames, bound_document_ids: resolvedBoundDocIds, enable_images: targetType === 'ppt' ? true : undefined, + flashcard_config: targetType === 'flashcard' ? (options?.flashcardConfigOverride || null) : undefined, }; console.info('[ThinkFlow] createOutline payload', outlinePayload); const response = await apiFetch('/api/v1/kb/outputs/outline', { @@ -4359,17 +4434,48 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: tryParseStructuredArray(result.content) || tryParseStructuredArray(result.preview_markdown); if (!parsed) return []; - return parsed.map((item, index) => ({ - id: String(item.id || `card_${index}`), - question: String(item.question || item.front || '').trim(), - answer: String(item.answer || item.back || '').trim(), - type: String(item.type || 'qa').trim(), - difficulty: item.difficulty ? String(item.difficulty) : null, - source_file: item.source_file ? String(item.source_file) : null, - source_excerpt: item.source_excerpt ? String(item.source_excerpt) : null, - tags: Array.isArray(item.tags) ? item.tags.map((tag) => String(tag)) : [], - created_at: item.created_at ? String(item.created_at) : null, - })); + const outputSourceNames = activeOutput?.source_names || []; + const outputSourcePaths = activeOutput?.source_paths || []; + const shouldReplaceGeneratedInput = (value?: string | null) => + Boolean(value && String(value).includes('generation_input.md') && outputSourceNames.length > 0); + return parsed.map((item, index) => { + const citations = Array.isArray(item.citations) + ? item.citations.map((citation: any) => { + const sourceNumber = + citation?.source_number !== undefined && citation?.source_number !== null + ? Number(citation.source_number) + : undefined; + const sourceIndex = sourceNumber && sourceNumber > 0 ? sourceNumber - 1 : 0; + const rawFileName = citation?.file_name ? String(citation.file_name) : null; + const rawFilePath = citation?.file_path ? String(citation.file_path) : null; + const replacementName = outputSourceNames[sourceIndex] || outputSourceNames[0] || null; + const replacementPath = outputSourcePaths[sourceIndex] || outputSourcePaths[0] || null; + return { + source_number: sourceNumber, + file_name: shouldReplaceGeneratedInput(rawFileName) ? replacementName : rawFileName, + file_path: shouldReplaceGeneratedInput(rawFilePath) ? replacementPath : rawFilePath, + preview: citation?.preview ? String(citation.preview) : null, + chunk_index: + citation?.chunk_index !== undefined && citation?.chunk_index !== null + ? Number(citation.chunk_index) + : null, + }; + }) + : []; + const sourceFile = item.source_file ? String(item.source_file) : null; + return { + id: String(item.id || `card_${index}`), + question: String(item.question || item.front || '').trim(), + answer: String(item.answer || item.back || '').trim(), + type: String(item.type || 'qa').trim(), + difficulty: item.difficulty ? String(item.difficulty) : null, + source_file: shouldReplaceGeneratedInput(sourceFile) ? outputSourceNames[0] || sourceFile : sourceFile, + source_excerpt: item.source_excerpt ? String(item.source_excerpt) : null, + tags: Array.isArray(item.tags) ? item.tags.map((tag) => String(tag)) : [], + citations, + created_at: item.created_at ? String(item.created_at) : null, + }; + }); }; const getQuizQuestionsFromResult = (result: Record): QuizQuestionItem[] => { @@ -4396,11 +4502,38 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: })); }; + const openFlashcardCitation = (reference: CitationReference) => { + const focused = focusSourceByReference(reference, reference.fileName); + const target = files.find((file) => { + const url = resolveFileUrl(file); + if ( + reference.filePath && + url && + (url === reference.filePath || + url.endsWith(reference.filePath) || + reference.filePath.endsWith(url) || + decodeURIComponent(url) === decodeURIComponent(reference.filePath)) + ) { + return true; + } + return Boolean(reference.fileName) && file.name === reference.fileName; + }); + if (target) { + void handlePreviewSource(target); + return; + } + if (!focused) { + setGlobalError('没有找到可打开的完整来源文件,当前仅能查看卡片内来源片段。'); + } + }; + const renderFlashcardPreview = (cards: FlashcardItem[]) => { if (cards.length === 0) return null; + const generationConfig = + activeOutput?.result?.generation_config || activeOutput?.flashcard_config || null; return (
- +
); }; @@ -4868,6 +5001,115 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: )}
+ + {directOutputIntent.targetType === 'flashcard' ? ( +
+
闪卡生成条件
+
+
+ 难度等级 +
+ {[ + { value: 'basic', label: '基础' }, + { value: 'intermediate', label: '进阶' }, + { value: 'advanced', label: '挑战' }, + ].map((item) => ( + + ))} +
+
+
+ + { + const nextValue = event.target.value.replace(/[^\d]/g, ''); + setDirectOutputIntent((current) => + current && current.targetType === 'flashcard' + ? { + ...current, + flashcardConfig: { + ...current.flashcardConfig, + card_count: nextValue + ? Math.max(1, Math.min(50, Number(nextValue) || 0)) || null + : null, + }, + } + : current, + ); + }} + placeholder="留空则按当前默认方式生成" + /> +
+
+ + + setDirectOutputIntent((current) => + current && current.targetType === 'flashcard' + ? { + ...current, + flashcardConfig: { + ...current.flashcardConfig, + topic: event.target.value, + }, + } + : current, + ) + } + placeholder="例如:Transformer 结构、实验结果对比、核心术语" + /> +
+
+ + + setDirectOutputIntent((current) => + current && current.targetType === 'flashcard' + ? { + ...current, + flashcardConfig: { + ...current.flashcardConfig, + test_focus: event.target.value, + }, + } + : current, + ) + } + placeholder="例如:只考概念理解、偏实验结论、重点记忆公式" + /> +
+
+
+ ) : null} )}
diff --git a/frontend_zh/src/components/thinkflow-types.ts b/frontend_zh/src/components/thinkflow-types.ts index 8717dae..71a864b 100644 --- a/frontend_zh/src/components/thinkflow-types.ts +++ b/frontend_zh/src/components/thinkflow-types.ts @@ -11,6 +11,7 @@ export type CitationReference = { filePath?: string; preview?: string; chunkIndex?: number | null; + sourceNumber?: string; }; export type PushDestinationType = 'summary' | 'document' | 'guidance'; @@ -168,9 +169,25 @@ export type FlashcardItem = { source_file?: string | null; source_excerpt?: string | null; tags?: string[]; + citations?: Array<{ + source_number?: number; + file_name?: string | null; + file_path?: string | null; + preview?: string | null; + chunk_index?: number | null; + }>; created_at?: string | null; }; +export type FlashcardGenerationConfig = { + difficulty_level?: 'basic' | 'intermediate' | 'advanced' | null; + card_count?: number | null; + topic?: string | null; + test_focus?: string | null; + language?: string | null; + generated_at?: string | null; +}; + export type QuizOptionItem = { label?: string; text?: string;