diff --git a/docs/editable_ppt_presentagent.md b/docs/editable_ppt_presentagent.md new file mode 100644 index 0000000..1f42367 --- /dev/null +++ b/docs/editable_ppt_presentagent.md @@ -0,0 +1,42 @@ +# Editable PPT PresentAgent Options + +ThinkFlow exposes a small PresentAgent option surface for editable PPT generation. + +For optional online PPTX editing through ONLYOFFICE, see `docs/onlyoffice-editable-ppt.md`. + +## User-Facing Options + +- `model_profile`: `general`, `claude`, or `qwen`. +- `coder_mode`: `library` or `direct`. If omitted, ThinkFlow defaults to `library`. +- `language`: `chinese` or `english`. +- `complexity`: `simple`, `balanced`, or `complex`. +- `target_slides`: positive integer page target. + +## Qwen Behavior + +- `model_profile=qwen` uses the local LLM backend. +- `model_profile=qwen` with omitted `coder_mode` defaults to `library`. +- `model_profile=qwen,coder_mode=library` is mapped inside the vendored PresentAgent CLI to the Qwen recipe library pipeline: `QwenRecipeCoder`, `QwenRecipeRenderer`, harness, audit, and `QwenRecipeRefiner`. +- `model_profile=qwen,coder_mode=direct` remains available and uses the direct generation path with the local Qwen backend. + +## Local Qwen Model Files + +Model weights are not committed. For the built-in local Qwen server, download: + +`Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled` + +to: + +`vendor/presentagent/models/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled/` + +The directory must contain `config.json`, tokenizer files, and model weight files. Then start the local server from `vendor/presentagent`: + +```bash +./run_local_qwen35_c500_server.sh +``` + +The script defaults to `http://127.0.0.1:18081/v1`, matching ThinkFlow's default `PRESENT_AGENT_LOCAL_LLM_API_BASE`. To use another local model directory, set `LOCAL_QWEN35_C500_MODEL_DIR`. To use an already running OpenAI-compatible Qwen service, set `PRESENT_AGENT_LOCAL_LLM_API_BASE` and `PRESENT_AGENT_LOCAL_LLM_MODEL`. + +## Option Boundary + +Qwen mode selection is represented only by the public pair `model_profile=qwen` and `coder_mode=library|direct`. diff --git a/docs/onlyoffice-editable-ppt.md b/docs/onlyoffice-editable-ppt.md new file mode 100644 index 0000000..30a3a00 --- /dev/null +++ b/docs/onlyoffice-editable-ppt.md @@ -0,0 +1,79 @@ +# ONLYOFFICE Editable PPT Deployment + +ThinkFlow editable PPT can optionally embed ONLYOFFICE Document Server for manual PPTX editing. If ONLYOFFICE is not configured, users can still download the generated PPTX. + +## Required Settings + +Configure these in `fastapi_app/.env` or the deployment environment: + +```bash +ONLYOFFICE_DOCUMENT_SERVER_URL=/onlyoffice +ONLYOFFICE_THINKFLOW_PUBLIC_URL=http://host.docker.internal:8213 +ONLYOFFICE_DOCUMENT_DOWNLOAD_BASE_URL=http://172.18.0.1:3003 +ONLYOFFICE_JWT_SECRET= +``` + +- `ONLYOFFICE_DOCUMENT_SERVER_URL`: browser-side Document Server entry. For local Vite development, use `/onlyoffice` so browser requests go through the frontend proxy. +- `ONLYOFFICE_THINKFLOW_PUBLIC_URL`: ThinkFlow backend URL reachable from the Document Server container. It is used for save callbacks. +- `ONLYOFFICE_DOCUMENT_DOWNLOAD_BASE_URL`: browser-facing URL used by ONLYOFFICE to download the PPTX. In local Vite development this should point at the frontend origin so cache and document URLs stay same-origin. +- `ONLYOFFICE_JWT_SECRET`: set this only when Document Server JWT is enabled, and keep it identical to the Document Server secret. + +## Local Docker Deployment + +Start Document Server: + +```bash +docker run -d --name thinkflow-onlyoffice \ + -p 8082:80 \ + --add-host=host.docker.internal:host-gateway \ + -e JWT_ENABLED=false \ + -e ALLOW_PRIVATE_IP_ADDRESS=true \ + onlyoffice/documentserver:latest +``` + +For local Vite development, `frontend_zh/vite.config.ts` proxies `/onlyoffice` to `http://localhost:8082`. Configure Document Server cache URLs to stay on the frontend origin: + +```bash +docker cp thinkflow-onlyoffice:/etc/onlyoffice/documentserver/local.json /tmp/thinkflow-onlyoffice-local.json +python - <<'PY' +import json +from pathlib import Path + +path = Path("/tmp/thinkflow-onlyoffice-local.json") +data = json.loads(path.read_text()) +storage = data.setdefault("storage", {}) +storage["externalHost"] = "http://localhost:3003/onlyoffice" +storage["useDirectStorageUrls"] = False +path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n") +PY +docker cp /tmp/thinkflow-onlyoffice-local.json thinkflow-onlyoffice:/etc/onlyoffice/documentserver/local.json +docker exec thinkflow-onlyoffice supervisorctl restart ds:docservice ds:converter +``` + +This avoids browser-side failures where ONLYOFFICE returns cache URLs such as `localhost:8082/cache/.../Editor.bin` while the editor is embedded from the frontend origin. + +## ThinkFlow Endpoints + +Editable PPT uses these backend endpoints: + +- `GET /api/v1/kb/outputs/{output_id}/onlyoffice/config` +- `GET|HEAD /api/v1/kb/outputs/{output_id}/onlyoffice/download/{document_key}.pptx` +- `POST /api/v1/kb/outputs/{output_id}/onlyoffice/callback` + +The callback downloads ONLYOFFICE's saved PPTX and writes it back to the output storage. PPTX edits are not reverse-synced into PresentAgent IR in this version. + +## Production Notes + +- Put Document Server behind the same HTTPS domain or a trusted internal URL. +- Enable Document Server JWT in production and set `ONLYOFFICE_JWT_SECRET` to the same secret in ThinkFlow. +- Ensure Document Server can reach `ONLYOFFICE_THINKFLOW_PUBLIC_URL` and the browser can reach `ONLYOFFICE_DOCUMENT_SERVER_URL`. +- Do not commit local container dumps such as `.onlyoffice_*.json`, `.oo_*`, or runtime logs. They are debugging artifacts, not deployable config. + +## Quick Check + +1. Create or generate an `editable_ppt` output. +2. Open the output workspace. +3. Click `在线编辑 PPTX`. +4. Confirm the editor loads and saves back through the callback. + +If the editor reports download error `-4`, re-check `storage.externalHost`, `ONLYOFFICE_DOCUMENT_DOWNLOAD_BASE_URL`, and the `/onlyoffice` proxy. 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/plans/2026-04-27-thinkflow-editable-ppt.md b/docs/superpowers/plans/2026-04-27-thinkflow-editable-ppt.md new file mode 100644 index 0000000..c573d12 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-thinkflow-editable-ppt.md @@ -0,0 +1,98 @@ +# ThinkFlow Editable PPT 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:** Build the first usable `可编辑PPT` output path by vendoring the stable PresentAgent CLI/runtime into ThinkFlow and exposing its editable PPTX plus IR artifacts in the Chinese frontend. + +**Architecture:** Add a backend service that prepares ThinkFlow context, invokes the vendored PresentAgent CLI, discovers artifacts, and stores them in outputs-v2. Extend the existing output manifest model with `editable_ppt` and add a frontend workspace that is independent from the old image-based PPT stage rail. + +**Tech Stack:** FastAPI, Python subprocess/pathlib/json, pytest, React 18, TypeScript, Vite, lucide-react. + +--- + +### Task 0: Vendor PresentAgent Runtime + +**Files:** +- Create: `vendor/presentagent/` +- Modify: `fastapi_app/config/settings.py` +- Modify: `fastapi_app/services/editable_ppt_service.py` +- Test: `tests/test_editable_ppt_service.py` + +- [x] Vendor PresentAgent CLI/runtime files needed by Step1-Step5 for `general`/`claude` direct/library and `qwen` direct/library. +- [x] Exclude generated outputs, caches, sample PDFs/PPTX, and model weights; include qwen recipe library/harness source. +- [x] Remove the internal `qwen_lib` CLI entry from the vendored runtime; expose Qwen library through `model_profile=qwen,coder_mode=library`. +- [x] Default `EditablePPTService` to `/vendor/presentagent`; keep `PRESENT_AGENT_ROOT` as explicit override. +- [x] Move PresentAgent runtime defaults into `fastapi_app/config/settings.py`. + +### Task 1: Backend PresentAgent Wrapper + +**Files:** +- Create: `fastapi_app/services/editable_ppt_service.py` +- Test: `tests/test_editable_ppt_service.py` + +- [x] Write tests for command construction, Qwen library/direct mode selection, artifact URL payloads, and local Qwen defaults. +- [x] Run `pytest -q tests/test_editable_ppt_service.py` and verify failures are about missing service. +- [x] Implement `EditablePPTService` with `build_context_markdown`, `normalize_request`, `run_presentagent`, and `discover_artifacts`. +- [x] Run `pytest -q tests/test_editable_ppt_service.py` and verify it passes. + +### Task 2: OutputV2 Integration + +**Files:** +- Modify: `fastapi_app/services/output_v2_service.py` +- Test: `tests/test_output_v2_editable_ppt.py` + +- [x] Write tests proving `editable_ppt` is supported, creates a lightweight output record, and dispatches generation to `EditablePPTService`. +- [x] Run `pytest -q tests/test_output_v2_editable_ppt.py` and verify failures are about unsupported output type or missing dispatch. +- [x] Extend `SUPPORTED_TYPES`, `create_outline`, and `generate_output` for `editable_ppt`. +- [x] Run `pytest -q tests/test_output_v2_editable_ppt.py` and verify it passes. + +### Task 3: Frontend Type And Entry + +**Files:** +- Modify: `frontend_zh/src/components/thinkflow-types.ts` +- Modify: `frontend_zh/src/components/ThinkFlowWorkspace.tsx` + +- [x] Add `editable_ppt` to local and shared output types. +- [x] Add a `可编辑PPT` output button and icon. +- [x] Ensure `resolveOutputCreationInputs` treats `editable_ppt` like `ppt` for source/document requirements. +- [x] Ensure `createOutline` routes `editable_ppt` into the output workspace and can auto-generate. + +### Task 4: Frontend Editable PPT Workspace + +**Files:** +- Modify: `frontend_zh/src/components/ThinkFlowWorkspace.tsx` +- Modify: `frontend_zh/src/components/ThinkFlowWorkspace.css` + +- [x] Add state for editable PPT options: model profile, coder mode, language, complexity, and target slide count. +- [x] Render a separate workspace for `editable_ppt`. +- [x] Render generation controls before result exists. +- [x] Render PPTX download links and IR JSON links after generation. +- [x] Render editable deck/slide IR fields and persist edits in component state. + +### Task 5: Verification + +**Files:** +- Verify: backend tests +- Verify: frontend build + +- [x] Run `pytest -q tests/test_editable_ppt_service.py tests/test_output_v2_editable_ppt.py`. +- [x] Run `npm run build` from `frontend_zh`. +- [x] Check `git status --short` and confirm only intended files were changed, aside from pre-existing unrelated files. + +### Task 6: ONLYOFFICE PPTX Editor + +**Files:** +- Modify: `fastapi_app/config/settings.py` +- Modify: `fastapi_app/services/output_v2_service.py` +- Modify: `fastapi_app/routers/kb_outputs_v2.py` +- Modify: `frontend_zh/src/components/ThinkFlowWorkspace.tsx` +- Modify: `frontend_zh/src/components/ThinkFlowWorkspace.css` +- Test: `tests/test_output_v2_editable_ppt.py` + +- [x] Add ONLYOFFICE settings for Document Server URL, ThinkFlow public URL, and optional JWT secret. +- [x] Add backend config endpoint for editable PPTX ONLYOFFICE editor config. +- [x] Add backend callback endpoint that saves ONLYOFFICE-returned PPTX back to output storage. +- [x] Add frontend `在线编辑 PPTX` action and embedded editor panel. +- [x] Keep PPTX download fallback when ONLYOFFICE is not configured. +- [x] Route Document Server browser assets through the Vite `/onlyoffice` proxy and configure `storage.externalHost` so editor cache files stay on the frontend origin. +- [x] Use a real same-origin `/online-editor-frame.html` iframe, per-open `editor_session_id`, and PPTX LibreOffice normalization to avoid stale sessions and ONLYOFFICE PPTX parser failures. 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/docs/superpowers/specs/2026-04-27-thinkflow-editable-ppt-design.md b/docs/superpowers/specs/2026-04-27-thinkflow-editable-ppt-design.md new file mode 100644 index 0000000..113aacc --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-thinkflow-editable-ppt-design.md @@ -0,0 +1,64 @@ +# ThinkFlow Editable PPT Design + +## Goal + +Add a first-version editable PPT output path in ThinkFlow by integrating PresentAgent as a separate output type. The existing image-oriented `ppt` workflow remains unchanged; the new workflow appears as a separate frontend option named `可编辑PPT`. + +## Architecture + +ThinkFlow vendors the stable PresentAgent CLI/runtime under `vendor/presentagent` and wraps it with a focused backend service. The wrapper prepares ThinkFlow context into a temporary input document, runs the vendored `vendor/presentagent/cli.py`, then records the resulting editable `.pptx` and PresentAgent IR artifacts under the existing notebook output directory. `PRESENT_AGENT_ROOT` remains an explicit override for development, but the default runtime no longer depends on `/mnt/paper2any/dingcheng/PresentAgent`. + +The frontend adds `editable_ppt` beside `ppt`. It uses the existing outputs-v2 list/open/generate pattern but renders a separate workspace focused on model/mode selection, deck/slide IR editing, generation status, and PPTX download. + +## Backend + +- Add `EditablePPTService` in `fastapi_app/services/editable_ppt_service.py`. +- Extend `OutputV2Service` with target type `editable_ppt`. +- Reuse output manifests instead of creating a second persistence system. +- Convert source paths, selected document content, bound documents, and guidance text into `presentagent_input.md`. +- Run PresentAgent CLI with: + - React enabled by default. + - ReAct iteration count left at PresentAgent default of 3. + - `general` profile with `direct` or `library`. + - `claude` profile with `direct` or `library`. + - `qwen` profile with `direct` or `library`; omitted mode defaults to `library`. +- Return artifact URLs for `pptx`, planned/final/refined deck IR, slide IR directory, token usage, and run log. + +## Model Configuration + +Non-Qwen models use API configuration via environment variables or explicit request fields. Qwen uses a local OpenAI-compatible server and does not commit model files. Deployers provide: + +- `PRESENT_AGENT_ROOT` only when intentionally overriding the vendored runtime +- `PRESENT_AGENT_PYTHON` +- `PRESENT_AGENT_LOCAL_LLM_API_BASE` +- `PRESENT_AGENT_LOCAL_LLM_MODEL` +- `LOCAL_QWEN35_C500_MODEL_DIR` when running the local server + +The repository commits the Qwen server wrapper but not model weights. By default, deployers download `Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled` to `vendor/presentagent/models/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled/`; local testing can also point `LOCAL_QWEN35_C500_MODEL_DIR` at an already downloaded model. + +## Frontend + +- Add `editable_ppt` to `OutputType`. +- Add a toolbar button labeled `可编辑PPT`. +- Use an output-immersive workspace, not the existing PPT stage rail. +- Show generation controls for model profile, coder mode, language, complexity, and target slide count. +- Show an optional `在线编辑 PPTX` action that embeds ONLYOFFICE when `ONLYOFFICE_DOCUMENT_SERVER_URL` is configured. +- Show editable IR fields after generation: + - deck title/subtitle/theme basics + - per-slide title, core message, points, and speaker notes +- Save IR edits into the output manifest result as `editable_ir`. +- Provide direct links to the editable PPTX and JSON IR artifacts. + +## First-Version Constraints + +- ONLYOFFICE is optional and must be deployed separately as a Document Server. +- Qwen library mode is enabled and remains the default when the user selects Qwen without choosing a generation mode. +- Frontend IR edits are persisted in ThinkFlow metadata; regeneration from edited IR should call the vendored PresentAgent chain. +- PPTX edits made in ONLYOFFICE save back to the PPTX file; v1 does not reverse-sync PPTX edits into IR. +- PresentAgent execution is synchronous in the first version, matching current outputs-v2 generation behavior. + +## Testing + +- Unit tests cover command construction, Qwen library/direct selection, artifact discovery, and output-v2 `editable_ppt` dispatch. +- Frontend verification uses TypeScript build. +- Full PresentAgent generation is not run in unit tests; command execution is mocked. diff --git a/docs/thinkflow-dev-setup.md b/docs/thinkflow-dev-setup.md new file mode 100644 index 0000000..0a8b783 --- /dev/null +++ b/docs/thinkflow-dev-setup.md @@ -0,0 +1,196 @@ +# ThinkFlow 开发前置说明 + +本文件用于 ThinkFlow 分支的日常开发初始化。每次开始开发前,先按本文检查环境、文档和分支状态。 + +## 1. 仓库与分支 + +- 本地仓库路径:`/mnt/paper2any/dingcheng/thinkflow` +- 上游分支:`thinkflow` +- 本地开发分支:`dingcheng-dev` + +建议每次开始开发先执行: + +```bash +cd /mnt/paper2any/dingcheng/thinkflow +git branch --show-current +``` + +如果不在 `dingcheng-dev`,先切换: + +```bash +git checkout dingcheng-dev +``` + +## 2. 开发前先读文档 + +仓库中的 `docs/` 目录包含当前开发规范、架构说明和 ThinkFlow workflow 文档。开始任何开发前,至少先阅读: + +- `docs/CLAUDE.md` +- `docs/development-architecture-guide.md` +- `docs/thinkflow-workflow-source-document-summary-guidance.md` +- `docs/thinkflow-summary-document-guidance-output-prompts.md` +- `docs/thinkflow-upload-file-processing-flow.md` + +如果后续功能开发新增了文档,开工前继续补读新增文档,避免脱离当前 workflow 约定。 + +## 3. 环境约定 + +- muxi 开发机的 `base` 环境可以直接运行项目。 +- 如需隔离环境,可以复制一个新的 conda 环境后再开发。 +- 如果需要 muxi 开发机权限或已有环境信息,直接向你确认。 + +ENV 文件位置: + +- 本地保留文件:`/mnt/paper2any/dingcheng/thinkflow/env` + +为了方便配置环境,可优先参考或复用该文件中的后端配置。 + +## 4. 启动方式 + +推荐启动脚本: + +```bash +cd /mnt/paper2any/dingcheng/thinkflow +./scripts/bash.sh +``` + +如果在 muxi 机器上启动,需要手动确认端口,不要覆盖在线服务端口: + +- 前端端口:`3001` +- 后端端口:`8213` + +## 5. 开发边界约束 + +### 5.1 新增 API / 新增模型能力 + +如果开发过程中需要新增 API、模型服务或外部能力,必须遵守下面的边界: + +- 先在 env 配置中补充变量。 +- 再在 `fastapi_app/providers/` 中增加对应 provider 调用逻辑。 +- 不要在 workflow 中直接读取零散 env 变量。 +- 不要在 workflow 中堆很重的外部调用逻辑。 + +建议遵循的落点顺序: + +1. `env` / 配置定义 +2. `fastapi_app/config/settings.py` +3. `fastapi_app/providers/` +4. `fastapi_app/services/` +5. `fastapi_app/routers/` 或 `workflow_engine/` + +当前可编辑 PPT 使用仓库内 `vendor/presentagent` 作为默认 PresentAgent 运行时。开发调试外部 PresentAgent 分支时,才设置 `PRESENT_AGENT_ROOT` 覆盖默认路径。Qwen direct/library 都依赖本地 OpenAI-compatible 服务,默认配置在 `fastapi_app/config/settings.py`: + +- `PRESENT_AGENT_PYTHON` +- `PRESENT_AGENT_LOCAL_LLM_API_BASE` +- `PRESENT_AGENT_LOCAL_LLM_MODEL` +- `THINKFLOW_EDITABLE_PPT_TIMEOUT_SECONDS` + +不要提交 Qwen 模型权重。内置本地 Qwen server 的默认模型目录是: + +```text +vendor/presentagent/models/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled/ +``` + +部署时把 `Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled` 下载到上面的目录,确保目录内包含 `config.json`、tokenizer 文件和模型权重文件,然后从 `vendor/presentagent` 启动: + +```bash +./run_local_qwen35_c500_server.sh +``` + +脚本默认监听 `http://127.0.0.1:18081/v1`,与 `PRESENT_AGENT_LOCAL_LLM_API_BASE` 默认值一致。若模型放在别处,设置 `LOCAL_QWEN35_C500_MODEL_DIR`;若已有 OpenAI-compatible Qwen 服务,直接设置 `PRESENT_AGENT_LOCAL_LLM_API_BASE` 和 `PRESENT_AGENT_LOCAL_LLM_MODEL`。 + +可编辑 PPT 的最终手工精修使用可选 ONLYOFFICE Document Server。未配置时前端保留 PPTX 下载;配置后会在可编辑 PPT 工作区显示“在线编辑 PPTX”。相关配置: + +完整部署说明见 `docs/onlyoffice-editable-ppt.md`。 + +- `ONLYOFFICE_DOCUMENT_SERVER_URL`:浏览器侧 ONLYOFFICE 入口。本地 Vite 开发推荐使用 `/onlyoffice` 代理,避免浏览器直接访问 Document Server 的 8082 端口。 +- `ONLYOFFICE_THINKFLOW_PUBLIC_URL`:ONLYOFFICE 容器可访问的 ThinkFlow 后端公网/内网地址,例如 `https://thinkflow.nas.cpolar.cn` +- `ONLYOFFICE_JWT_SECRET`:如果 Document Server 开启 JWT,则填同一个 secret + +ONLYOFFICE 需要能访问 ThinkFlow 的 `/outputs/...` 文件 URL 和 `/api/v1/kb/outputs/{output_id}/onlyoffice/callback` 保存回调。 + +本地 Docker 部署时,如果 `ONLYOFFICE_THINKFLOW_PUBLIC_URL` 指向宿主机内网地址,需要允许 Document Server 访问私有 IP: + +```bash +docker run -d --name thinkflow-onlyoffice \ + -p 8082:80 \ + --add-host=host.docker.internal:host-gateway \ + -e JWT_ENABLED=false \ + -e ALLOW_PRIVATE_IP_ADDRESS=true \ + onlyoffice/documentserver:latest +``` + +如果前端通过 Vite `/onlyoffice` 代理加载 Document Server,还需要让 ONLYOFFICE 生成的缓存文件 URL 也走同一个 3003 origin,否则浏览器可能访问 `localhost:8082/cache/.../Editor.bin` 失败并显示“错误码 -4:下载失败”。容器启动后执行: + +```bash +docker cp thinkflow-onlyoffice:/etc/onlyoffice/documentserver/local.json /tmp/thinkflow-onlyoffice-local.json +python - <<'PY' +import json +from pathlib import Path + +path = Path("/tmp/thinkflow-onlyoffice-local.json") +data = json.loads(path.read_text()) +storage = data.setdefault("storage", {}) +storage["externalHost"] = "http://localhost:3003/onlyoffice" +storage["useDirectStorageUrls"] = False +path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n") +PY +docker cp /tmp/thinkflow-onlyoffice-local.json thinkflow-onlyoffice:/etc/onlyoffice/documentserver/local.json +docker exec thinkflow-onlyoffice supervisorctl restart ds:docservice ds:converter +``` + +生产部署建议开启 JWT,并将 `ONLYOFFICE_JWT_SECRET` 配成与 Document Server 相同的 secret;本地开发为了避免随机 secret 与后端配置不一致,默认关闭 JWT。 + +### 5.2 ThinkFlow 整体开发逻辑 + +整体开发逻辑遵循: + +`来源引入 -> 基于 RAG 的 chat -> 产出消费` + +当前 ThinkFlow 的主联通对象是: + +- 梳理文档 +- 产出指导 + +开发时优先保证这条链路的清晰性,不要把来源、聊天、产出强耦合在一个模块里。 + +### 5.3 工作流理解 + +根据当前仓库文档,ThinkFlow 的正式上下文结构可概括为: + +- 来源:事实主源 +- 梳理文档:核心中间产物 +- 产出指导:高权重产出约束 +- 摘要:偏阅读笔记,不是当前正式产出主输入 + +因此开发新功能时,优先考虑: + +- 来源如何进入系统 +- 是否需要先沉淀成梳理文档 +- 是否需要产出指导参与最终生成 + +## 6. 当前本地 SSH 约定 + +当前目录专用 SSH 文件: + +- 私钥:`/mnt/paper2any/dingcheng/thinkflow_dingcheng_ed25519` +- 公钥:`/mnt/paper2any/dingcheng/thinkflow_dingcheng_ed25519.pub` +- SSH 配置:`/mnt/paper2any/dingcheng/thinkflow_ssh_config` + +后续如果需要显式使用当前目录专用密钥拉取或推送,使用: + +```bash +GIT_SSH_COMMAND='ssh -F /mnt/paper2any/dingcheng/thinkflow_ssh_config' git +``` + +不要依赖全局 `~/.ssh/config` 或机器上的其他 ssh-agent 身份。 + +## 7. 每次开发前的最小检查清单 + +- 当前目录在 `/mnt/paper2any/dingcheng/thinkflow` +- 当前分支是 `dingcheng-dev` +- 已阅读 `docs/` 中相关开发规范 +- 已确认本次开发涉及的 workflow 文档 +- 已确认 ENV 是否齐全 +- 已确认端口不会覆盖在线服务 +- 若涉及新增外部能力,已规划 `env -> provider -> service -> router/workflow` 的接入路径 diff --git a/fastapi_app/.env.example b/fastapi_app/.env.example index 0493ff6..1acf84e 100644 --- a/fastapi_app/.env.example +++ b/fastapi_app/.env.example @@ -58,6 +58,35 @@ LLM_API_URL=https://api.apiyi.com/v1 LLM_API_KEY=your_llm_api_key LLM_MODEL=gemini-2.5-flash +# ============================================ +# Editable PPT Provider +# ============================================ +# Editable PPT uses vendored runtime settings first. Set these when the +# editable-PPT provider differs from ThinkFlow's global LLM/Image settings. +PRESENT_AGENT_LLM_API_BASE=http://123.129.219.111:3000/v1 +PRESENT_AGENT_LLM_API_KEY=your_present_llm_api_key +PRESENT_AGENT_LLM_MODEL=claude-sonnet-4-6 +PRESENT_AGENT_VLM_API_BASE=http://123.129.219.111:3000/v1 +PRESENT_AGENT_VLM_API_KEY=your_present_vlm_api_key +PRESENT_AGENT_VLM_MODEL=qwen3-vl-32b-instruct +PRESENT_AGENT_IMAGE_API_BASE=http://123.129.219.111:3000/v1 +PRESENT_AGENT_IMAGE_API_KEY=your_present_image_api_key +PRESENT_AGENT_IMAGE_MODEL=gemini-3.1-flash-image-preview + +# Qwen editable PPT local backend. Download model weights to the vendored +# PresentAgent folder below, or point LOCAL_QWEN35_C500_MODEL_DIR at another +# existing local directory that contains config.json/tokenizer/model weights. +PRESENT_AGENT_LOCAL_LLM_API_BASE=http://127.0.0.1:18081/v1 +PRESENT_AGENT_LOCAL_LLM_MODEL=Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled +LOCAL_QWEN35_C500_MODEL_DIR=vendor/presentagent/models/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled + +# Online PPTX editing. In local Vite dev, keep the browser on the /onlyoffice +# proxy and configure Document Server storage.externalHost to +# http://localhost:3003/onlyoffice so cache files do not jump to port 8082. +ONLYOFFICE_DOCUMENT_SERVER_URL=/onlyoffice +ONLYOFFICE_THINKFLOW_PUBLIC_URL=http://host.docker.internal:8213 +ONLYOFFICE_DOCUMENT_DOWNLOAD_BASE_URL=http://172.18.0.1:3003 + # ============================================ # Search Provider # SEARCH_PROVIDER: serper | serpapi | bocha(默认 serper) diff --git a/fastapi_app/config/settings.py b/fastapi_app/config/settings.py index 64270ab..81486eb 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): @@ -96,6 +98,27 @@ class AppSettings(BaseSettings): LLM_API_KEY: str = "" LLM_MODEL: str = "gemini-2.5-flash" + # Editable PPT / vendored PresentAgent + # Empty PRESENT_AGENT_ROOT means use /vendor/presentagent. + PRESENT_AGENT_ROOT: str = "" + PRESENT_AGENT_PYTHON: str = "python" + PRESENT_AGENT_LLM_API_KEY: str = "" + PRESENT_AGENT_LLM_API_BASE: str = "http://123.129.219.111:3000/v1" + PRESENT_AGENT_LLM_MODEL: str = "claude-sonnet-4-6" + PRESENT_AGENT_VLM_API_KEY: str = "" + PRESENT_AGENT_VLM_API_BASE: str = "http://123.129.219.111:3000/v1" + PRESENT_AGENT_VLM_MODEL: str = "qwen3-vl-32b-instruct" + PRESENT_AGENT_IMAGE_API_KEY: str = "" + PRESENT_AGENT_IMAGE_API_BASE: str = "http://123.129.219.111:3000/v1" + PRESENT_AGENT_IMAGE_MODEL: str = "gemini-3.1-flash-image-preview" + PRESENT_AGENT_LOCAL_LLM_API_BASE: str = "http://127.0.0.1:18081/v1" + PRESENT_AGENT_LOCAL_LLM_MODEL: str = "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled" + THINKFLOW_EDITABLE_PPT_TIMEOUT_SECONDS: int = 7200 + ONLYOFFICE_DOCUMENT_SERVER_URL: str = "" + ONLYOFFICE_THINKFLOW_PUBLIC_URL: str = "" + ONLYOFFICE_DOCUMENT_DOWNLOAD_BASE_URL: str = "" + ONLYOFFICE_JWT_SECRET: str = "" + # Legacy: Local service switches (backward compatibility) USE_LOCAL_TTS: int = 0 TTS_ENGINE: str = "qwen" @@ -126,7 +149,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/main.py b/fastapi_app/main.py index 8038d22..e9dd9ec 100644 --- a/fastapi_app/main.py +++ b/fastapi_app/main.py @@ -26,7 +26,7 @@ else: log.info(f"Supabase not configured: URL={'set' if _supabase_url else 'unset'}, ANON_KEY={'set' if _supabase_anon else 'unset'}") -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse @@ -95,8 +95,8 @@ def create_app() -> FastAPI: outputs_dir = project_root / "outputs" outputs_dir.mkdir(parents=True, exist_ok=True) - @app.get("/outputs/{path:path}") - async def serve_outputs(path: str): + @app.api_route("/outputs/{path:path}", methods=["GET", "HEAD"]) + async def serve_outputs(path: str, request: Request): path_decoded = unquote(path) outputs_resolved = outputs_dir.resolve() for candidate in (path_decoded, path): @@ -105,7 +105,7 @@ async def serve_outputs(path: str): if not str(file_path).startswith(str(outputs_resolved)): continue if file_path.is_file(): - resp = FileResponse(path=str(file_path), filename=file_path.name) + resp = FileResponse(path=str(file_path), filename=file_path.name, method=request.method) if file_path.suffix.lower() == ".pdf": resp.headers["Content-Disposition"] = "inline" return resp diff --git a/fastapi_app/middleware/api_key.py b/fastapi_app/middleware/api_key.py index 7d179d4..74d0537 100644 --- a/fastapi_app/middleware/api_key.py +++ b/fastapi_app/middleware/api_key.py @@ -40,6 +40,12 @@ async def workflow(_: None = Depends(verify_api_key)): ) +def _is_onlyoffice_public_endpoint(path: str, method: str) -> bool: + if method == "POST" and path.endswith("/onlyoffice/callback"): + return True + return method in {"GET", "HEAD"} and "/onlyoffice/download/" in path and path.endswith(".pptx") + + class APIKeyMiddleware(BaseHTTPMiddleware): """ Middleware that verifies API key for /api/* routes. @@ -65,8 +71,12 @@ async def dispatch(self, request: Request, call_next): # Only check API key for /api/* and /paper2video/* routes if path.startswith("/api/") or path.startswith("/paper2video/"): + if _is_onlyoffice_public_endpoint(path, request.method): + return await call_next(request) + api_key = request.headers.get("X-API-Key") - # EventSource 无法带自定义头,progress SSE 允许通过 query 传 key + # EventSource / third-party callbacks cannot set custom headers, + # so progress SSE allows passing the internal key in query. if not api_key and request.method == "GET" and "/paper2rebuttal/progress/" in path: api_key = request.query_params.get("x_api_key") or request.query_params.get("X-API-Key") 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..e0af4bc 100644 --- a/fastapi_app/routers/kb_outputs_v2.py +++ b/fastapi_app/routers/kb_outputs_v2.py @@ -2,7 +2,8 @@ from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Request +from fastapi.responses import FileResponse from pydantic import BaseModel from fastapi_app.services.output_v2_service import OutputV2Service @@ -29,6 +30,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): @@ -43,6 +45,22 @@ class SaveOutlineRequest(BaseModel): enable_images: Optional[bool] = None +class SaveEditablePptIRRequest(BaseModel): + notebook_id: str + notebook_title: str = "" + user_id: str = "local" + email: Optional[str] = None + deck_ir: Dict[str, Any] + + +class OnlyOfficeCallbackRequest(BaseModel): + status: Optional[int] = None + url: Optional[str] = None + key: Optional[str] = None + users: Optional[List[str]] = None + actions: Optional[List[Dict[str, Any]]] = None + + class RefineOutlineRequest(BaseModel): notebook_id: str notebook_title: str = "" @@ -62,6 +80,7 @@ class GenerateOutputRequest(BaseModel): api_url: Optional[str] = None api_key: Optional[str] = None model: Optional[str] = None + editable_ppt_options: Optional[Dict[str, Any]] = None class RegeneratePptPageRequest(GenerateOutputRequest): @@ -127,6 +146,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} @@ -147,6 +167,87 @@ async def save_outline(output_id: str, request: SaveOutlineRequest) -> Dict[str, return {"success": True, "output": item} +@router.put("/{output_id}/editable-ir") +async def save_editable_ppt_ir(output_id: str, request: SaveEditablePptIRRequest) -> Dict[str, Any]: + item = service.save_editable_ppt_ir( + notebook_id=request.notebook_id, + notebook_title=request.notebook_title, + user_id=_effective_user(request.user_id, request.email), + output_id=output_id, + deck_ir=request.deck_ir, + ) + return {"success": True, "output": item} + + +@router.get("/{output_id}/onlyoffice/config") +async def get_onlyoffice_config( + output_id: str, + request: Request, + notebook_id: str = Query(...), + notebook_title: str = Query(""), + user_id: str = Query("local"), + email: Optional[str] = Query(None), + browser_base_url: str = Query(""), + editor_session_id: str = Query(""), +) -> Dict[str, Any]: + payload = service.get_onlyoffice_config( + notebook_id=notebook_id, + notebook_title=notebook_title, + user_id=_effective_user(user_id, email), + output_id=output_id, + request_base_url=str(request.base_url).rstrip("/"), + browser_base_url=browser_base_url, + editor_session_id=editor_session_id, + ) + return {"success": True, **payload} + + +@router.api_route("/{output_id}/onlyoffice/download/{document_key}.pptx", methods=["GET", "HEAD"]) +async def download_onlyoffice_document( + output_id: str, + document_key: str, + request: Request, + notebook_id: str = Query(...), + notebook_title: str = Query(""), + user_id: str = Query("local"), + email: Optional[str] = Query(None), + document_base_url: str = Query(""), + editor_session_id: str = Query(""), +) -> FileResponse: + return service.get_onlyoffice_document_response( + notebook_id=notebook_id, + notebook_title=notebook_title, + user_id=_effective_user(user_id, email), + output_id=output_id, + document_key=document_key, + document_base_url=document_base_url, + editor_session_id=editor_session_id, + method=request.method, + ) + + +@router.post("/{output_id}/onlyoffice/callback") +async def handle_onlyoffice_callback( + output_id: str, + request: OnlyOfficeCallbackRequest, + notebook_id: str = Query(...), + notebook_title: str = Query(""), + user_id: str = Query("local"), + email: Optional[str] = Query(None), + document_base_url: str = Query(""), + editor_session_id: str = Query(""), +) -> Dict[str, int]: + return service.handle_onlyoffice_callback( + notebook_id=notebook_id, + notebook_title=notebook_title, + user_id=_effective_user(user_id, email), + output_id=output_id, + payload=request.model_dump(exclude_none=True), + document_base_url=document_base_url, + editor_session_id=editor_session_id, + ) + + @router.post("/{output_id}/outline-refine") async def refine_outline(output_id: str, request: RefineOutlineRequest) -> Dict[str, Any]: item = await service.refine_outline( @@ -174,6 +275,7 @@ async def generate_output(output_id: str, request: GenerateOutputRequest) -> Dic api_url=request.api_url, api_key=request.api_key, model=request.model, + editable_ppt_options=request.editable_ppt_options, ) 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/editable_ppt_service.py b/fastapi_app/services/editable_ppt_service.py new file mode 100644 index 0000000..2b2c17a --- /dev/null +++ b/fastapi_app/services/editable_ppt_service.py @@ -0,0 +1,580 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException + +from fastapi_app.config.settings import settings +from fastapi_app.utils import _from_outputs_url, _to_outputs_url +from workflow_engine.utils import get_project_root + + +class EditablePPTService: + """ThinkFlow wrapper around the editable PPT CLI.""" + + IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".svg"} + + def __init__( + self, + *, + presentagent_root: Optional[Path] = None, + project_root: Optional[Path] = None, + python_bin: Optional[str] = None, + ) -> None: + self.project_root = project_root or get_project_root() + configured_root = str(presentagent_root or settings.PRESENT_AGENT_ROOT or "").strip() + self.presentagent_root = Path(configured_root) if configured_root else self.project_root / "vendor" / "presentagent" + self.python_bin = python_bin or settings.PRESENT_AGENT_PYTHON + + def normalize_request( + self, + *, + model_profile: Optional[str], + coder_mode: Optional[str], + language: Optional[str], + complexity: Optional[str], + target_slides: Optional[int], + api_url: Optional[str], + api_key: Optional[str], + model: Optional[str], + ) -> Dict[str, Any]: + profile = (model_profile or "general").strip().lower() + mode = (coder_mode or "library").strip().lower() + lang = (language or "chinese").strip().lower() + level = (complexity or "balanced").strip().lower() + slides = int(target_slides or 0) + + if profile not in {"general", "claude", "qwen"}: + raise HTTPException(status_code=400, detail="model_profile must be general, claude, or qwen") + if mode not in {"direct", "library"}: + raise HTTPException(status_code=400, detail="coder_mode must be direct or library") + if lang not in {"english", "chinese"}: + raise HTTPException(status_code=400, detail="language must be english or chinese") + if level not in {"simple", "balanced", "complex"}: + raise HTTPException(status_code=400, detail="complexity must be simple, balanced, or complex") + + return { + "model_profile": profile, + "coder_mode": mode, + "language": lang, + "complexity": level, + "target_slides": max(0, slides), + "api_url": (api_url or "").strip(), + "api_key": (api_key or "").strip(), + "model": (model or "").strip(), + } + + def resolve_input_path( + self, + *, + source_paths: List[str], + document_content: str, + output_dir: Path, + ) -> str: + for raw in source_paths: + value = str(raw or "").strip() + if not value: + continue + if value.startswith(("http://", "https://")) and value.lower().split("?", 1)[0].endswith(".pdf"): + return value + local = Path(_from_outputs_url(value)) + if local.exists() and local.is_file() and local.suffix.lower() == ".pdf": + return str(local.resolve()) + + if str(document_content or "").strip(): + context_path = output_dir / "editable_ppt_input.md" + context_path.parent.mkdir(parents=True, exist_ok=True) + context_path.write_text(document_content, encoding="utf-8") + + raise HTTPException( + status_code=400, + detail="Editable PPT v1 requires at least one PDF source", + ) + + def build_context_markdown( + self, + *, + item: Dict[str, Any], + document: Dict[str, Any], + guidance_text: str, + ) -> str: + lines = [f"# {item.get('title') or document.get('title') or '可编辑PPT'}", ""] + if str(guidance_text or "").strip(): + lines.extend(["## 产出指导", "", str(guidance_text).strip(), ""]) + if str(document.get("content") or "").strip(): + lines.extend(["## 梳理文档", "", str(document.get("content") or "").strip(), ""]) + source_names = [str(name or "").strip() for name in item.get("source_names") or [] if str(name or "").strip()] + if source_names: + lines.extend(["## 来源文件", ""]) + lines.extend(f"- {name}" for name in source_names) + return "\n".join(lines).strip() + + def run_from_output( + self, + *, + item: Dict[str, Any], + document: Dict[str, Any], + guidance_text: str, + output_dir: Path, + api_url: Optional[str], + api_key: Optional[str], + model: Optional[str], + options: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + merged_options = dict((item.get("result") or {}).get("editable_ppt_options") or {}) + merged_options.update(options or {}) + context_text = self.build_context_markdown(item=item, document=document, guidance_text=guidance_text) + input_path = self.resolve_input_path( + source_paths=item.get("source_paths") or [], + document_content=context_text, + output_dir=output_dir, + ) + resume_output_dir = self.prepare_resume_output_dir( + input_path=input_path, + output_dir=output_dir, + ) + return self.run_presentagent( + input_path=input_path, + output_dir=output_dir, + title=str(item.get("title") or "editable_ppt"), + model_profile=merged_options.get("model_profile") or "general", + coder_mode=merged_options.get("coder_mode") or "library", + language=merged_options.get("language") or "chinese", + complexity=merged_options.get("complexity") or "balanced", + target_slides=int(merged_options.get("target_slides") or item.get("page_count") or 0), + api_url=api_url, + api_key=api_key, + model=model, + resume_output_dir=resume_output_dir, + ) + + def run_presentagent( + self, + *, + input_path: str, + output_dir: Path, + title: str, + model_profile: Optional[str], + coder_mode: Optional[str], + language: Optional[str], + complexity: Optional[str], + target_slides: Optional[int], + api_url: Optional[str], + api_key: Optional[str], + model: Optional[str], + resume_output_dir: Optional[Path] = None, + ) -> Dict[str, Any]: + normalized = self.normalize_request( + model_profile=model_profile, + coder_mode=coder_mode, + language=language, + complexity=complexity, + target_slides=target_slides, + api_url=api_url, + api_key=api_key, + model=model, + ) + if not (self.presentagent_root / "cli.py").exists(): + raise HTTPException(status_code=500, detail="Editable PPT runtime is not available") + + output_dir.mkdir(parents=True, exist_ok=True) + run_root = output_dir / "editable_ppt_run" + final_pptx = output_dir / "editable.pptx" + log_path = output_dir / "editable_ppt.log" + command = self._build_command( + input_path=input_path, + output_path=final_pptx, + options=normalized, + resume_output_dir=resume_output_dir, + ) + env = self._build_env(run_root=run_root, options=normalized) + + try: + completed = subprocess.run( + command, + cwd=str(self.presentagent_root), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=int(settings.THINKFLOW_EDITABLE_PPT_TIMEOUT_SECONDS), + check=True, + ) + log_path.write_text(completed.stdout or "", encoding="utf-8") + except subprocess.CalledProcessError as exc: + log_path.write_text(exc.stdout or "", encoding="utf-8") + raise HTTPException(status_code=500, detail=f"Editable PPT generation failed; see {log_path}") from exc + except subprocess.TimeoutExpired as exc: + log_path.write_text(exc.stdout or "", encoding="utf-8") + raise HTTPException(status_code=504, detail=f"Editable PPT generation timed out; see {log_path}") from exc + + onlyoffice_normalized = self._normalize_pptx_for_onlyoffice(final_pptx, log_path=log_path) + result = self.discover_artifacts(output_dir=output_dir, run_root=run_root) + result["editable_ppt_options"] = normalized + result["log_path"] = str(log_path) + result["log_url"] = _to_outputs_url(str(log_path)) + result["download_url"] = result.get("pptx_url", "") + result["onlyoffice_normalized"] = onlyoffice_normalized + return result + + def _normalize_pptx_for_onlyoffice(self, pptx_path: Path, *, log_path: Path) -> bool: + converter = shutil.which("libreoffice") or shutil.which("soffice") + if not converter: + with log_path.open("a", encoding="utf-8") as handle: + handle.write("\n[thinkflow] LibreOffice not found; skipped PPTX normalization.\n") + return False + if not pptx_path.exists(): + return False + + with tempfile.TemporaryDirectory(prefix="thinkflow-pptx-normalize-") as temp_dir: + out_dir = Path(temp_dir) + command = [ + converter, + "--headless", + "--convert-to", + "pptx", + "--outdir", + str(out_dir), + str(pptx_path), + ] + try: + completed = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=180, + check=True, + ) + except subprocess.CalledProcessError as exc: + with log_path.open("a", encoding="utf-8") as handle: + handle.write("\n[thinkflow] PPTX normalization failed:\n") + handle.write(exc.stdout or "") + raise HTTPException(status_code=500, detail=f"Editable PPT normalization failed; see {log_path}") from exc + except subprocess.TimeoutExpired as exc: + with log_path.open("a", encoding="utf-8") as handle: + handle.write("\n[thinkflow] PPTX normalization timed out:\n") + handle.write(str(exc.stdout or "")) + raise HTTPException(status_code=504, detail=f"Editable PPT normalization timed out; see {log_path}") from exc + + normalized_path = out_dir / pptx_path.name + if not normalized_path.exists() or normalized_path.stat().st_size <= 0: + with log_path.open("a", encoding="utf-8") as handle: + handle.write("\n[thinkflow] PPTX normalization produced no output.\n") + handle.write(completed.stdout or "") + raise HTTPException(status_code=500, detail=f"Editable PPT normalization produced no output; see {log_path}") + shutil.copy2(normalized_path, pptx_path) + with log_path.open("a", encoding="utf-8") as handle: + handle.write("\n[thinkflow] PPTX normalized for online editing:\n") + handle.write(completed.stdout or "") + return True + + def _build_command( + self, + *, + input_path: str, + output_path: Path, + options: Dict[str, Any], + resume_output_dir: Optional[Path] = None, + ) -> List[str]: + command = [ + self.python_bin, + "cli.py", + input_path, + "--output", + str(output_path), + "--coder-mode", + options["coder_mode"], + "--model-profile", + options["model_profile"], + "--language", + options["language"], + "--complexity", + options["complexity"], + ] + if resume_output_dir: + command.extend(["--resume-output-dir", str(resume_output_dir)]) + if int(options.get("target_slides") or 0) > 0: + command.extend(["--target-slides", str(int(options["target_slides"]))]) + if options["model_profile"] == "qwen": + command.extend(["--llm-backend", "local"]) + else: + command.extend(["--llm-backend", "remote"]) + api_url = str(options.get("api_url") or "").strip() + model = str(options.get("model") or "").strip() + if api_url and options["model_profile"] != "qwen": + command.extend(["--local-llm-api-base", api_url]) + if model and options["model_profile"] != "qwen": + command.extend(["--local-llm-model", model]) + return command + + def _build_env(self, *, run_root: Path, options: Dict[str, Any]) -> Dict[str, str]: + env = os.environ.copy() + env["PRESENT_AGENT_OUTPUT_DIR"] = str(run_root) + env["PRESENT_AGENT_MODEL_PROFILE"] = options["model_profile"] + + thinkflow_llm_key = str(settings.LLM_API_KEY or "").strip() + thinkflow_llm_base = str(settings.LLM_API_URL or "").strip() + thinkflow_llm_model = str(settings.LLM_MODEL or "").strip() + thinkflow_vlm_model = str(settings.PAPER2PPT_VLM_MODEL or "").strip() + thinkflow_image_key = str(settings.IMAGE_GEN_API_KEY or "").strip() + thinkflow_image_base = str(settings.IMAGE_GEN_API_URL or "").strip() + thinkflow_image_model = str(settings.IMAGE_GEN_MODEL or "").strip() + + present_llm_key = str(env.get("PRESENT_AGENT_LLM_API_KEY") or settings.PRESENT_AGENT_LLM_API_KEY or "").strip() + present_llm_base = str(env.get("PRESENT_AGENT_LLM_API_BASE") or settings.PRESENT_AGENT_LLM_API_BASE or "").strip() + present_llm_model = str(env.get("PRESENT_AGENT_LLM_MODEL") or settings.PRESENT_AGENT_LLM_MODEL or "").strip() + present_vlm_key = str(env.get("PRESENT_AGENT_VLM_API_KEY") or settings.PRESENT_AGENT_VLM_API_KEY or present_llm_key).strip() + present_vlm_base = str(env.get("PRESENT_AGENT_VLM_API_BASE") or settings.PRESENT_AGENT_VLM_API_BASE or present_llm_base).strip() + present_vlm_model = str(env.get("PRESENT_AGENT_VLM_MODEL") or settings.PRESENT_AGENT_VLM_MODEL or thinkflow_vlm_model).strip() + present_image_key = str(env.get("PRESENT_AGENT_IMAGE_API_KEY") or settings.PRESENT_AGENT_IMAGE_API_KEY or "").strip() + present_image_base = str(env.get("PRESENT_AGENT_IMAGE_API_BASE") or settings.PRESENT_AGENT_IMAGE_API_BASE or "").strip() + present_image_model = str(env.get("PRESENT_AGENT_IMAGE_MODEL") or settings.PRESENT_AGENT_IMAGE_MODEL or "").strip() + + if options["model_profile"] != "qwen": + env["PRESENT_AGENT_LLM_API_KEY"] = present_llm_key or str(options.get("api_key") or "").strip() or thinkflow_llm_key + if present_llm_key: + env["PRESENT_AGENT_LLM_API_BASE"] = present_llm_base + env["PRESENT_AGENT_LLM_MODEL"] = present_llm_model + else: + env["PRESENT_AGENT_LLM_API_BASE"] = str(options.get("api_url") or "").strip() or thinkflow_llm_base + env["PRESENT_AGENT_LLM_MODEL"] = str(options.get("model") or "").strip() or thinkflow_llm_model + + env["PRESENT_AGENT_VLM_API_KEY"] = present_vlm_key or thinkflow_llm_key + if present_vlm_key: + env["PRESENT_AGENT_VLM_API_BASE"] = present_vlm_base + env["PRESENT_AGENT_VLM_MODEL"] = present_vlm_model + else: + env["PRESENT_AGENT_VLM_API_BASE"] = thinkflow_llm_base + env["PRESENT_AGENT_VLM_MODEL"] = present_vlm_model or thinkflow_vlm_model + + env["PRESENT_AGENT_IMAGE_API_KEY"] = present_image_key or thinkflow_image_key or thinkflow_llm_key + if present_image_key: + env["PRESENT_AGENT_IMAGE_API_BASE"] = present_image_base + env["PRESENT_AGENT_IMAGE_MODEL"] = present_image_model + else: + env["PRESENT_AGENT_IMAGE_API_BASE"] = thinkflow_image_base or thinkflow_llm_base + env["PRESENT_AGENT_IMAGE_MODEL"] = thinkflow_image_model or present_image_model + + if options["model_profile"] == "qwen": + env["PRESENT_AGENT_USE_LOCAL_LLM"] = "1" + env.setdefault("PRESENT_AGENT_LOCAL_LLM_API_BASE", settings.PRESENT_AGENT_LOCAL_LLM_API_BASE) + env.setdefault("PRESENT_AGENT_LOCAL_LLM_MODEL", settings.PRESENT_AGENT_LOCAL_LLM_MODEL) + return env + + def prepare_resume_output_dir(self, *, input_path: str, output_dir: Path) -> Optional[Path]: + if str(input_path).startswith(("http://", "https://")): + return None + pdf_path = Path(input_path) + if not pdf_path.exists() or pdf_path.suffix.lower() != ".pdf": + return None + + source_dir = self._find_source_dir_for_pdf(pdf_path) + if not source_dir: + return None + markdown_path = self._find_existing_source_markdown(source_dir=source_dir, pdf_path=pdf_path) + if not markdown_path: + return None + + resume_dir = output_dir / "editable_ppt_resume" / pdf_path.stem + markdown_dir = resume_dir / "markdown" + images_dir = resume_dir / "images" / "self" + markdown_dir.mkdir(parents=True, exist_ok=True) + images_dir.mkdir(parents=True, exist_ok=True) + + copied_names = self._copy_existing_source_images(source_dir=source_dir, images_dir=images_dir) + markdown_text = markdown_path.read_text(encoding="utf-8") + markdown_text = self._rewrite_markdown_image_paths(markdown_text, copied_names) + (markdown_dir / "full.md").write_text(markdown_text, encoding="utf-8") + return resume_dir + + def _find_source_dir_for_pdf(self, pdf_path: Path) -> Optional[Path]: + if pdf_path.parent.name == "original" and pdf_path.parent.parent.exists(): + return pdf_path.parent.parent + for parent in pdf_path.parents: + if parent.name == "sources": + candidate = pdf_path.parent + while candidate.parent != parent and candidate != candidate.parent: + candidate = candidate.parent + return candidate if candidate.exists() else None + return None + + def _find_existing_source_markdown(self, *, source_dir: Path, pdf_path: Path) -> Optional[Path]: + candidates = [ + source_dir / "markdown" / f"{pdf_path.stem}.md", + source_dir / "mineru" / source_dir.name / f"{source_dir.name}.md", + source_dir / "mineru" / pdf_path.stem / f"{pdf_path.stem}.md", + ] + for candidate in candidates: + if candidate.exists() and candidate.is_file(): + return candidate + for base in [source_dir / "markdown", source_dir / "mineru"]: + if not base.exists(): + continue + markdown_files = sorted(path for path in base.rglob("*.md") if path.is_file()) + if markdown_files: + return markdown_files[0] + return None + + def _copy_existing_source_images(self, *, source_dir: Path, images_dir: Path) -> set[str]: + copied_names: set[str] = set() + image_dirs = self._find_existing_source_image_dirs(source_dir) + for image_dir in image_dirs: + for source_path in sorted(image_dir.rglob("*")): + if not source_path.is_file() or source_path.suffix.lower() not in self.IMAGE_EXTENSIONS: + continue + target_name = self._unique_image_name(source_path.name, copied_names) + shutil.copy2(source_path, images_dir / target_name) + copied_names.add(target_name) + return copied_names + + def _find_existing_source_image_dirs(self, source_dir: Path) -> List[Path]: + candidates: List[Path] = [] + mineru_dir = source_dir / "mineru" + for name in [source_dir.name]: + candidates.extend( + [ + mineru_dir / name / "auto" / "images", + mineru_dir / name / "auto" / "_pages", + ] + ) + if mineru_dir.exists(): + for child in sorted(mineru_dir.iterdir()): + if not child.is_dir(): + continue + candidates.extend([child / "auto" / "images", child / "auto" / "_pages"]) + existing: List[Path] = [] + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.resolve() if candidate.exists() else candidate + if candidate.exists() and candidate.is_dir() and resolved not in seen: + existing.append(candidate) + seen.add(resolved) + return existing + + def _unique_image_name(self, original_name: str, used_names: set[str]) -> str: + if original_name not in used_names: + return original_name + stem = Path(original_name).stem + suffix = Path(original_name).suffix + index = 1 + while True: + candidate = f"{stem}_{index}{suffix}" + if candidate not in used_names: + return candidate + index += 1 + + def _rewrite_markdown_image_paths(self, markdown_text: str, copied_names: set[str]) -> str: + if not copied_names: + return markdown_text + for image_name in sorted(copied_names, key=len, reverse=True): + escaped_name = re.escape(image_name) + markdown_text = re.sub( + rf"\]\((?:\./)?(?:auto/)?(?:images|_pages)/{escaped_name}\)", + f"](images/self/{image_name})", + markdown_text, + ) + markdown_text = re.sub( + rf'src="(?:\./)?(?:auto/)?(?:images|_pages)/{escaped_name}"', + f'src="images/self/{image_name}"', + markdown_text, + ) + return markdown_text + + def discover_artifacts(self, *, output_dir: Path, run_root: Path) -> Dict[str, Any]: + pptx_path = output_dir / "editable.pptx" + if not pptx_path.exists(): + raise HTTPException(status_code=500, detail=f"Editable PPT generation did not produce PPTX: {pptx_path}") + + search_roots = [run_root, output_dir / "editable_ppt_resume"] + refined_ir = self._find_first_in_roots(search_roots, ["ir/refined/final_ir.json"]) + final_ir = self._find_first_in_roots(search_roots, ["ir/final/final_ir.json"]) + planned_ir = self._find_first_in_roots(search_roots, ["ir/planned/final_ir.json"]) + deck_ir_path = refined_ir or final_ir or planned_ir + deck_ir = self._read_json(deck_ir_path) if deck_ir_path else {} + slide_ir_paths = self._find_slide_irs_in_roots( + search_roots, + [ + "ir/refined/slides", + "ir/final/slides", + "ir/planned/slides", + ], + ) + slide_irs = [self._read_json(path) for path in slide_ir_paths] + slide_irs = [slide for slide in slide_irs if slide] + if isinstance(deck_ir, dict) and slide_irs and not isinstance(deck_ir.get("slides"), list): + deck_ir = dict(deck_ir) + deck_ir["slides"] = slide_irs + token_usage = self._find_first_in_roots(search_roots, ["token_usage.json"]) + slide_count = len(slide_irs) + if not slide_count and isinstance(deck_ir, dict): + slide_count = len(deck_ir.get("slides") or []) + + return { + "pptx_path": str(pptx_path), + "pptx_url": _to_outputs_url(str(pptx_path)), + "deck_ir_path": str(deck_ir_path) if deck_ir_path else "", + "deck_ir_url": _to_outputs_url(str(deck_ir_path)) if deck_ir_path else "", + "final_ir_path": str(final_ir) if final_ir else "", + "final_ir_url": _to_outputs_url(str(final_ir)) if final_ir else "", + "planned_ir_path": str(planned_ir) if planned_ir else "", + "planned_ir_url": _to_outputs_url(str(planned_ir)) if planned_ir else "", + "slide_ir_paths": [str(path) for path in slide_ir_paths], + "slide_ir_urls": [_to_outputs_url(str(path)) for path in slide_ir_paths], + "slide_irs": slide_irs, + "token_usage_path": str(token_usage) if token_usage else "", + "token_usage_url": _to_outputs_url(str(token_usage)) if token_usage else "", + "run_root": str(run_root), + "deck_ir": deck_ir, + "slide_count": slide_count, + } + + def _find_first_in_roots(self, roots: List[Path], relative_candidates: List[str]) -> Optional[Path]: + for root in roots: + found = self._find_first(root, relative_candidates) + if found: + return found + return None + + def _find_slide_irs_in_roots(self, roots: List[Path], relative_dirs: List[str]) -> List[Path]: + for root in roots: + for child in sorted(root.iterdir()) if root.exists() else []: + if not child.is_dir(): + continue + for rel in relative_dirs: + slides_dir = child / rel + if slides_dir.exists() and slides_dir.is_dir(): + paths = sorted(slides_dir.glob("slide_*.json")) + if paths: + return paths + return [] + + def _find_first(self, root: Path, relative_candidates: List[str]) -> Optional[Path]: + for child in sorted(root.iterdir()) if root.exists() else []: + if not child.is_dir(): + continue + for rel in relative_candidates: + candidate = child / rel + if candidate.exists() and candidate.is_file(): + return candidate + for rel in relative_candidates: + candidate = root / rel + if candidate.exists() and candidate.is_file(): + return candidate + return None + + def _read_json(self, path: Optional[Path]) -> Dict[str, Any]: + if not path: + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} 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..e506a54 100644 --- a/fastapi_app/services/output_v2_service.py +++ b/fastapi_app/services/output_v2_service.py @@ -4,16 +4,23 @@ import logging import re import shutil +import base64 +import hashlib +import hmac +import urllib.parse +import urllib.request from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from uuid import uuid4 from fastapi import HTTPException +from fastapi.responses import FileResponse import httpx from fastapi_app.config import settings from fastapi_app.notebook_paths import get_notebook_paths +from fastapi_app.services.editable_ppt_service import EditablePPTService from fastapi_app.source_manager import SourceManager from fastapi_app.utils import _from_outputs_url, _to_outputs_url @@ -21,7 +28,7 @@ class OutputV2Service: - SUPPORTED_TYPES = {"ppt", "report", "mindmap", "podcast", "flashcard", "quiz"} + SUPPORTED_TYPES = {"ppt", "editable_ppt", "report", "mindmap", "podcast", "flashcard", "quiz"} PPT_STAGE_OUTLINE = "outline_ready" PPT_STAGE_PAGES = "pages_ready" PPT_STAGE_GENERATED = "generated" @@ -475,6 +482,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 +564,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, @@ -818,6 +827,8 @@ def _remap_legacy_pipeline_dir(self, pipeline_dir: Optional[Path], base_dir: Pat def _hydrate_ppt_item_from_disk( self, item: Dict[str, Any], base_dir: Optional[Path] = None ) -> tuple[Dict[str, Any], bool]: + if item.get("target_type") == "editable_ppt": + return self._hydrate_editable_ppt_item_from_disk(item) if item.get("target_type") != "ppt": return item, False @@ -879,6 +890,60 @@ def _hydrate_ppt_item_from_disk( return item, changed + def _hydrate_editable_ppt_item_from_disk(self, item: Dict[str, Any]) -> tuple[Dict[str, Any], bool]: + result = item.get("result") + if not isinstance(result, dict): + return item, False + output_dir_raw = str(item.get("result_path") or "").strip() + output_dir = Path(output_dir_raw) if output_dir_raw else None + if output_dir is None or not output_dir.exists(): + return item, False + + pptx_path = output_dir / "editable.pptx" + if not pptx_path.exists(): + try: + resolved = self._resolve_editable_pptx_path(item) + except HTTPException: + resolved = pptx_path + if resolved.exists(): + output_dir = resolved.parent + pptx_path = resolved + if not pptx_path.exists(): + return item, False + + needs_ir = not result.get("deck_ir_path") or not result.get("deck_ir") or int(result.get("slide_count") or 0) == 0 + needs_slides = not result.get("slide_irs") or not result.get("slide_ir_paths") + if not needs_ir and not needs_slides: + return item, False + + run_root = output_dir / "editable_ppt_run" + try: + discovered = EditablePPTService().discover_artifacts(output_dir=output_dir, run_root=run_root) + except Exception as exc: + log.warning( + "[outputs_v2] editable PPT artifact hydration failed output_id=%s error=%s", + item.get("id"), + exc, + ) + return item, False + + next_result = dict(result) + for key, value in discovered.items(): + if key in {"editable_ppt_options", "log_path", "log_url"}: + continue + if key == "slide_count" and int(next_result.get(key) or 0) == 0: + pass + elif next_result.get(key) not in (None, "", [], {}): + continue + if value not in (None, "", [], {}): + next_result[key] = value + next_result["download_url"] = next_result.get("pptx_url") or next_result.get("download_url") or "" + + if next_result == result: + return item, False + item["result"] = next_result + return item, True + def _read_json_file(self, path: Path) -> Dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) @@ -1634,11 +1699,12 @@ 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") - if target_type != "ppt" and not document_id: + if target_type not in {"ppt", "editable_ppt"} and not document_id: raise HTTPException(status_code=400, detail="document_id is required") document = self._maybe_load_document( @@ -1701,6 +1767,28 @@ async def create_outline( stage = self.PPT_STAGE_OUTLINE result_payload: Dict[str, Any] = {} result_path = str(ppt_payload["result_path"]) + elif target_type == "editable_ppt": + outline = [] + stage = "ready" + result_payload = { + "mode_defaults": { + "react_enabled": True, + "react_iterations": 3, + "model_profile": "general", + "coder_mode": "library", + "language": "chinese", + "complexity": "balanced", + "target_slides": normalized_page_count, + }, + "editable_ppt_options": { + "model_profile": "general", + "coder_mode": "library", + "language": "chinese", + "complexity": "balanced", + "target_slides": normalized_page_count, + }, + } + result_path = str(output_dir) else: outline = self._fallback_outline( target_type=target_type, @@ -1737,6 +1825,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, @@ -1810,6 +1899,334 @@ def save_outline( self._write_manifest(manifest_path, manifest) return item + def save_editable_ppt_ir( + self, + *, + notebook_id: str, + notebook_title: str, + user_id: str, + output_id: str, + deck_ir: Dict[str, Any], + ) -> Dict[str, Any]: + manifest_path = self._manifest_path(notebook_id, notebook_title, user_id) + manifest = self._read_manifest(manifest_path) + index, item = self._find_output(manifest, output_id) + if item.get("target_type") != "editable_ppt": + raise HTTPException(status_code=400, detail="Only editable_ppt outputs support IR editing") + if not isinstance(deck_ir, dict): + raise HTTPException(status_code=400, detail="deck_ir must be an object") + + result = dict(item.get("result") or {}) + result["editable_ir"] = deck_ir + item["result"] = result + item["updated_at"] = self._now() + manifest[index] = item + self._write_manifest(manifest_path, manifest) + self._write_json( + self._item_dir(notebook_id, notebook_title, user_id, output_id) / "editable_ir.json", + deck_ir, + ) + return item + + def get_onlyoffice_config( + self, + *, + notebook_id: str, + notebook_title: str, + user_id: str, + output_id: str, + request_base_url: str, + browser_base_url: str = "", + editor_session_id: str = "", + ) -> Dict[str, Any]: + document_server_url = str(settings.ONLYOFFICE_DOCUMENT_SERVER_URL or "").strip().rstrip("/") + if not document_server_url: + return {"enabled": False, "reason": "ONLYOFFICE_DOCUMENT_SERVER_URL is not configured"} + + item = self.get_output( + notebook_id=notebook_id, + notebook_title=notebook_title, + user_id=user_id, + output_id=output_id, + ) + if item.get("target_type") != "editable_ppt": + raise HTTPException(status_code=400, detail="Only editable_ppt outputs support ONLYOFFICE editing") + + pptx_path = self._resolve_editable_pptx_path(item) + if not pptx_path.exists(): + raise HTTPException(status_code=404, detail="Editable PPTX not found") + + callback_base_url = str(settings.ONLYOFFICE_THINKFLOW_PUBLIC_URL or request_base_url or "").strip().rstrip("/") + document_base_url = str( + browser_base_url + or settings.ONLYOFFICE_DOCUMENT_DOWNLOAD_BASE_URL + or callback_base_url + or "" + ).strip().rstrip("/") + if not callback_base_url or not document_base_url: + raise HTTPException(status_code=500, detail="ThinkFlow public URL is required for ONLYOFFICE") + + editor_session_id = str(editor_session_id or "").strip() + query = urllib.parse.urlencode( + { + "notebook_id": notebook_id, + "notebook_title": notebook_title, + "user_id": user_id, + "document_base_url": document_base_url, + "editor_session_id": editor_session_id, + } + ) + document_key = self._onlyoffice_document_key( + pptx_path, + output_id=output_id, + item=item, + document_base_url=document_base_url, + editor_session_id=editor_session_id, + ) + document_url = self._onlyoffice_document_download_url( + output_id=output_id, + document_key=document_key, + base_url=document_base_url, + notebook_id=notebook_id, + notebook_title=notebook_title, + user_id=user_id, + editor_session_id=editor_session_id, + ) + + config = { + "documentType": "slide", + "type": "desktop", + "width": "100%", + "height": "100%", + "document": { + "fileType": "pptx", + "key": document_key, + "title": f"{item.get('title') or 'editable'}.pptx", + "url": document_url, + "permissions": { + "edit": True, + "download": True, + "print": True, + }, + }, + "editorConfig": { + "mode": "edit", + "lang": "zh-CN", + "callbackUrl": f"{callback_base_url}/api/v1/kb/outputs/{output_id}/onlyoffice/callback?{query}", + "user": { + "id": user_id or "local", + "name": user_id or "local", + }, + "customization": { + "forcesave": True, + "autosave": True, + }, + }, + } + token = self._onlyoffice_jwt(config) + if token: + config["token"] = token + return { + "enabled": True, + "document_server_url": document_server_url, + "script_url": f"{document_server_url}/web-apps/apps/api/documents/api.js", + "config": config, + } + + def get_onlyoffice_document_response( + self, + *, + notebook_id: str, + notebook_title: str, + user_id: str, + output_id: str, + document_key: str, + document_base_url: str = "", + editor_session_id: str = "", + method: str = "GET", + ) -> FileResponse: + item = self.get_output( + notebook_id=notebook_id, + notebook_title=notebook_title, + user_id=user_id, + output_id=output_id, + ) + if item.get("target_type") != "editable_ppt": + raise HTTPException(status_code=400, detail="Only editable_ppt outputs support ONLYOFFICE editing") + pptx_path = self._resolve_editable_pptx_path(item) + if not pptx_path.exists(): + raise HTTPException(status_code=404, detail="Editable PPTX not found") + expected_key = self._onlyoffice_document_key( + pptx_path, + output_id=output_id, + item=item, + document_base_url=str( + document_base_url + or settings.ONLYOFFICE_DOCUMENT_DOWNLOAD_BASE_URL + or "" + ).strip().rstrip("/"), + editor_session_id=str(editor_session_id or "").strip(), + ) + if document_key != expected_key: + raise HTTPException(status_code=404, detail="Editable PPTX version not found") + return FileResponse(path=str(pptx_path), filename=pptx_path.name, method=method) + + def handle_onlyoffice_callback( + self, + *, + notebook_id: str, + notebook_title: str, + user_id: str, + output_id: str, + payload: Dict[str, Any], + document_base_url: str = "", + editor_session_id: str = "", + ) -> Dict[str, int]: + status = int(payload.get("status") or 0) + if status not in {2, 3, 6, 7}: + return {"error": 0} + download_url = str(payload.get("url") or "").strip() + if not download_url: + return {"error": 1} + + manifest_path = self._manifest_path(notebook_id, notebook_title, user_id) + manifest = self._read_manifest(manifest_path) + index, item = self._find_output(manifest, output_id) + if item.get("target_type") != "editable_ppt": + return {"error": 1} + + pptx_path = self._resolve_editable_pptx_path(item) + expected_key = self._onlyoffice_document_key( + pptx_path, + output_id=output_id, + item=item, + document_base_url=str(document_base_url or "").strip().rstrip("/"), + editor_session_id=str(editor_session_id or "").strip(), + ) + if str(payload.get("key") or "") != expected_key: + return {"error": 1} + + pptx_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = pptx_path.with_suffix(".onlyoffice.tmp") + try: + with urllib.request.urlopen(download_url, timeout=60) as response: + tmp_path.write_bytes(response.read()) + tmp_path.replace(pptx_path) + except Exception as exc: + log.warning("[outputs_v2] ONLYOFFICE save failed output_id=%s error=%s", output_id, exc) + try: + tmp_path.unlink(missing_ok=True) + except Exception: + pass + return {"error": 1} + + result = dict(item.get("result") or {}) + result["pptx_path"] = str(pptx_path) + result["pptx_url"] = _to_outputs_url(str(pptx_path)) + result["download_url"] = result["pptx_url"] + result["onlyoffice_saved_at"] = self._now() + item["result"] = result + item["updated_at"] = self._now() + manifest[index] = item + self._write_manifest(manifest_path, manifest) + return {"error": 0} + + def _resolve_editable_pptx_path(self, item: Dict[str, Any]) -> Path: + result = item.get("result") or {} + for key in ("pptx_path", "pptx_url", "download_url"): + value = str(result.get(key) or "").strip() + if not value: + continue + candidate = Path(_from_outputs_url(value)) + if candidate.suffix.lower() == ".pptx": + return candidate + result_path = Path(str(item.get("result_path") or "")) + if result_path: + return result_path / "editable.pptx" + raise HTTPException(status_code=404, detail="Editable PPTX not found") + + def _onlyoffice_document_url(self, item: Dict[str, Any], pptx_path: Path, public_base_url: str) -> str: + result = item.get("result") or {} + for key in ("pptx_url", "download_url"): + value = str(result.get(key) or "").strip() + if not value: + continue + if value.startswith(("http://", "https://")): + parsed = urllib.parse.urlparse(value) + if parsed.path.startswith("/outputs/"): + return f"{public_base_url}{parsed.path}" + return value + if value.startswith("/outputs/"): + return f"{public_base_url}{value}" + return _to_outputs_url(str(pptx_path), base_url=public_base_url) + + def _onlyoffice_document_download_url( + self, + *, + output_id: str, + document_key: str, + base_url: str, + notebook_id: str, + notebook_title: str, + user_id: str, + editor_session_id: str = "", + ) -> str: + query_items = { + "notebook_id": notebook_id, + "notebook_title": notebook_title, + "user_id": user_id, + "document_base_url": base_url, + } + if editor_session_id: + query_items["editor_session_id"] = editor_session_id + query = urllib.parse.urlencode(query_items) + return ( + f"{base_url}/api/v1/kb/outputs/{output_id}/onlyoffice/download/" + f"{document_key}.pptx?{query}" + ) + + def _onlyoffice_document_key( + self, + pptx_path: Path, + *, + output_id: str, + item: Dict[str, Any], + document_base_url: str = "", + editor_session_id: str = "", + ) -> str: + stat = pptx_path.stat() + raw = ( + "thinkflow-onlyoffice-v6:" + f"{output_id}:" + f"{item.get('updated_at') or ''}:" + f"{document_base_url}:" + f"{editor_session_id}:" + f"{pptx_path.resolve()}:{stat.st_mtime_ns}:{stat.st_size}" + ) + return hashlib.sha1(raw.encode("utf-8")).hexdigest() + + def _onlyoffice_cache_busted_url(self, url: str, document_key: str) -> str: + parsed = urllib.parse.urlparse(url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query.append(("oo_key", document_key[:12])) + return urllib.parse.urlunparse( + parsed._replace(query=urllib.parse.urlencode(query)) + ) + + def _onlyoffice_jwt(self, payload: Dict[str, Any]) -> str: + secret = str(settings.ONLYOFFICE_JWT_SECRET or "").strip() + if not secret: + return "" + header = {"alg": "HS256", "typ": "JWT"} + + def encode(data: Dict[str, Any]) -> bytes: + raw = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).rstrip(b"=") + + signing_input = encode(header) + b"." + encode(payload) + signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest() + return (signing_input + b"." + base64.urlsafe_b64encode(signature).rstrip(b"=")).decode("ascii") + async def refine_outline( self, *, @@ -1948,9 +2365,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 +2408,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 +2418,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( @@ -2085,6 +2511,7 @@ async def generate_output( api_url: Optional[str], api_key: Optional[str], model: Optional[str], + editable_ppt_options: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: manifest_path = self._manifest_path(notebook_id, notebook_title, user_id) manifest = self._read_manifest(manifest_path) @@ -2131,6 +2558,19 @@ async def generate_output( ) item["pipeline_stage"] = self.PPT_STAGE_PAGES item["status"] = self.PPT_STAGE_PAGES + elif item["target_type"] == "editable_ppt": + result = EditablePPTService().run_from_output( + item=item, + document=document, + guidance_text=guidance_text, + output_dir=output_dir, + api_url=api_url, + api_key=api_key, + model=model, + options=editable_ppt_options, + ) + item["pipeline_stage"] = "generated" + item["status"] = "generated" elif item["target_type"] == "report": result = await self._generate_report(output_dir=output_dir, item=item, document=document, guidance_text=guidance_text) item["status"] = "generated" @@ -2151,6 +2591,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 +2871,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/public/online-editor-frame.html b/frontend_zh/public/online-editor-frame.html new file mode 100644 index 0000000..aa367ce --- /dev/null +++ b/frontend_zh/public/online-editor-frame.html @@ -0,0 +1,164 @@ + + + + + + + + +
正在加载在线编辑器...
+
+ + + 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/ThinkFlowLeftSidebar.tsx b/frontend_zh/src/components/ThinkFlowLeftSidebar.tsx index e3a4dd2..d4c5d85 100644 --- a/frontend_zh/src/components/ThinkFlowLeftSidebar.tsx +++ b/frontend_zh/src/components/ThinkFlowLeftSidebar.tsx @@ -3,13 +3,15 @@ import { Eye, FolderOpen, Loader2, Package, Plus, RefreshCw, Trash2, Upload } fr import type { KnowledgeFile } from '../types'; -type OutputType = 'ppt' | 'report' | 'mindmap' | 'podcast' | 'flashcard' | 'quiz'; +type OutputType = 'ppt' | 'editable_ppt' | 'report' | 'mindmap' | 'podcast' | 'flashcard' | 'quiz'; type SidebarOutput = { id: string; title: string; target_type: OutputType; outline?: Array; + page_count?: number; + result?: Record; updated_at: string; }; @@ -35,6 +37,23 @@ type Props = { onAddSource: () => void; }; +function getOutputItemCount(output: SidebarOutput): number { + if (output.target_type === 'editable_ppt') { + const slideCount = output.result?.slide_count; + if (typeof slideCount === 'number' && slideCount > 0) { + return slideCount; + } + const slideIrs = output.result?.slide_irs; + if (Array.isArray(slideIrs) && slideIrs.length > 0) { + return slideIrs.length; + } + } + if (typeof output.page_count === 'number' && output.page_count > 0) { + return output.page_count; + } + return output.outline?.length || 0; +} + function statusLabel(file: KnowledgeFile) { if (file.vectorStatus === 'embedded' || file.vectorReady || file.isEmbedded) { return '已解析'; @@ -251,7 +270,7 @@ export function ThinkFlowLeftSidebar({ >
{getOutputEmoji(output.target_type)}
-
{output.outline?.length || 0} 项
+
{getOutputItemCount(output)} 项
{output.title}
diff --git a/frontend_zh/src/components/ThinkFlowWorkspace.css b/frontend_zh/src/components/ThinkFlowWorkspace.css index eb9b64c..2a55205 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,542 @@ 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) */ +.thinkflow-editable-ppt-workspace { + gap: 14px; +} + +.thinkflow-editable-ppt-toolbar { + display: grid; + grid-template-columns: repeat(5, minmax(120px, 1fr)) auto; + gap: 10px; + align-items: end; +} + +.thinkflow-editable-ppt-control { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.thinkflow-editable-ppt-control span { + font-size: 12px; + color: var(--tf-muted); +} + +.thinkflow-editable-ppt-control select, +.thinkflow-editable-ppt-control input { + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 9px 10px; + background: #fff; + color: var(--tf-text); +} + +.thinkflow-editable-ppt-main { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(260px, 0.9fr); + gap: 12px; +} + +.thinkflow-editable-ppt-panel, +.thinkflow-editable-ppt-slide-card { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgba(255, 255, 255, 0.94); + padding: 14px; +} + +.thinkflow-editable-ppt-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.thinkflow-editable-ppt-panel-head h4 { + margin: 2px 0 0; + font-size: 16px; +} + +.thinkflow-editable-ppt-form, +.thinkflow-editable-ppt-links, +.thinkflow-editable-ppt-slide-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.thinkflow-editable-ppt-links { + align-items: flex-start; +} + +.thinkflow-onlyoffice-open-btn { + border: 0; + padding: 0; + background: transparent; + font: inherit; + cursor: pointer; +} + +.thinkflow-onlyoffice-open-btn:disabled { + cursor: wait; + opacity: 0.65; +} + +.thinkflow-editable-ppt-slides { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 12px; +} + +.thinkflow-onlyoffice-modal-overlay { + position: fixed; + inset: 0; + z-index: 120; + background: rgba(15, 23, 42, 0.46); + backdrop-filter: blur(5px); +} + +.thinkflow-onlyoffice-modal { + position: fixed; + z-index: 121; + left: 50%; + top: 50%; + width: min(1320px, calc(100vw - 56px)); + height: min(860px, calc(100vh - 56px)); + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 18px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.34); +} + +.thinkflow-onlyoffice-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(255, 255, 255, 0.92); +} + +.thinkflow-onlyoffice-modal-header h3 { + margin: 3px 0 0; + font-size: 17px; + color: #0f172a; +} + +.thinkflow-onlyoffice-modal-close { + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 999px; + background: #ffffff; + color: #334155; + cursor: pointer; + font: inherit; + padding: 7px 14px; +} + +.thinkflow-onlyoffice-modal-close:hover { + border-color: rgba(37, 99, 235, 0.38); + color: #1d4ed8; +} + +.thinkflow-onlyoffice-modal-body { + flex: 1; + min-height: 0; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.thinkflow-onlyoffice-error { + flex: 0 0 auto; + max-height: 96px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + border: 1px solid rgba(239, 68, 68, 0.26); + border-radius: 8px; + background: #fff7f7; + color: #991b1b; + font-size: 13px; + line-height: 1.45; + padding: 8px 10px; +} + +.thinkflow-onlyoffice-editor { + display: block; + flex: 1; + width: 100%; + height: 100%; + min-height: 520px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 12px; + background: #ffffff; +} + +@media (max-width: 980px) { + .thinkflow-editable-ppt-toolbar, + .thinkflow-editable-ppt-main { + grid-template-columns: 1fr; + } + + .thinkflow-onlyoffice-modal { + width: calc(100vw - 18px); + height: calc(100vh - 18px); + border-radius: 12px; + } + + .thinkflow-onlyoffice-modal-header { + padding: 12px; + } + + .thinkflow-onlyoffice-modal-body { + padding: 8px; + } +} diff --git a/frontend_zh/src/components/ThinkFlowWorkspace.tsx b/frontend_zh/src/components/ThinkFlowWorkspace.tsx index 14cf05d..fb22154 100644 --- a/frontend_zh/src/components/ThinkFlowWorkspace.tsx +++ b/frontend_zh/src/components/ThinkFlowWorkspace.tsx @@ -44,6 +44,7 @@ import './ThinkFlowWorkspace.css'; const DEFAULT_USER = { id: 'local', email: '' }; const PANEL_GUIDE_STORAGE_KEY = 'thinkflow_panel_guides_v1'; +const ONLINE_EDITOR_FRAME_VERSION = 'v4'; type Notebook = { id: string; @@ -69,6 +70,7 @@ type CitationReference = { filePath?: string; preview?: string; chunkIndex?: number | null; + sourceNumber?: string; }; type ThinkFlowDocument = { @@ -152,7 +154,14 @@ type ThinkFlowWorkspaceItem = { updated_at: string; }; -type OutputType = 'ppt' | 'report' | 'mindmap' | 'podcast' | 'flashcard' | 'quiz'; +type OnlyOfficeEditorPayload = { + enabled: boolean; + reason?: string; + script_url?: string; + config?: Record; +}; + +type OutputType = 'ppt' | 'editable_ppt' | 'report' | 'mindmap' | 'podcast' | 'flashcard' | 'quiz'; type ThinkFlowOutput = { id: string; @@ -175,6 +184,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 +205,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 +298,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; }; @@ -330,6 +360,7 @@ const outputButtons: Array<{ icon: React.ReactNode; }> = [ { type: 'ppt', label: 'PPT', icon: }, + { type: 'editable_ppt', label: '可编辑PPT', icon: }, { type: 'report', label: '报告', icon: }, { type: 'mindmap', label: '导图', icon: }, { type: 'podcast', label: '播客', icon: }, @@ -376,6 +407,8 @@ function outputEmoji(type: OutputType) { switch (type) { case 'ppt': return '📊'; + case 'editable_ppt': + return '✦'; case 'report': return '📝'; case 'mindmap': @@ -395,6 +428,10 @@ function outputLabel(type: OutputType) { return outputButtons.find((item) => item.type === type)?.label || type; } +function isPptLikeOutput(type: OutputType) { + return type === 'ppt' || type === 'editable_ppt'; +} + function normalizePptStage(output: ThinkFlowOutput | null): PptPipelineStage { if (!output) return 'outline_ready'; if (output.pipeline_stage === 'generated' || output.status === 'generated') return 'generated'; @@ -735,9 +772,31 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: const [pptPagePrompt, setPptPagePrompt] = useState(''); const [pptPageBusyAction, setPptPageBusyAction] = useState<'regenerate' | 'confirm' | 'select_version' | ''>(''); const [pptPageStatus, setPptPageStatus] = useState(''); + const [editablePptModelProfile, setEditablePptModelProfile] = useState<'general' | 'claude' | 'qwen'>('general'); + const [editablePptCoderMode, setEditablePptCoderMode] = useState<'library' | 'direct'>('library'); + const [editablePptLanguage, setEditablePptLanguage] = useState<'chinese' | 'english'>('chinese'); + const [editablePptComplexity, setEditablePptComplexity] = useState<'simple' | 'balanced' | 'complex'>('balanced'); + const [editablePptTargetSlides, setEditablePptTargetSlides] = useState('8'); + const [editablePptSavingIr, setEditablePptSavingIr] = useState(false); + const [onlyOfficeLoading, setOnlyOfficeLoading] = useState(false); + const [onlyOfficeError, setOnlyOfficeError] = useState(''); + const [onlyOfficeConfig, setOnlyOfficeConfig] = useState(null); + const [onlyOfficeModalOpen, setOnlyOfficeModalOpen] = useState(false); + const [onlyOfficeSessionId, setOnlyOfficeSessionId] = useState(''); + const onlyOfficeFrameRef = useRef(null); 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([ { @@ -1307,6 +1366,46 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: ensureOutputContext(activeOutput); }, [activeOutput]); + useEffect(() => { + setOnlyOfficeError(''); + setOnlyOfficeModalOpen(false); + setOnlyOfficeConfig(null); + setOnlyOfficeSessionId(''); + }, [activeOutput?.id]); + + const postOnlyOfficeFrameConfig = useCallback(() => { + if (!onlyOfficeSessionId || !onlyOfficeConfig?.enabled) return; + const targetWindow = onlyOfficeFrameRef.current?.contentWindow; + if (!targetWindow) return; + targetWindow.postMessage( + { + source: 'thinkflow-online-editor-init', + sessionId: onlyOfficeSessionId, + payload: onlyOfficeConfig, + }, + window.location.origin, + ); + }, [onlyOfficeConfig, onlyOfficeSessionId]); + + useEffect(() => { + if (!onlyOfficeSessionId) return; + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const data = event.data || {}; + if (data.source !== 'thinkflow-online-editor') return; + if (data.type === 'frameReady') { + postOnlyOfficeFrameConfig(); + return; + } + if (data.sessionId !== onlyOfficeSessionId) return; + if (data.type === 'error') { + setOnlyOfficeError(data.payload?.message || '在线编辑器加载失败'); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [onlyOfficeSessionId, postOnlyOfficeFrameConfig]); + useEffect(() => { if (!activeOutput || activeOutput.target_type !== 'ppt') return; const slideCount = activeOutput.outline?.length || 0; @@ -1412,6 +1511,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)); @@ -1566,6 +1681,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: deferSourceDerivedDocument?: boolean; }, ) => { + const isPptLike = isPptLikeOutput(targetType); const overrideGuidanceIds = options?.guidanceItemIdsOverride; const overrideBoundDocIds = options?.boundDocumentIdsOverride; const overrideSourceIds = options?.sourceIdsOverride; @@ -1595,7 +1711,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: let outputDocumentId = options?.documentIdOverride ?? activeDocumentId ?? - (targetType === 'ppt' ? activeOutput?.document_id || '' : ''); + (isPptLike ? activeOutput?.document_id || '' : ''); let outputDocumentTitle = documentTitle || activeDocument?.title || '文档'; let outputDocumentContent = documentContent; if (outputDocumentId && outputDocumentId !== activeDocumentId) { @@ -1605,11 +1721,11 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: outputDocumentContent = ensuredDocument.content || outputDocumentContent; } } - if (targetType === 'ppt' && (!outputDocumentTitle || outputDocumentTitle === '文档')) { - outputDocumentTitle = resolvedSourceNames[0] || notebookTitle || 'PPT'; + if (isPptLike && (!outputDocumentTitle || outputDocumentTitle === '文档')) { + outputDocumentTitle = resolvedSourceNames[0] || notebookTitle || outputLabel(targetType); } if ( - targetType !== 'ppt' && + !isPptLike && options?.deferSourceDerivedDocument && (!outputDocumentId || !String(outputDocumentContent || '').trim()) ) { @@ -1620,7 +1736,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: outputDocumentContent = outputDocumentContent || ''; } if ( - targetType !== 'ppt' && + !isPptLike && !options?.deferSourceDerivedDocument && (!outputDocumentId || !String(outputDocumentContent || '').trim()) ) { @@ -1630,20 +1746,20 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: outputDocumentContent = generatedDocument.content; } if ( - targetType !== 'ppt' && + !isPptLike && !options?.deferSourceDerivedDocument && (!outputDocumentId || !String(outputDocumentContent || '').trim()) ) { throw new Error('没有可用于产出的内容,请先选择来源或准备梳理文档。'); } if ( - targetType === 'ppt' && + isPptLike && resolvedSourcePaths.length === 0 && !String(outputDocumentContent || '').trim() && resolvedBoundDocIds.length === 0 && resolvedGuidanceIds.length === 0 ) { - throw new Error('PPT 生成至少需要一个来源,或可用的梳理文档 / 参考文档 / 产出指导。'); + throw new Error(`${outputLabel(targetType)}生成至少需要一个来源,或可用的梳理文档 / 参考文档 / 产出指导。`); } const resolvedGuidanceTitles = guidanceItems @@ -1736,6 +1852,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 +1874,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: sourceIds: [], sourcePaths: [], sourceNames: [], + flashcardConfig: nextFlashcardConfig, loading: true, errorMessage: '', }); @@ -1793,7 +1919,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: const intent = directOutputIntent; setDirectOutputIntent(null); await createOutline(intent.targetType, { - autoGenerate: true, + autoGenerate: intent.targetType !== 'editable_ppt', titleOverride: intent.outputTitle, documentIdOverride: intent.outputDocumentId, guidanceItemIdsOverride: intent.guidanceItemIds, @@ -1801,7 +1927,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) => { @@ -1812,7 +1942,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: setActiveOutputId(output.id); setLeftTab('outputs'); ensureOutputContext(output); - enterOutputWorkspace(output.target_type === 'ppt' ? 'output_focus' : 'output_immersive'); + enterOutputWorkspace(isPptLikeOutput(output.target_type) ? 'output_focus' : 'output_immersive'); if (output.document_id) { setActiveDocumentId(output.document_id); try { @@ -1861,7 +1991,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 +2000,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 +2027,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 +3036,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(''); @@ -2909,7 +3051,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: setActivePptSlideIndex(0); setLeftTab('outputs'); setRightMode('outline'); - enterOutputWorkspace(targetType === 'ppt' ? 'output_focus' : 'output_immersive'); + enterOutputWorkspace(isPptLikeOutput(targetType) ? 'output_focus' : 'output_immersive'); try { const { outputDocumentId, @@ -2929,12 +3071,18 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: target_type: targetType, title: outputTitle, prompt: '', - page_count: targetType === 'ppt' ? 10 : 6, + page_count: + targetType === 'ppt' + ? 10 + : targetType === 'editable_ppt' + ? Math.max(1, Math.min(20, Number(editablePptTargetSlides) || 8)) + : 6, guidance_item_ids: resolvedGuidanceIds, source_paths: resolvedSourcePaths, source_names: resolvedSourceNames, bound_document_ids: resolvedBoundDocIds, - enable_images: targetType === 'ppt' ? true : undefined, + enable_images: isPptLikeOutput(targetType) ? 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', { @@ -3369,6 +3517,16 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: notebook_title: notebookTitle, user_id: effectiveUser?.id || 'local', email: effectiveUser?.email || '', + editable_ppt_options: + outputs.find((item) => item.id === outputId)?.target_type === 'editable_ppt' + ? { + model_profile: editablePptModelProfile, + coder_mode: editablePptCoderMode, + language: editablePptLanguage, + complexity: editablePptComplexity, + target_slides: Math.max(1, Math.min(20, Number(editablePptTargetSlides) || 8)), + } + : undefined, }), }); await parseJson<{ output: ThinkFlowOutput }>(response); @@ -3624,6 +3782,7 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: const renderDirectOutputWorkspace = () => { if (!activeOutput || activeOutput.target_type === 'ppt') return null; + if (activeOutput.target_type === 'editable_ppt') return renderEditablePptWorkspace(); const result = activeOutput.result || {}; const downloadUrl = result.download_url || result.pdf_path || result.previewUrl || result.preview_url || result.audio_path || ''; return ( @@ -3656,6 +3815,294 @@ const ThinkFlowWorkspace = ({ notebook, onBack }: { notebook: Notebook; onBack: ); }; + const resolveEditablePptIr = (output: ThinkFlowOutput | null) => { + const result = output?.result || {}; + const deck = result.editable_ir || result.deck_ir || {}; + if (!deck || typeof deck !== 'object') return {}; + if (Array.isArray((deck as any).slides)) return deck; + if (Array.isArray(result.slide_irs) && result.slide_irs.length > 0) { + return { ...deck, slides: result.slide_irs }; + } + if (Array.isArray((deck as any).deck_outline)) { + return { ...deck, slides: (deck as any).deck_outline }; + } + return deck; + }; + + const updateEditablePptIr = (patch: Record) => { + if (!activeOutput || activeOutput.target_type !== 'editable_ppt') return; + const currentIr = resolveEditablePptIr(activeOutput); + const nextIr = { ...currentIr, ...patch }; + setOutputs((previous) => + previous.map((item) => + item.id === activeOutput.id + ? { + ...item, + result: { + ...(item.result || {}), + editable_ir: nextIr, + }, + } + : item, + ), + ); + }; + + const updateEditablePptSlide = (slideIndex: number, patch: Record) => { + if (!activeOutput || activeOutput.target_type !== 'editable_ppt') return; + const currentIr = resolveEditablePptIr(activeOutput); + const slides = Array.isArray(currentIr.slides) ? [...currentIr.slides] : []; + slides[slideIndex] = { ...(slides[slideIndex] || {}), ...patch }; + updateEditablePptIr({ slides }); + }; + + const saveEditablePptIr = async () => { + if (!activeOutput || activeOutput.target_type !== 'editable_ppt') return; + const deckIr = resolveEditablePptIr(activeOutput); + setEditablePptSavingIr(true); + try { + const response = await apiFetch(`/api/v1/kb/outputs/${activeOutput.id}/editable-ir`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + notebook_id: notebook.id, + notebook_title: notebookTitle, + user_id: effectiveUser?.id || 'local', + email: effectiveUser?.email || '', + deck_ir: deckIr, + }), + }); + const data = await parseJson<{ output: ThinkFlowOutput }>(response); + setOutputs((previous) => previous.map((item) => (item.id === data.output.id ? data.output : item))); + } catch (error: any) { + setGlobalError(error?.message || '保存可编辑 PPT IR 失败'); + } finally { + setEditablePptSavingIr(false); + } + }; + + const openOnlyOfficeEditor = async () => { + if (!activeOutput || activeOutput.target_type !== 'editable_ppt') return; + setOnlyOfficeLoading(true); + setOnlyOfficeError(''); + try { + const editorSessionId = `${activeOutput.id}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const params = new URLSearchParams({ + notebook_id: notebook.id, + notebook_title: notebookTitle, + user_id: effectiveUser?.id || 'local', + email: effectiveUser?.email || '', + browser_base_url: window.location.origin, + editor_session_id: editorSessionId, + }); + const response = await apiFetch(`/api/v1/kb/outputs/${activeOutput.id}/onlyoffice/config?${params.toString()}`); + const payload = await parseJson(response); + if (!payload.enabled) { + setOnlyOfficeConfig(null); + setOnlyOfficeModalOpen(false); + setOnlyOfficeError(payload.reason || '在线编辑服务未配置'); + return; + } + setOnlyOfficeConfig(payload); + setOnlyOfficeError(''); + setOnlyOfficeSessionId(editorSessionId); + setOnlyOfficeModalOpen(true); + } catch (error: any) { + setOnlyOfficeConfig(null); + setOnlyOfficeModalOpen(false); + setOnlyOfficeError(error?.message || '打开在线编辑器失败'); + } finally { + setOnlyOfficeLoading(false); + } + }; + + const renderEditablePptWorkspace = () => { + if (!activeOutput || activeOutput.target_type !== 'editable_ppt') return null; + const result = activeOutput.result || {}; + const deckIr = resolveEditablePptIr(activeOutput); + const slides = Array.isArray(deckIr.slides) ? deckIr.slides : []; + const slideIrUrls = Array.isArray(result.slide_ir_urls) ? result.slide_ir_urls : []; + const generated = activeOutput.status === 'generated' || Boolean(result.pptx_url || result.pptx_path); + const effectiveCoderMode = editablePptCoderMode; + + return ( +
+
+
+ 模型 + +
+
+ 生成模式 + +
+
+ 语言 + +
+
+ 复杂度 + +
+
+ 页数 + setEditablePptTargetSlides(event.target.value)} inputMode="numeric" /> +
+ +
+ +
+
+
+
+ Deck IR +

{deckIr.title || activeOutput.title}

+
+ {generated ? ( + + ) : null} +
+ {generated ? ( +
+ updateEditablePptIr({ title: event.target.value })} placeholder="Deck 标题" /> + updateEditablePptIr({ subtitle: event.target.value })} placeholder="Deck 副标题" /> +