From 9884ea0ea6b37fdfaacfd312920a0daaf1ce3615 Mon Sep 17 00:00:00 2001 From: liaibo Date: Tue, 6 Jan 2026 05:21:53 +0800 Subject: [PATCH] =?UTF-8?q?0.1.02=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE?= =?UTF-8?q?=E5=85=A8=E5=B1=8F/lightrag=E4=BF=AE=E5=A4=8D/luckysheet?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +- LightRAG/start-lightrag-7777.ps1 | 36 ++ services/ingest_service/app/core/config.py | 9 +- .../ingest_service/app/services/runtime.py | 33 +- services/ingest_service/app/services/tasks.py | 96 ++--- .../ingest_service/app/services/webhooks.py | 38 +- services/rag_gateway/app/core/config.py | 9 +- .../rag_gateway/app/services/rag_service.py | 59 ++- wolai-frontend/.gitignore | 2 +- .../components/editor/blocks/MindmapBlock.tsx | 389 ++++++++++++++++-- .../editor/blocks/MindmapSidebarTrigger.tsx | 79 ++-- wolai-frontend/src/types/online-table.ts | 12 +- 12 files changed, 570 insertions(+), 199 deletions(-) create mode 100644 LightRAG/start-lightrag-7777.ps1 diff --git a/.gitignore b/.gitignore index 61d3c916..6258d17a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,8 +41,5 @@ cankao/ **/test/ pw-tests/** pw-tests/ -wolai-frontend\public\documents -wolai-frontend/public/documents/d7561119-ff9f-44ee-8fa9-003a657d43e6/index.md -wolai-frontend/public/documents/f8d926b4-1351-4d0d-b768-3356f18b0281/index.md -wolai-frontend/public/documents/d7561119-ff9f-44ee-8fa9-003a657d43e6/mindmap.json -wolai-frontend/public/documents/f8d926b4-1351-4d0d-b768-3356f18b0281/mindmap.json +wolai-frontend/public/documents/ +wolai-frontend/public/documents/** \ No newline at end of file diff --git a/LightRAG/start-lightrag-7777.ps1 b/LightRAG/start-lightrag-7777.ps1 new file mode 100644 index 00000000..79899477 --- /dev/null +++ b/LightRAG/start-lightrag-7777.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference = "Stop" + +# 说明: +# 1) LightRAG Server 启动时会在控制台打印 Emoji/彩色字符,Windows 默认编码(GBK)可能导致启动失败。 +# 2) 本脚本强制使用 UTF-8,并确保存在一个到 supabase-db 的端口转发(host:15433 -> supabase-db:5432)。 + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$env:PYTHONUTF8 = "1" +$env:PYTHONIOENCODING = "utf-8" +$env:PYTHONLEGACYWINDOWSSTDIO = "1" + +Set-Location -Path $PSScriptRoot + +function Ensure-SupabaseDbForwarder { + $name = "supabase-db-forward" + $exists = (docker ps -a --format "{{.Names}}" | Where-Object { $_ -eq $name }) -ne $null + + if (-not $exists) { + Write-Host "创建端口转发容器:$name(15433 -> supabase-db:5432)" + docker run -d --name $name --restart unless-stopped --network supabase_default -p 15433:5432 alpine/socat -v TCP-LISTEN:5432,fork,reuseaddr TCP:supabase-db:5432 | Out-Null + } else { + $running = (docker ps --format "{{.Names}}" | Where-Object { $_ -eq $name }) -ne $null + if (-not $running) { + Write-Host "启动端口转发容器:$name" + docker start $name | Out-Null + } + } +} + +Ensure-SupabaseDbForwarder + +Write-Host "启动 LightRAG Server(http://127.0.0.1:7777)" +Write-Host "提示:API-Key 使用 X-API-Key: aimnote-local(可在 .env 中调整 LIGHTRAG_API_KEY)" + +& ".\.venv\Scripts\lightrag-server.exe" + diff --git a/services/ingest_service/app/core/config.py b/services/ingest_service/app/core/config.py index fed90a07..5141e0ff 100644 --- a/services/ingest_service/app/core/config.py +++ b/services/ingest_service/app/core/config.py @@ -1,10 +1,12 @@ import os from functools import lru_cache -from pydantic import BaseSettings, Field +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """FastAPI 配置,统一读取 .env 或系统环境变量""" + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore") app_name: str = Field("AIMNOTE Ingest Service", env="INGEST_APP_NAME") environment: str = Field("development", env="INGEST_ENV") @@ -12,16 +14,13 @@ class Settings(BaseSettings): supabase_url: str = Field("http://127.0.0.1:54321", env="SUPABASE_URL") supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY") lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL") + lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY") ollama_base_url: str = Field("http://127.0.0.1:11434/v1", env="OLLAMA_BASE_URL") ollama_api_key: str = Field("ollama", env="OLLAMA_API_KEY") embeddings_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL") llm_model: str = Field("qwen3:32b", env="DEFAULT_LLM_MODEL") rerank_model: str = Field("qwen3-reranker-4b", env="RERANK_MODEL") - class Config: - env_file = ".env" - case_sensitive = False - @lru_cache() def get_settings() -> Settings: diff --git a/services/ingest_service/app/services/runtime.py b/services/ingest_service/app/services/runtime.py index cc747fa1..1d163f98 100644 --- a/services/ingest_service/app/services/runtime.py +++ b/services/ingest_service/app/services/runtime.py @@ -1,42 +1,13 @@ -import sys -from pathlib import Path - from app.core.config import get_settings -from app.models.job import IngestJob -from app.services.embeddings import EmbeddingGenerator from app.services.job_store import InMemoryJobStore from app.services.scheduler import SchedulerManager from app.services.tasks import IngestWorker from app.services.webhooks import LightRAGWebhook -ROOT = Path(__file__).resolve().parents[3] -sys.path.append(str(ROOT / "packages" / "siyuan_ingest" / "src")) -from siyuan_ingest import SiYuanClient, SupabaseSync, SupabaseWriter # type: ignore # noqa: E402 - settings = get_settings() job_store = InMemoryJobStore() scheduler = SchedulerManager() -siyuan_client = SiYuanClient() webhook = LightRAGWebhook() -embedding_generator = EmbeddingGenerator( - api_base=settings.ollama_base_url, - api_key=settings.ollama_api_key, - model=settings.embeddings_model, -) -supabase_sync = SupabaseSync( - supabase_url=settings.supabase_url + "/rest/v1", - supabase_key=settings.supabase_key, -) -supabase_writer = SupabaseWriter( - supabase_url=settings.supabase_url + "/rest/v1", - supabase_key=settings.supabase_key, -) -worker = IngestWorker( - job_store=job_store, - siyuan_client=siyuan_client, - webhook=webhook, - supabase_sync=supabase_sync, - supabase_writer=supabase_writer, - embedding_generator=embedding_generator, -) +worker = IngestWorker(job_store=job_store, webhook=webhook) + diff --git a/services/ingest_service/app/services/tasks.py b/services/ingest_service/app/services/tasks.py index cdd18e21..99cd1eb2 100644 --- a/services/ingest_service/app/services/tasks.py +++ b/services/ingest_service/app/services/tasks.py @@ -1,49 +1,26 @@ import asyncio import contextlib import logging -import sys -from pathlib import Path -from typing import Dict, List, Optional +from typing import List, Optional from uuid import uuid4 from app.models.job import IngestJob -from app.services.embeddings import EmbeddingGenerator from app.services.job_store import InMemoryJobStore from app.services.webhooks import LightRAGWebhook -ROOT = Path(__file__).resolve().parents[3] -sys.path.append(str(ROOT / "packages" / "siyuan_ingest" / "src")) -from siyuan_ingest import ( # type: ignore # noqa: E402 - IngestState, - SiYuanClient, - SiYuanContentExtractor, - SupabaseSync, - SupabaseWriter, - chunk_blocks, -) - logger = logging.getLogger(__name__) class IngestWorker: - """处理 ingest 队列,串联 SiYuan → Supabase → LightRAG""" + """处理 ingest 队列:将外部文本推送到 LightRAG。 - def __init__( - self, - job_store: InMemoryJobStore, - siyuan_client: SiYuanClient, - webhook: LightRAGWebhook, - supabase_sync: Optional[SupabaseSync] = None, - supabase_writer: Optional[SupabaseWriter] = None, - embedding_generator: Optional[EmbeddingGenerator] = None, - ) -> None: + 说明:历史版本依赖 `siyuan_ingest` 从思源笔记拉取块数据,但该包当前不在仓库内, + 回档后会导致服务无法启动。此处改为“由调用方直接提供 blocks 文本列表”。 + """ + + def __init__(self, job_store: InMemoryJobStore, webhook: LightRAGWebhook) -> None: self.job_store = job_store - self.siyuan_client = siyuan_client - self.extractor = SiYuanContentExtractor(siyuan_client) self.webhook = webhook - self.supabase_sync = supabase_sync - self.supabase_writer = supabase_writer - self.embedding_generator = embedding_generator self.queue: asyncio.Queue[IngestJob] = asyncio.Queue() self._consumer_task: Optional[asyncio.Task[None]] = None @@ -57,13 +34,19 @@ class IngestWorker: with contextlib.suppress(asyncio.CancelledError): await self._consumer_task - async def enqueue(self, notebook_id: Optional[str], doc_id: Optional[str], source: str, blocks: List[str]) -> IngestJob: + async def enqueue( + self, + notebook_id: Optional[str], + doc_id: Optional[str], + source: str, + blocks: List[str], + ) -> IngestJob: job = IngestJob( id=str(uuid4()), notebook_id=notebook_id, doc_id=doc_id, source=source, - blocks=blocks, + blocks=blocks or [], ) await self.job_store.save(job) await self.queue.put(job) @@ -80,12 +63,19 @@ class IngestWorker: try: job.mark_running() await self.job_store.save(job) - blocks = await self.extractor.list_blocks(job.notebook_id or "") - chunks = chunk_blocks(blocks) - logger.info("任务 %s 产生 %s 个 chunk", job.id, len(chunks)) - await self._maybe_embed(chunks) - await self._persist_to_supabase(job, chunks) - await self.webhook.notify_ingest(job.doc_id, [c["block_id"] for c in chunks if c.get("block_id")]) + + texts = [b.strip() for b in job.blocks if b and b.strip()] + if not texts: + raise ValueError("blocks 为空:当前 ingest_service 仅支持由调用方提供文本列表") + + # 约定:doc_id 存在时使用 doc:// 作为 file_source,便于后续统一引用 + file_source = f"doc://{job.doc_id}" if job.doc_id else f"ingest://{job.id}" + merged_text = "\n\n".join(texts) + track_id = await self.webhook.ingest_text(file_source=file_source, text=merged_text) + job.payload["file_source"] = file_source + if track_id: + job.payload["lightrag_track_id"] = track_id + job.mark_done() except Exception as exc: # noqa: BLE001 logger.exception("任务 %s 失败: %s", job.id, exc) @@ -93,33 +83,3 @@ class IngestWorker: finally: await self.job_store.save(job) - async def _persist_to_supabase(self, job: IngestJob, chunks: List[Dict[str, str]]) -> None: - await self._maybe_sync_state(job, chunks) - if not self.supabase_writer: - logger.debug("未配置 SupabaseWriter,跳过落库") - return - await self.supabase_writer.ingest_chunks(job.notebook_id, chunks) - - async def _maybe_sync_state(self, job: IngestJob, chunks: List[Dict[str, str]]) -> None: - if not self.supabase_sync: - return - if not chunks: - return - merged_checksum = chunks[-1]["checksum"] - state = await self.supabase_sync.fetch_state(job.notebook_id, job.doc_id) - if state and not state.should_ingest(merged_checksum): - logger.info("任务 %s 未发生变化,跳过写入 Supabase", job.id) - return - state = state or IngestState(notebook_id=job.notebook_id, doc_id=job.doc_id) - state.update(merged_checksum) - await self.supabase_sync.upsert_state(state) - - async def _maybe_embed(self, chunks: List[Dict[str, str]]) -> None: - if not self.embedding_generator: - return - texts = [c.get("text") or "" for c in chunks] - if not texts: - return - embeddings = await self.embedding_generator.embed_texts(texts) - for chunk, embedding in zip(chunks, embeddings): - chunk["embedding"] = embedding diff --git a/services/ingest_service/app/services/webhooks.py b/services/ingest_service/app/services/webhooks.py index efda840d..a950dd7a 100644 --- a/services/ingest_service/app/services/webhooks.py +++ b/services/ingest_service/app/services/webhooks.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional import httpx @@ -9,18 +9,36 @@ logger = logging.getLogger(__name__) class LightRAGWebhook: - """封装与 LightRAG 的交互,默认调用 /ingest""" + """封装与 LightRAG 的交互。 + + 说明:当前 LightRAG(>=1.4.x) 的“文本入库”接口为 `/documents/text` / `/documents/texts`, + 旧版项目里使用的 `/ingest` 已不存在(回档后代码仍在调用旧接口)。 + """ def __init__(self, base_url: Optional[str] = None): settings = get_settings() self.base_url = base_url or settings.lightrag_url.rstrip("/") + self.api_key = settings.lightrag_api_key - async def notify_ingest(self, doc_id: Optional[str], block_ids: List[str]) -> None: - if not block_ids: - logger.info("无可用 block 推送给 LightRAG") - return - payload = {"doc_id": doc_id, "block_ids": block_ids} - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.post(f"{self.base_url}/ingest", json=payload) + def _headers(self) -> dict: + headers: dict = {} + if self.api_key: + headers["X-API-Key"] = self.api_key + return headers + + async def ingest_text(self, file_source: str, text: str) -> Optional[str]: + """向 LightRAG 提交一段文本,触发后台索引。 + + 返回 track_id(如果服务端返回),便于后续排查。 + """ + if not text.strip(): + logger.info("空文本,跳过推送到 LightRAG(file_source=%s)", file_source) + return None + payload = {"text": text, "file_source": file_source} + async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: + resp = await client.post(f"{self.base_url}/documents/text", json=payload) resp.raise_for_status() - logger.info("LightRAG ingest 完成,返回 %s", resp.status_code) + data = resp.json() + track_id = data.get("track_id") if isinstance(data, dict) else None + logger.info("LightRAG 入库请求已提交(file_source=%s, track_id=%s)", file_source, track_id) + return track_id diff --git a/services/rag_gateway/app/core/config.py b/services/rag_gateway/app/core/config.py index b0401807..3455bacd 100644 --- a/services/rag_gateway/app/core/config.py +++ b/services/rag_gateway/app/core/config.py @@ -1,21 +1,20 @@ from functools import lru_cache -from pydantic import BaseSettings, Field +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore") app_name: str = Field("AIMNOTE RAG Gateway", env="RAG_GATEWAY_APP_NAME") api_prefix: str = "/rag" environment: str = Field("development", env="RAG_GATEWAY_ENV") lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL") + lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY") supabase_rest_url: str = Field("http://127.0.0.1:54321/rest/v1", env="SUPABASE_REST_URL") supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY") embedding_base_url: str = Field("http://127.0.0.1:11434/v1", env="EMBEDDING_BASE_URL") embedding_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL") - class Config: - env_file = ".env" - case_sensitive = False - @lru_cache() def get_gateway_settings() -> Settings: diff --git a/services/rag_gateway/app/services/rag_service.py b/services/rag_gateway/app/services/rag_service.py index 77e4e1c4..6ea81e28 100644 --- a/services/rag_gateway/app/services/rag_service.py +++ b/services/rag_gateway/app/services/rag_service.py @@ -1,37 +1,54 @@ import logging -import sys -from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional + +import httpx from app.core.config import get_gateway_settings -ROOT = Path(__file__).resolve().parents[3] -sys.path.append(str(ROOT / "siyuan-rag-llm-main")) -from utils.rag.rag_knowledge_base import HybridRAGKnowledgeBase # type: ignore # noqa: E402 - logger = logging.getLogger(__name__) class RagService: - """包装 HybridRAGKnowledgeBase,供路由调用""" + """RAG 网关:直接调用 LightRAG 独立服务。 + + 说明:历史版本依赖 `siyuan-rag-llm-main`(回档后目录缺失),导致服务无法启动。 + 当前实现改为通过 HTTP 调用 LightRAG 的官方 API(/query、/query/data 等)。 + """ def __init__(self) -> None: settings = get_gateway_settings() - self.kb = HybridRAGKnowledgeBase( - lightrag_url=settings.lightrag_url, - supabase_rest_url=settings.supabase_rest_url, - supabase_service_key=settings.supabase_key, - embedding_base_url=settings.embedding_base_url, - embedding_model=settings.embedding_model, - ) + self.base_url = settings.lightrag_url.rstrip("/") + self.api_key = settings.lightrag_api_key + + def _headers(self) -> dict: + headers: dict = {} + if self.api_key: + headers["X-API-Key"] = self.api_key + return headers async def query(self, query: str, top_k: int = 8) -> Dict[str, Any]: - return await self.kb.query(query=query, top_k=top_k) + """调用 LightRAG /query,返回生成回答与引用。""" + payload = {"query": query, "top_k": top_k} + async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0, headers=self._headers()) as client: + resp = await client.post("/query", json=payload) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + return {"response": str(data), "references": []} + return data - async def graph(self, query: str) -> Dict[str, Any]: - result = await self.kb.lightrag.graph(query=query) - await self.kb.sync_graph_metadata(result) - return result + async def graph(self, query: str, top_k: int = 60) -> Dict[str, Any]: + """调用 LightRAG /query/data 获取结构化检索结果(实体/关系/分块/引用)。""" + payload = {"query": query, "top_k": top_k, "mode": "mix"} + async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0, headers=self._headers()) as client: + resp = await client.post("/query/data", json=payload) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {"status": "failure", "message": "invalid response", "data": data} async def path(self, source: str, target: str) -> Dict[str, Any]: - return await self.kb.lightrag.path(source=source, target=target) + """路径查询:当前 LightRAG API 未提供等价 /path 接口,先返回明确错误。""" + message = "当前 LightRAG API 未提供 /path 等价接口(rag_gateway:path 暂不可用)" + logger.warning("%s: source=%s target=%s", message, source, target) + return {"status": "not_supported", "message": message, "source": source, "target": target} + diff --git a/wolai-frontend/.gitignore b/wolai-frontend/.gitignore index f957a3da..d44328f3 100644 --- a/wolai-frontend/.gitignore +++ b/wolai-frontend/.gitignore @@ -44,4 +44,4 @@ next-env.d.ts **/test/ pw-tests/** pw-tests/ -wolai-frontend\public\documents +wolai-frontend/public/documents/** diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index eff55715..8b539a3a 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -1,7 +1,15 @@ "use client"; import "simple-mind-map/dist/simpleMindMap.esm.css"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { createReactBlockSpec } from "@blocknote/react"; import type { Block, BlockNoteEditor } from "@blocknote/core"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; @@ -230,6 +238,9 @@ const MindmapBlockView = ({ const fileInputRef = useRef(null); const [mindmap, setMindmap] = useState(null); const mindmapReadyRef = useRef(false); + const mindmapRef = useRef(null); + const hasLocalEditsRef = useRef(false); + const applyingRemoteRef = useRef(false); const [canBack, setCanBack] = useState(false); const [canForward, setCanForward] = useState(false); const [activeNodes, setActiveNodes] = useState([]); @@ -238,6 +249,12 @@ const MindmapBlockView = ({ const [activeSidebar, setActiveSidebar] = useState(null); const [showNoteModal, setShowNoteModal] = useState(false); const [noteContent, setNoteContent] = useState(""); + const [localFullscreen, setLocalFullscreen] = useState(false); + const effectiveFullscreen = fullscreen || localFullscreen; + const wrapperRef = useRef(null); + const hotkeyScopeRef = useRef(false); + const recentNodeDblclickRef = useRef(false); + const [fullscreenApiActive, setFullscreenApiActive] = useState(false); const getRootNode = useCallback((mm?: MindMapInstance | null) => { const instance = mm ?? mindmap; @@ -254,6 +271,11 @@ const MindmapBlockView = ({ [block.props.docId, currentDocumentId], ); + useEffect(() => { + hasLocalEditsRef.current = false; + applyingRemoteRef.current = false; + }, [docId]); + const autosaveKey = useMemo( () => `${STORAGE_PREFIX}${docId || block.id}`, [block.id, docId], @@ -283,10 +305,19 @@ const MindmapBlockView = ({ const payload = await resp.json().catch(() => null); const data = payload?.data; if (!data || cancelled) return; + // 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置” + if (hasLocalEditsRef.current) return; initialDataRef.current = data; if (mindmap) { - mindmap.setData(data); - mindmap.command.clearHistory(); + applyingRemoteRef.current = true; + try { + mindmap.setData(data); + mindmap.command.clearHistory(); + } finally { + window.setTimeout(() => { + applyingRemoteRef.current = false; + }, 0); + } } } catch (error) { console.warn("加载本地/远端思维导图失败", error); @@ -297,6 +328,220 @@ const MindmapBlockView = ({ }; }, [docId, mindmap]); + // 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域 + useEffect(() => { + const onPointerDownCapture = (e: Event) => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + const target = e.target as Node | null; + hotkeyScopeRef.current = !!(target && wrapper.contains(target)); + }; + document.addEventListener("pointerdown", onPointerDownCapture, true); + document.addEventListener("mousedown", onPointerDownCapture, true); + return () => { + document.removeEventListener("pointerdown", onPointerDownCapture, true); + document.removeEventListener("mousedown", onPointerDownCapture, true); + }; + }, []); + + // 标记“节点双击”事件,避免和“画布双击进入全屏”产生冲突 + useEffect(() => { + if (!mindmap) return; + const mark = () => { + recentNodeDblclickRef.current = true; + window.setTimeout(() => { + recentNodeDblclickRef.current = false; + }, 0); + }; + mindmap.on?.("node_dblclick", mark); + return () => { + mindmap.off?.("node_dblclick", mark); + }; + }, [mindmap]); + + const exitLocalFullscreen = useCallback(() => { + setActiveSidebar(null); + if (fullscreen) return; + if (typeof document === "undefined") { + setLocalFullscreen(false); + return; + } + if (document.fullscreenElement) { + document + .exitFullscreen() + .catch(() => { + // ignore + }) + .finally(() => setLocalFullscreen(false)); + return; + } + setLocalFullscreen(false); + }, [fullscreen]); + + const enterLocalFullscreen = useCallback(() => { + if (fullscreen) return; + setLocalFullscreen(true); + setActiveSidebar(null); + if (typeof document === "undefined") return; + if (!document.fullscreenEnabled) return; + if (document.fullscreenElement) return; + // 必须在“用户手势回调”中同步调用,避免 Fullscreen API 被浏览器拒绝 + try { + const target = document.documentElement as any; + const p = target?.requestFullscreen?.(); + if (p && typeof p.catch === "function") { + p.catch(() => { + // ignore:失败则保持 Portal 伪全屏 + }); + } + } catch { + // ignore:失败则保持 Portal 伪全屏 + } + }, [fullscreen]); + + // 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效) + useEffect(() => { + if (!localFullscreen) return; + if (typeof document === "undefined") return; + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + // 进入全屏后尽量把焦点放到思维导图容器上,保证快捷键立即生效 + queueMicrotask(() => { + try { + wrapperRef.current?.focus?.({ preventScroll: true }); + } catch { + // ignore + } + }); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (fullscreen) return; // 外部传入的 fullscreen 不在这里处理 + e.preventDefault(); + e.stopPropagation(); + exitLocalFullscreen(); + }; + + window.addEventListener("keydown", onKeyDown, true); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + document.body.style.overflow = prevOverflow; + }; + }, [localFullscreen, fullscreen, exitLocalFullscreen]); + + // “真全屏”:使用 Fullscreen API 隐藏浏览器/桌面窗口的外层 UI(Electron/Web 都可用) + useEffect(() => { + if (typeof document === "undefined") return; + + const onFsChange = () => { + const active = Boolean(document.fullscreenElement); + setFullscreenApiActive(active); + // 用户按 ESC 退出浏览器全屏时,同步退出沉浸式全屏界面 + if (!active && localFullscreen) { + setLocalFullscreen(false); + } + }; + + document.addEventListener("fullscreenchange", onFsChange); + return () => { + document.removeEventListener("fullscreenchange", onFsChange); + }; + }, [localFullscreen]); + + // 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键) + useLayoutEffect(() => { + const onKeyDownCapture = (e: KeyboardEvent) => { + if (!hotkeyScopeRef.current) return; + const mm = mindmapRef.current; + if (!mm || !mindmapReadyRef.current) return; + + const target = e.target as HTMLElement | null; + const editClasses = (mm as any)?.editNodeClassList as string[] | undefined; + const wrapper = wrapperRef.current; + const isInWrapper = Boolean(wrapper && target && wrapper.contains(target)); + if (target && isInWrapper) { + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + // contenteditable 输入中不拦截:避免破坏节点文本编辑/粘贴 + if ((target as any).isContentEditable) return; + const editableAncestor = target.closest?.('[contenteditable="true"]'); + if (editableAncestor) return; + if (editClasses && target.classList) { + for (const cls of editClasses) { + if (cls && target.classList.contains(cls)) return; + } + } + } + + const isMod = e.ctrlKey || e.metaKey; + const key = e.key; + const lower = key.toLowerCase(); + const stop = () => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + + const pickTargetNode = (inst: MindMapInstance) => { + const renderer = inst.renderer as any; + const active: any[] = renderer?.activeNodeList ?? []; + const last: any[] = renderer?.lastActiveNodeList ?? []; + const root = renderer?.root ?? renderer?.renderTree?._node ?? null; + return (active && active[0]) || (last && last[0]) || root || null; + }; + + if (key === "Enter" && !e.shiftKey && !e.altKey && !isMod) { + stop(); + const inst = ensureActiveBefore(mm); + if (!inst) return; + const node = pickTargetNode(inst); + if (!node) return; + inst.execCommand?.("SET_NODE_ACTIVE", node, true); + inst.execCommand?.("INSERT_NODE", false, [node]); + return; + } + + if (key === "Tab" && !e.shiftKey && !e.altKey && !isMod) { + stop(); + const inst = ensureActiveBefore(mm); + if (!inst) return; + const node = pickTargetNode(inst); + if (!node) return; + inst.execCommand?.("SET_NODE_ACTIVE", node, true); + inst.execCommand?.("INSERT_CHILD_NODE", false, [node]); + return; + } + + if (isMod && lower === "c") { + stop(); + const inst = ensureActiveBefore(mm); + if (!inst) return; + const renderer = inst.renderer as any; + if (typeof renderer?.copy === "function") renderer.copy(); + return; + } + + if (isMod && lower === "v") { + stop(); + const inst = ensureActiveBefore(mm); + if (!inst) return; + const renderer = inst.renderer as any; + const copyData = renderer?.beingCopyData ?? null; + if (copyData) { + inst.execCommand?.("PASTE_NODE", copyData); + return; + } + if (typeof renderer?.paste === "function") renderer.paste(); + } + }; + + window.addEventListener("keydown", onKeyDownCapture, true); + return () => { + window.removeEventListener("keydown", onKeyDownCapture, true); + }; + }, []); + const persistData = useCallback( (data: unknown) => { if (!editor) return; @@ -328,7 +573,7 @@ const MindmapBlockView = ({ const debouncedPersist = useDebouncedCallback((data: unknown) => { persistData(data); - }, 500); + }, 800); const initialSyncDone = useRef(false); useEffect(() => { @@ -559,6 +804,7 @@ const MindmapBlockView = ({ ]), }) as MindMapInstance; createdInstance = instance; + mindmapRef.current = instance; // 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用 //(render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。 mindmapReadyRef.current = true; @@ -629,7 +875,6 @@ const MindmapBlockView = ({ if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root); } mindmapReadyRef.current = true; - console.log("[mindmap] render_end activeLen", renderer?.activeNodeList?.length ?? 0); centerAndFit(); }; @@ -673,6 +918,9 @@ const MindmapBlockView = ({ instance.on?.("painter_start", () => setPainterMode(true)); instance.on?.("painter_end", () => setPainterMode(false)); instance.on?.("data_change", (data: unknown) => { + if (!applyingRemoteRef.current) { + hasLocalEditsRef.current = true; + } debouncedPersist(data); }); const renderer = instance.renderer; @@ -682,7 +930,6 @@ const MindmapBlockView = ({ renderer?.clearActiveNodeList && renderer.clearActiveNodeList(); renderer?.addNodeToActiveList && renderer.addNodeToActiveList(rootNode, true); renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(rootNode); - console.log("[mindmap] init select root", instance.renderer?.activeNodeList?.length ?? 0); setActiveNodes([rootNode]); } // 若首次渲染时 root 仍未就绪,设置兜底延迟激活 @@ -692,7 +939,6 @@ const MindmapBlockView = ({ renderer?.clearActiveNodeList && renderer.clearActiveNodeList(); renderer?.addNodeToActiveList && renderer.addNodeToActiveList(root, true); renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(root); - console.log("[mindmap] delayed select root", instance.renderer?.activeNodeList?.length ?? 0); setActiveNodes([root]); }, 120); @@ -704,6 +950,32 @@ const MindmapBlockView = ({ return () => { destroyed = true; try { + // 切换“内嵌/全屏”会导致实例重建:这里尽量在销毁前同步一次数据,避免丢失最后一次编辑 + if (createdInstance && typeof window !== "undefined") { + const data = + createdInstance.getData?.(true) ?? createdInstance.getData?.(); + if (data) { + try { + window.localStorage.setItem(autosaveKey, JSON.stringify(data)); + } catch { + // ignore + } + try { + editor?.updateBlock(block, { props: { ...block.props, data } }); + } catch { + // ignore + } + if (docId) { + fetch(`/api/mindmap/${docId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data }), + }).catch(() => { + // ignore + }); + } + } + } createdInstance?.destroy(); } catch { // ignore @@ -716,10 +988,11 @@ const MindmapBlockView = ({ } } setMindmap(null); + mindmapRef.current = null; mindmapReadyRef.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [block.id]); + }, [block.id, effectiveFullscreen]); const getInstanceCandidate = () => mindmap ?? @@ -1463,33 +1736,61 @@ const MindmapBlockView = ({ ) : null; - if (fullscreen) { - return ( -
-
+ if (effectiveFullscreen) { + const fullscreenView = ( +
+
+
+ 思维导图编辑{fullscreenApiActive ? "(全屏)" : ""} +
+ +
+ +
-
+
- - {/* @ts-expect-error mindmap type */} - setActiveSidebar(null)} /> { + if (localFullscreen) exitLocalFullscreen(); + else enterLocalFullscreen(); + } + } onToggleMiniMap={() => setShowMiniMap(!showMiniMap)} miniMapOpen={showMiniMap} /> @@ -1503,19 +1804,53 @@ const MindmapBlockView = ({
); + + if (typeof document === "undefined") return null; + return createPortal(fullscreenView, document.body); } return ( -
+
-
+
{ + hotkeyScopeRef.current = true; + // 点击后把焦点落到容器上,避免快捷键被编辑器抢走 + try { + wrapperRef.current?.focus?.({ preventScroll: true }); + } catch { + // ignore + } + }} + onMouseDown={() => { + hotkeyScopeRef.current = true; + // 兼容:某些环境下 pointer 事件不触发 + try { + wrapperRef.current?.focus?.({ preventScroll: true }); + } catch { + // ignore + } + }} + onDoubleClick={() => { + if (recentNodeDblclickRef.current) return; + enterLocalFullscreen(); + }} + >
@@ -1532,7 +1867,15 @@ const MindmapBlockView = ({ /> { + if (localFullscreen) exitLocalFullscreen(); + else enterLocalFullscreen(); + } + } onToggleMiniMap={() => setShowMiniMap(!showMiniMap)} miniMapOpen={showMiniMap} /> diff --git a/wolai-frontend/src/components/editor/blocks/MindmapSidebarTrigger.tsx b/wolai-frontend/src/components/editor/blocks/MindmapSidebarTrigger.tsx index 76e60898..d7e38af9 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapSidebarTrigger.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapSidebarTrigger.tsx @@ -7,7 +7,7 @@ type Props = { onSelect: (value: SidebarPanel | null) => void; }; -export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => { +export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => { const [show, setShow] = React.useState(true); return ( @@ -15,35 +15,64 @@ export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => { className={`absolute top-1/2 z-30 flex -translate-y-1/2 transition-all duration-300 ${ activeSidebar ? "right-[300px]" : "right-0" }`} + contentEditable={false} + onPointerDownCapture={(e) => e.stopPropagation()} + onMouseDownCapture={(e) => e.stopPropagation()} >
setShow(!show)} - style={{ display: show ? "flex" : "none" }} + data-testid="mindmap-sidebar-collapse-handle" + className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md transition-all hover:-left-6 hover:w-6" + onClick={(e) => { + e.stopPropagation(); + setShow((prev) => { + const next = !prev; + if (!next) { + // 避免在 setShow 的 updater 内触发父组件 setState,导致 React 警告 + queueMicrotask(() => onSelect(null)); + } + return next; + }); + }} > - +
-
- {sidebarTriggers - .filter((item) => item.visible !== false) - .map((item) => { - const target = item.target; - const isActive = activeSidebar === target; - return ( - - ); - })} -
+ {show ? ( +
+ {sidebarTriggers + .filter((item) => item.visible !== false) + .map((item) => { + const target = item.target; + const isActive = activeSidebar === target; + return ( + + ); + })} +
+ ) : null}
); }; diff --git a/wolai-frontend/src/types/online-table.ts b/wolai-frontend/src/types/online-table.ts index fa8348e5..2c16ec72 100644 --- a/wolai-frontend/src/types/online-table.ts +++ b/wolai-frontend/src/types/online-table.ts @@ -52,14 +52,16 @@ export interface OnlineTableBlockProps { // Global declarations for Luckysheet (minimal definition) declare global { interface Window { - luckysheet: { + luckysheet?: { // eslint-disable-next-line @typescript-eslint/no-explicit-any - create: (options: any) => void; - destroy: (id: string) => void; + create?: (options: any) => void; + destroy?: (id: string) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any - getluckysheetfile: () => any; + getluckysheetfile?: () => any; + enterEditMode?: () => void; + getluckysheet_select_save?: () => unknown; // 实际功能会通过动态加载的脚本注入 - // 最小化声明以通过 TypeScript 检查 + // 最小化声明以通过 TypeScript 检查(必须允许 undefined) }; } }