diff --git a/.gitignore b/.gitignore index cdf3130..83bc0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,8 @@ node_modules/ tmp/ demos/demo_tiles/ assets/*.mp4 + +# Research knowledge base (local only — papers are large & copyrighted, indexes are generated) +papers/ +knowledge_index/ +e2e_test/index/ diff --git a/CLAUDE.md b/CLAUDE.md index 87aeed0..53ec322 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + # PixelRAG Visual RAG: render documents (web pages, PDFs) as screenshot tiles and retrieve over the images @@ -6,16 +10,87 @@ for how it runs in production. ## Layout - `render/` — `pixelshot` capture (Playwright/CDP, PDF) -- `embed/`, `index/` — tiles → vectors → FAISS index -- `serve/` — FAISS search API (`pixelrag serve`) +- `embed/` — tiles → chunks → vectors → index (the `chunk`, `embed`, `build-index` stages) +- `index/` — `pixelrag index` orchestrator + pluggable document sources +- `serve/` — FAISS/Qdrant search API (`pixelrag serve`) - `web/` — Next.js frontend (on Vercel) + `agent-server.mjs` (the chat agent backend) - `train/` — **separate uv project** (LoRA finetune); install from inside `train/`, not the root +- `eval/` — **separate uv project** reproducing paper Table 1 (reader + grader over HTTP) - `deploy/` — systemd units, CD workflow, blue-green scripts +## Commands + +Python (uv only — `uv add`, never `uv pip install`; work in `.venv`; commit `uv.lock`): + +```bash +# Install dev deps and run the full Python suite (render tests need Chrome too) +uv sync --extra dev +uv run pixelshot install-chrome # one-time; CI also runs `npx -y playwright install-deps chromium` +uv run pytest tests/ -v +uv run pytest tests/test_cli.py::test_ -v # single test + +# Lint / format (ruff; config in pyproject.toml excludes train/, demos/, docs/, skill/) +uvx ruff check . +uvx ruff format --check . + +# Pipeline: build an index from a pixelrag.yaml config, then serve it +pixelrag index build -c pixelrag.yaml +pixelrag serve --index-dir ./index --port 30001 +``` + +Frontend (`cd web`): + +```bash +npm ci +npm test # node --test lib/*.test.mjs +npm run build +npm run typecheck # tsc --noEmit +``` + +## Architecture + +**One distribution, many packages.** `pixelrag` is the single PyPI package (`pyproject.toml` +bundles `src/pixelrag` plus `render/embed/index/serve` under `src/`). Two entry points: the +standalone `pixelshot` (`pixelrag_render.render:main`) and the `pixelrag` umbrella +(`pixelrag.cli:main`). Heavy ML stages are opt-in extras (`embed`, `index`, `serve`, `qdrant`, `pdf`). + +**CLI dispatch is lazy.** `src/pixelrag/cli.py` maps `pixelrag ` to a stage module and +imports it on demand, printing an install hint if the extra is missing. Stage 0 (render) is the +separate `pixelshot` command, kept torch-free in core. + +**`pixelrag index` orchestrates by subprocess.** `index/src/pixelrag_index/pipelines.py:build` +walks `source → ingest (render tiles) → chunk → embed → build-index`, shelling out to +`python -m pixelrag_embed.chunk / .embed_cpu / .embed / .index` rather than importing them. +Device routing happens here: `device: cpu|mps|auto` → `embed_cpu.py`, `cuda` → `embed.py` +(vLLM/sglang). + +**Tile data model.** Each document gets a numeric position index. Tiles live in +`/tiles/.png.tiles/` containing `tile_XXXX.jpg`, a `tiles.json` manifest +(`article_id`, `source`, `page_height`, `tiles`, `complete`), and — after chunking — +`chunks.json` + `chunk_XXXX_YY.png` (≤1024px strips). `embed` reads `article_id` from the +manifest, never from the directory name. `/articles.json` maps position index → +title/url/department (department = first subdirectory under a local source root, used for +filtered search). Manifests are written atomically (`*.tmp` + `os.replace`) and `_needs_render` +uses the `source` field to detect stale tile dirs when the source set changes between runs. + +**Config.** `pixelrag.yaml` is deep-merged over `DEFAULT_CONFIG` in +`index/src/pixelrag_index/config.py` (`ingest`, `embed` sections merge key-wise). Sources are +pluggable via the `SOURCES` registry in `index/src/pixelrag_index/sources/__init__.py` +(`local`, `web`, `kiwix`, `pdf`); each yields `Document(id, url, path, metadata)`. + +**Serve.** `serve/src/pixelrag_serve/api.py` is a FastAPI app serving FAISS (default) or Qdrant +backends, with text and image queries. It also holds `render_ondemand.py` (render retrieved +pages at query time from a kiwix ZIM) and `zim_server*.py`. `web/agent-server.mjs` calls +`/search`; its `/health` is liveness and `/ready` is readiness (see `deploy/README.md`). + +**Training and eval are separate uv projects.** `train/` LoRA-fine-tunes +`Qwen/Qwen3-VL-Embedding-2B` (`train_contrastors.py` is the primary script); it has its own +`train/CLAUDE.md` and `train/README.md` — read those before touching it. `eval/` is a pure HTTP +client (reader + grader) that reproduces paper numbers; also self-contained. + ## Conventions -- Python: `uv` only (`uv add`, never `uv pip install`); work in `.venv`; commit `uv.lock`. -- One distribution (`pixelrag`) with extras; the CLIs are `pixelshot` and `pixelrag `. - `main` is branch-protected — land changes via PRs. +- Releasing is automated: `uv version X.Y.Z`, tag `vX.Y.Z`, GitHub Release → `.github/workflows/release.yml` publishes `pixelrag` to PyPI. ## Operational context (host-specific, kept out of this public repo) Deploy/runtime details that are specific to the live host are **not** committed here. They are diff --git "a/PixelRAG\345\256\242\346\210\267\347\253\257.bat" "b/PixelRAG\345\256\242\346\210\267\347\253\257.bat" new file mode 100644 index 0000000..ff5b3dc --- /dev/null +++ "b/PixelRAG\345\256\242\346\210\267\347\253\257.bat" @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +start "" ".venv\Scripts\pythonw.exe" pixelrag_client.py diff --git a/e2e_test/pixelrag.yaml b/e2e_test/pixelrag.yaml new file mode 100644 index 0000000..323e146 --- /dev/null +++ b/e2e_test/pixelrag.yaml @@ -0,0 +1,9 @@ +source: + type: local + path: D:/ccproject/PixelRAG/e2e_test/test.pdf + +embed: + model: Qwen/Qwen3-VL-Embedding-2B + device: cpu + +output: D:/ccproject/PixelRAG/e2e_test/index diff --git a/e2e_test/test.pdf b/e2e_test/test.pdf new file mode 100644 index 0000000..697055c Binary files /dev/null and b/e2e_test/test.pdf differ diff --git a/embed/src/pixelrag_embed/embed_cpu.py b/embed/src/pixelrag_embed/embed_cpu.py index 08b4c33..aa1922c 100644 --- a/embed/src/pixelrag_embed/embed_cpu.py +++ b/embed/src/pixelrag_embed/embed_cpu.py @@ -142,18 +142,33 @@ def scan_chunks(shard_dir: str) -> list[dict]: return items +def _save_checkpoint(path: Path, embeddings: np.ndarray, done: int) -> None: + """Atomically persist a resume checkpoint holding the first `done` rows.""" + tmp = path.parent / (path.name + ".tmp.npz") + np.savez(tmp, embeddings=embeddings[:done], done=np.int64(done)) + os.replace(tmp, path) + + def embed_items( items: list[dict], model_name: str, device: str = "cpu", instruction: str = "", -) -> np.ndarray: - """Embed image items using transformers on the given device.""" + checkpoint_dir: str | None = None, + checkpoint_every: int = 10, +) -> tuple[np.ndarray, Path | None]: + """Embed image items using transformers on the given device. + + When ``checkpoint_dir`` is set, progress is written to + ``/embed_checkpoint.npz`` every ``checkpoint_every`` + chunks and resumed from there on restart, so an interrupted CPU embed + continues instead of restarting. Returns ``(embeddings, checkpoint_path)``. + """ import torch from transformers import AutoProcessor, Qwen3VLForConditionalGeneration device = _resolve_device(device) - dtype = torch.float32 if device == "cpu" else torch.float16 + dtype = torch.bfloat16 if device == "cpu" else torch.float16 logger.info("Loading model %s on %s (%s)...", model_name, device, dtype) processor = AutoProcessor.from_pretrained(model_name, trust_remote_code=True) @@ -170,9 +185,36 @@ def embed_items( dim = model.config.text_config.hidden_size embeddings = np.zeros((len(items), dim), dtype=np.float16) + checkpoint_path = None + start = 0 + if checkpoint_dir: + checkpoint_path = Path(checkpoint_dir) / "embed_checkpoint.npz" + if checkpoint_path.exists(): + try: + with np.load(checkpoint_path) as ck: + partial = ck["embeddings"] + done = int(ck["done"]) + if partial.shape == (done, dim) and 0 < done <= len(items): + embeddings[:done] = partial + start = done + logger.info( + "Resuming from checkpoint: %d/%d chunks already embedded", + done, + len(items), + ) + else: + logger.warning( + "Ignoring unusable checkpoint (shape=%s, done=%d)", + partial.shape, + done, + ) + except (OSError, ValueError, KeyError): + logger.warning("Ignoring unreadable checkpoint at %s", checkpoint_path) + prefix = f"Instruct: {instruction}\n" if instruction else "" - for i, item in enumerate(tqdm(items, desc="Embedding")): + for i in tqdm(range(start, len(items)), desc="Embedding", initial=start): + item = items[i] img = Image.open(item["path"]).convert("RGB") img = _clamp_width(img) @@ -202,7 +244,10 @@ def embed_items( last_idx = seq_lens - 1 pooled = last_hidden[0, last_idx[0]] pooled = pooled / pooled.norm() - embeddings[i] = pooled.cpu().numpy().astype(np.float16) + embeddings[i] = pooled.cpu().float().numpy().astype(np.float16) + + if checkpoint_path and (i + 1) % checkpoint_every == 0: + _save_checkpoint(checkpoint_path, embeddings, i + 1) # tqdm covers interactive runs but never reaches log files; when # stderr is not a TTY (nohup/CI/redirects) emit a periodic record so @@ -210,7 +255,7 @@ def embed_items( if (i + 1) % 100 == 0 and not sys.stderr.isatty(): logger.info("Embedded %d/%d", i + 1, len(items)) - return embeddings + return embeddings, checkpoint_path def main(): @@ -230,6 +275,15 @@ def main(): "--instruction", default="", help="Instruction prefix for queries" ) parser.add_argument("--limit", type=int, default=None, help="Max chunks to embed") + parser.add_argument( + "--checkpoint-every", + type=int, + default=1, + help="Save a resume checkpoint every N chunks (default: 1). Embedding " + "keeps all progress in memory; only the checkpoint survives a kill, so " + "this bounds what an OOM kill costs. The write is a few MB at most, so " + "saving after every chunk is cheap.", + ) args = parser.parse_args() os.makedirs(args.output_dir, exist_ok=True) @@ -244,8 +298,20 @@ def main(): items = items[: args.limit] else: logger.info("Found %d chunks to embed", len(items)) - embeddings = embed_items( - items, args.model, device=args.device, instruction=args.instruction + + checkpoint_every = max(1, args.checkpoint_every) + logger.info( + "Checkpoint every %d chunk(s) -> %s (an OOM kill costs at most that much)", + checkpoint_every, + Path(args.output_dir) / "embed_checkpoint.npz", + ) + embeddings, checkpoint_path = embed_items( + items, + args.model, + device=args.device, + instruction=args.instruction, + checkpoint_dir=args.output_dir, + checkpoint_every=checkpoint_every, ) output_path = Path(args.output_dir) / "shard_000.npz" @@ -259,6 +325,9 @@ def main(): tile_heights=np.array([it["height"] for it in items], dtype=np.int32), ) + if checkpoint_path and checkpoint_path.exists(): + checkpoint_path.unlink() + logger.info("Saved %d embeddings to %s", len(items), output_path) diff --git a/index/src/pixelrag_index/pipelines.py b/index/src/pixelrag_index/pipelines.py index a7118b4..70002e1 100644 --- a/index/src/pixelrag_index/pipelines.py +++ b/index/src/pixelrag_index/pipelines.py @@ -213,7 +213,7 @@ def _repl(m: re.Match) -> str: if not _needs_render(tiles_dir, idx, doc): continue src_path = Path(doc.path) - content = src_path.read_text(errors="replace") + content = src_path.read_text(encoding="utf-8", errors="replace") ext = (doc.metadata or {}).get("extension", src_path.suffix.lower()) if ext == ".md": body = md_lib.markdown( @@ -224,7 +224,7 @@ def _repl(m: re.Match) -> str: body = _resolve_relative_paths(body, str(src_path.parent)) html_content = f"{_HTML_TEMPLATE}{body}" html_path = Path(tmp_dir) / f"{idx}.html" - html_path.write_text(html_content) + html_path.write_text(html_content, encoding="utf-8") text_urls.append(f"file://{html_path.resolve()}") text_stems.append(str(idx)) @@ -349,7 +349,7 @@ def _repl(m: re.Match) -> str: article_entries.append( {"title": title, "url": url, "department": _department_of(a, source_root)} ) - with open(articles_path, "w") as f: + with open(articles_path, "w", encoding="utf-8") as f: json.dump(article_entries, f) logger.info( " Saved %d article mappings to %s", len(article_entries), articles_path diff --git a/pixelrag.yaml b/pixelrag.yaml new file mode 100644 index 0000000..00fe00a --- /dev/null +++ b/pixelrag.yaml @@ -0,0 +1,9 @@ +source: + type: local + path: D:/ccproject/PixelRAG/papers + +embed: + model: Qwen/Qwen3-VL-Embedding-2B + device: cpu + +output: D:/ccproject/PixelRAG/knowledge_index diff --git a/pixelrag_client.py b/pixelrag_client.py new file mode 100644 index 0000000..a832ce5 --- /dev/null +++ b/pixelrag_client.py @@ -0,0 +1,2126 @@ +#!/usr/bin/env python3 +"""PixelRAG 知识库桌面客户端 —— 「Notion 纸感」浅色聊天界面。 + +打开即自动检测/启动检索服务,输入问题后检索最相关的论文页面截图,交给视觉模型 +流式生成答案,正文下方附引用卡片(文件名 / 页码 / 缩略图 / 点击打开 PDF)。 +顶栏可切换深色主题。 + +界面样式全部取自 ``pixelrag_theme`` 的设计令牌;tkinter 缺失的圆角、hover 等能力 +由 ``pixelrag_widgets`` 用 Pillow 离屏渲染补齐。检索与生成逻辑与旧版一致。 + +依赖:标准库 tkinter + 已安装的 Pillow、anthropic。 +""" + +import base64 +import io +import json +import os +import re +import subprocess +import threading +import time +import tkinter as tk +import urllib.request + +import anthropic +from PIL import Image, ImageTk + +import pixelrag_history as H +import pixelrag_theme as T +import pixelrag_widgets as W + +ROOT = os.path.dirname(os.path.abspath(__file__)) +HOST = "localhost" +PORT = 30001 +N_DOCS = 5 +HEALTH_URL = f"http://{HOST}:{PORT}/health" +SEARCH_URL = f"http://{HOST}:{PORT}/search" +ARTICLES_JSON = os.path.join(ROOT, "knowledge_index", "articles.json") +TILES_DIR = os.path.join( + ROOT, "knowledge_index", "tiles" +) # 与 _serve_cmd 里的 --tiles-dir 同一处 + +# ---- VLM 生成配置(DeepSeek-V4.1-Flash,原生视觉)---- +VLM_MODEL = ( + "deepseek-flash" # DeepSeek-V4.1-Flash 官方主名;旧名 deepseek-v4-flash 已下线 +) +VLM_BASE_URL = "https://api.deepseek.com/anthropic" +VLM_MAX_IMAGES = 6 # 最多发几张截图 +VLM_MAX_SIDE = 1568 # 截图长边像素上限(超出即压缩) +# 输出上限。注意:deepseek-flash 是带思考的模型,思考 token 也计入这里 —— +# 图多、问题复杂时思考轻松吃掉 2000+,上限太小会导致"一个字都没写就被切断" +# (界面表现就是"模型没有返回任何内容")。实测 8192 能稳定作答。 +VLM_MAX_TOKENS = 8192 + +# 窗口逻辑尺寸(会按屏幕 DPI 缩放)。比原来宽,是因为左边多了历史栏。 +WIN_W, WIN_H = 1140, 800 +WIN_MIN_W = 880 # 侧栏可收起,但收起后对话区也不该挤到读不了 + + +# --------------------------------------------------------------------------- +# 服务与检索(与旧版一致,未改动) +# --------------------------------------------------------------------------- + + +def check_health() -> bool: + try: + urllib.request.urlopen(HEALTH_URL, timeout=3) + return True + except Exception: + return False + + +def _serve_cmd() -> list[str]: + exe = os.path.join(ROOT, ".venv", "Scripts", "pixelrag.exe") + return [ + exe, + "serve", + "--index-dir", + os.path.join(ROOT, "knowledge_index"), + "--tiles-dir", + os.path.join(ROOT, "knowledge_index", "tiles"), + "--articles-json", + os.path.join(ROOT, "knowledge_index", "articles.json"), + "--device", + "cpu", + "--port", + str(PORT), + ] + + +def start_serve_process() -> subprocess.Popen: + env = dict(os.environ) + env["HF_HUB_OFFLINE"] = "1" + env["TRANSFORMERS_OFFLINE"] = "1" + flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + return subprocess.Popen( + _serve_cmd(), + env=env, + creationflags=flags, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def search(text: str, n_docs: int = N_DOCS) -> dict: + payload = json.dumps( + {"queries": [{"text": text}], "n_docs": n_docs, "include_images": True} + ).encode("utf-8") + req = urllib.request.Request( + SEARCH_URL, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=900) as r: + return json.loads(r.read().decode("utf-8")) + + +def title_of(url: str) -> str: + name = os.path.basename((url or "").replace("\\", "/")) + if name.lower().endswith(".pdf"): + name = name[:-4] + return name + + +def decode_b64_image(b64: str) -> Image.Image: + return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB") + + +def _load_titles() -> dict[int, str]: + """articles.json 的下标即 article_id,用它拿到比文件名更干净的标题。""" + try: + with open(ARTICLES_JSON, encoding="utf-8") as f: + arts = json.load(f) + return {i: (a.get("title") or "") for i, a in enumerate(arts)} + except Exception: + return {} + + +def refs_of(hits) -> list[dict]: + """把命中列表压成"引用页"列表,按 (文章, 页, 段) 去重。 + + 存进历史的是**数据不是图片**:缩略图由 :func:`history_tiles` 从本地 tiles 现读, + 所以历史文件很小,而且 tiles 重新渲染过也照样对得上。 + + 去重是因为同一个问题走不同路径(直接生成 / 降级只显示引用)可能拿到同一页两条命中, + 不去重历史里就会存两份一模一样的卡片。 + """ + out: list[dict] = [] + seen: set = set() + for h in hits or []: + if not isinstance(h, dict): + continue + ref = { + "article_id": h.get("article_id"), + "tile_index": h.get("tile_index"), + "chunk_index": h.get("chunk_index"), + "score": h.get("score"), + "url": h.get("url"), + } + key = (ref["article_id"], ref["tile_index"], ref["chunk_index"]) + if key in seen: + continue + seen.add(key) + out.append(ref) + return out + + +def tiles_from(hits) -> list[tuple[str, dict]]: + """命中列表 → 引用卡片要的 ``(图片 base64, hit)``。 + + 按相似度降序排、按 (文章, 页, 段) 去重,并且丢掉没带截图的命中 —— 卡片是靠 + 截图显示"引用的是哪一页"的,没有图就没得显示。 + """ + out: list[tuple[str, dict]] = [] + seen: set = set() + for h in sorted(hits or [], key=lambda x: float(x.get("score", 0.0)), reverse=True): + if not isinstance(h, dict): + continue + key = (h.get("article_id"), h.get("tile_index"), h.get("chunk_index")) + if key in seen: + continue + seen.add(key) + b64 = h.get("image_base64") + if b64: + out.append((b64, h)) + return out + + +def history_tiles(item: dict) -> list[tuple[str, dict]]: + """把历史里的引用页还原成 ``(图片 base64, hit)``。 + + 读的是本地 tiles 目录,所以打开旧问答既不联网也不用检索服务; + 文件被删掉时缩略图留空,卡片其余信息照常显示。 + """ + out: list[tuple[str, dict]] = [] + for ref in item.get("refs") or []: + if not isinstance(ref, dict): + continue + b64 = "" + try: + article = int(ref.get("article_id")) + tile = int(ref.get("tile_index")) + path = os.path.join( + TILES_DIR, f"{article}.png.tiles", f"tile_{tile:04d}.jpg" + ) + with open(path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("ascii") + except Exception: + b64 = "" + out.append((b64, dict(ref))) + return out + + +def screen_scale() -> float: + try: + import ctypes + + return ctypes.windll.shcore.GetScaleFactorForDevice(0) / 100.0 + except Exception: + return 1.0 + + +SYSTEM_PROMPT = ( + "你是一个科研文献视觉问答助手。用户会提供若干张论文页面的截图,并附上一个问题。" + "请只根据这些截图中的可见内容回答,严格遵守:\n" + "1. 只能依据截图内容回答,严禁编造截图里没有的数据或事实。\n" + '2. 若截图信息不足以回答,直接说"根据现有资料无法回答",不要猜测。\n' + "3. 回答要具体、简洁,直接给出结论。\n" + "4. 每个关键事实后用 [1][2] 等编号标注,编号对应第几张截图(从 1 开始)。\n" + "5. 若答案来自某张图表,说明依据的是第几张图的哪个部分。\n" +) + + +def _load_vlm_config() -> dict: + """读取 VLM 配置:优先环境变量 PIXELRAG_VLM_*,其次 ANTHROPIC_* 环境变量,最后回退 ~/.claude/settings.json。""" + cfg = { + "api_key": os.environ.get("PIXELRAG_VLM_API_KEY"), + "base_url": os.environ.get("PIXELRAG_VLM_BASE_URL"), + "model": os.environ.get("PIXELRAG_VLM_MODEL"), + } + if not cfg["api_key"]: + cfg["api_key"] = os.environ.get("ANTHROPIC_AUTH_TOKEN") + cfg["base_url"] = os.environ.get("ANTHROPIC_BASE_URL") or VLM_BASE_URL + if not cfg["api_key"]: + try: + p = os.path.join(os.path.expanduser("~"), ".claude", "settings.json") + env = json.load(open(p, encoding="utf-8")).get("env", {}) + cfg["api_key"] = env.get("ANTHROPIC_AUTH_TOKEN") + cfg["base_url"] = env.get("ANTHROPIC_BASE_URL") or VLM_BASE_URL + except Exception: + pass + cfg["base_url"] = cfg["base_url"] or VLM_BASE_URL + cfg["model"] = cfg["model"] or VLM_MODEL + return cfg + + +def prepare_images(hits: list, k: int = VLM_MAX_IMAGES) -> list: + """取 Top-K 相关截图:按相关度降序、按 (article_id, tile_index) 去重、压缩为长边 ≤ VLM_MAX_SIDE 的 JPEG。 + + 返回 [(b64_jpeg, hit), ...],顺序即引用编号 [1][2]...。 + """ + seen = set() + out = [] + for h in sorted(hits, key=lambda x: float(x.get("score", 0.0)), reverse=True): + key = (h.get("article_id"), h.get("tile_index")) + if key in seen: + continue + seen.add(key) + b64 = h.get("image_base64") + if not b64: + continue + try: + img = decode_b64_image(b64) + img.thumbnail((VLM_MAX_SIDE, VLM_MAX_SIDE), Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=80) + b64_out = base64.b64encode(buf.getvalue()).decode("ascii") + except Exception: + continue + out.append((b64_out, h)) + if len(out) >= k: + break + return out + + +def vlm_stream(images, question: str, cfg: dict): + """把多张截图按相关度顺序 + 问题发给 VLM,流式 yield 答案文本(过滤 thinking)。 + + 两个容易被误判成"网络坏了"的情况在这里说明白:模型把输出额度全花在思考上、 + 一个字都没写出来时,抛一条能看懂的错误,而不是静默返回空串。 + """ + client = anthropic.Anthropic(api_key=cfg["api_key"], base_url=cfg["base_url"]) + content = [] + for i, (b64, _h) in enumerate(images, 1): + content.append( + { + "type": "image", + "source": {"type": "base64", "media_type": "image/jpeg", "data": b64}, + } + ) + content.append({"type": "text", "text": f"[第{i}张截图]"}) + content.append({"type": "text", "text": question}) + + got_text = False + with client.messages.stream( + model=cfg["model"], + max_tokens=VLM_MAX_TOKENS, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": content}], + ) as stream: + for ev in stream: + if getattr(ev, "type", None) == "text": + got_text = True + yield ev.text + stop_reason = getattr(stream.get_final_message(), "stop_reason", None) + + if stop_reason == "max_tokens": + if not got_text: + raise RuntimeError( + f"模型的思考占满了输出上限(max_tokens={VLM_MAX_TOKENS})," + "还没来得及作答就被切断了。请再试一次,或把 pixelrag_client.py 里的 " + "VLM_MAX_TOKENS 调得更大。" + ) + yield "\n\n(回答达到长度上限,可能不完整)" + + +_CODE_RE = re.compile(r"```[ \t]*[\w+-]*\n(.*?)```", re.DOTALL) + + +def _split_code_blocks(text: str) -> list[tuple[str, str]]: + """把答案切成 ('text'|'code', 片段) 序列,用于分别排版代码块。""" + out: list[tuple[str, str]] = [] + pos = 0 + for m in _CODE_RE.finditer(text): + if m.start() > pos: + out.append(("text", text[pos : m.start()])) + out.append(("code", m.group(1).rstrip("\n"))) + pos = m.end() + if pos < len(text): + out.append(("text", text[pos:])) + return out or [("text", text)] + + +# ---- 答案正文里的行内标记 --------------------------------------------------- +# 模型给的答案不是纯文本,常见的是「**小标题**」和行首「* 条目」,原样塞进 Text 就会 +# 把星号显示出来。这里只认模型真会写的那三种,不做通用 Markdown —— 宁可漏掉罕见的 +# 写法,也不要把正文里的星号当成标记吃掉(数学式 a*b、脚注 * 之类都很常见)。 +_BOLD_RE = re.compile(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", re.DOTALL) +_BULLET_RE = re.compile(r"^([ \t]*)\*[ \t]+") +_HEADING_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+") + + +def _rich_runs(text: str) -> list[tuple[str, str]]: + """把答案正文切成 ``(文字, 标签)``:标签 ``""`` 是普通文字,``"bold"`` 是加粗。 + + - ``**强调**`` → 去掉星号,文字加粗(模型常拿它当小标题用,粗体得保住); + - 行首 ``#`` 标题 → 去掉 # 号与空格,整行加粗(各级都是加粗,不改字号); + - 行首 ``* 条目`` → 星号换成圆点(列表还是列表,屏幕上不再有星号); + - ``-`` 开头的列表不动:它不是星号,换成圆点反而和破折号撞脸。 + + 代码块不经过这里 —— 调用方先用 :func:`_split_code_blocks` 把它们分出去了。 + """ + runs: list[tuple[str, str]] = [] + pos = 0 + for m in _BOLD_RE.finditer(text): + if m.start() > pos: + _plain_runs(text[pos : m.start()], runs) + runs.append((m.group(1), "bold")) + pos = m.end() + if pos < len(text): + _plain_runs(text[pos:], runs) + return runs + + +def _plain_runs(chunk: str, runs: list[tuple[str, str]]) -> None: + """普通片段里的行首标记:先换圆点、再剥 ``#`` 标题,逐行处理(换行符原样留着)。""" + for line in chunk.splitlines(keepends=True): + head = _BULLET_RE.match(line) + if head: + # 只换星号那一个字符:后面用来对齐的空格原样保留,列表的缩进才不变 + i = len(head.group(1)) + line = line[:i] + "•" + line[i + 1 :] + m = _HEADING_RE.match(line) + if m: + body = line[m.end() :] + nl = "\n" if body.endswith("\n") else "" + body = body[:-1] if nl else body + # 整行本来就要加粗,行内的 ** 就只剩标记作用了,一并去掉 + body = _BOLD_RE.sub(r"\1", body) + if body: + runs.append((body, "bold")) + if nl: + runs.append((nl, "")) + elif line: + runs.append((line, "")) + + +# --------------------------------------------------------------------------- +# 界面 +# --------------------------------------------------------------------------- + + +class App: + def __init__(self, root: tk.Tk): + self.root = root + root.title("PixelRAG 视觉问答") + root.configure(bg=T.c("bg")) + root.minsize(int(WIN_MIN_W * T.UI_SCALE), int(480 * T.UI_SCALE)) + + # 消息数据模型:主题切换 / 窗口缩放时据此重放渲染 + self.messages: list[dict] = [] + self._photos: list[ImageTk.PhotoImage] = [] # 防止图片被 GC + self._answer_text: tk.Text | None = None + self._answer_msg: dict | None = None + self._paint_job = None + self._loading_job = None + self._loading_active = False + self._loading_base = "正在检索相关页面" + self._resize_job = None + self._send_hover = False + self._stop_hover = False + self._busy = False + # 停止生成:主线程置位,后台消费循环轮询。generator 另存一份是为了能 + # 显式 close(),让底层的 HTTP 流立刻断开而不是等 GC。 + self._stop_event = threading.Event() + self._gen = None + self._stopped = False + self._last_question: str | None = None + # 查询历史:落盘存储 + 左侧栏 + 输入框上下键召回 + self.history = H.History() + self.sidebar: H.HistorySidebar | None = None + self._sidebar_open = True # 顶栏「历史」按钮切换它 + self._current_hist: dict | None = None # 当前打开的那轮问答 + self._hist_entry: dict | None = None # 本次提问对应的历史条目,答完回填 + self._refetching = False # 正在给老记录补检索引用页(同时只补一条) + self._hist_pos: int | None = None # None = 不在召回态;0 = 最新一条 + self._hist_draft = "" # 召回前用户已经敲了一半的内容 + self._serve_ready = check_health() + self._titles = _load_titles() + self._content_w = T.CONTENT_MAX_WIDTH + self._inset = T.SPACE[ + "xl" + ] # 消息列到画布边的留白(宽屏时变大,见 _content_metrics) + self._canvas_w = 0 + + self._apply_window_geometry() + self._build_ui() + self._add_welcome() + self._set_status() + + if not self._serve_ready: + threading.Thread(target=self._ensure_serve, daemon=True).start() + + def _apply_window_geometry(self): + sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight() + w = min(int(WIN_W * T.UI_SCALE), sw - int(80 * T.UI_SCALE)) + h = min(int(WIN_H * T.UI_SCALE), sh - int(120 * T.UI_SCALE)) + x = max(0, (sw - w) // 2) + y = max(0, (sh - h) // 3) + self.root.geometry(f"{w}x{h}+{x}+{y}") + + # ---------- UI 构建 ---------- + + def _build_ui(self): + self._build_topbar() + self._build_bottom_bar() + self._build_body() + + def _build_topbar(self): + self.topbar = tk.Frame(self.root, bg=T.c("surface")) + self.topbar.pack(side="top", fill="x") + inner = tk.Frame(self.topbar, bg=T.c("surface")) + inner.pack(fill="x", padx=T.SPACE["lg"], pady=T.SPACE["sm"]) + + self.brand = tk.Label( + inner, + text="PixelRAG", + bg=T.c("surface"), + fg=T.c("text"), + font=T.font("title"), + ) + self.brand.pack(side="left") + self.brand_sub = tk.Label( + inner, + text="视觉问答", + bg=T.c("surface"), + fg=T.c("text_faint"), + font=T.font("caption"), + ) + self.brand_sub.pack( + side="left", padx=(T.SPACE["sm"], 0), pady=(T.SPACE["xs"], 0) + ) + + # 主题切换 + self.theme_btn = tk.Label( + inner, + text="", + bg=T.c("surface"), + fg=T.c("text_muted"), + font=T.font("caption"), + cursor="hand2", + padx=T.SPACE["sm"], + pady=T.SPACE["xs"], + ) + self.theme_btn.pack(side="right") + self.theme_btn.bind("", lambda _e: self._toggle_theme()) + W.make_focusable(self.theme_btn, T.c("surface"), self._toggle_theme) + W.bind_hover( + self.theme_btn, + on_enter=lambda: self.theme_btn.configure( + fg=T.c("accent_text"), bg=T.c("accent_soft") + ), + on_leave=lambda: self.theme_btn.configure( + fg=T.c("text_muted"), bg=T.c("surface") + ), + ) + self._sync_theme_btn() + + # 查询历史 + self.history_btn = tk.Label( + inner, + text="历史", + bg=T.c("surface"), + fg=T.c("text_muted"), + font=T.font("caption"), + cursor="hand2", + padx=T.SPACE["sm"], + pady=T.SPACE["xs"], + ) + self.history_btn.pack(side="right", padx=(0, T.SPACE["xs"])) + self.history_btn.bind("", lambda _e: self._on_history()) + W.make_focusable(self.history_btn, T.c("surface"), self._on_history) + W.bind_hover( + self.history_btn, + on_enter=lambda: self.history_btn.configure( + fg=T.c("accent_text"), bg=T.c("accent_soft") + ), + on_leave=lambda: self.history_btn.configure( + fg=T.c("text_muted"), bg=T.c("surface") + ), + ) + + self.status = tk.Label( + inner, + text="", + bg=T.c("surface"), + fg=T.c("text_muted"), + font=T.font("caption"), + ) + self.status.pack(side="right", padx=(0, T.SPACE["md"])) + + self.topbar_line = tk.Frame(self.root, bg=T.c("border"), height=1) + self.topbar_line.pack(side="top", fill="x") + + def _sync_theme_btn(self): + text = "切换深色" if T.mode() == "light" else "切换浅色" + self.theme_btn.configure(text=text) + + def _build_bottom_bar(self): + self.bottom = tk.Frame(self.root, bg=T.c("bg")) + self.bottom.pack(side="bottom", fill="x") + tk.Frame(self.bottom, bg=T.c("border"), height=1).pack(side="top", fill="x") + + # 输入框正对着正文那条窄栏:同宽、同留白,不是通栏(DeepSeek 就是这样)。 + # 留白在 _on_canvas_configure 里跟着聊天画布的宽度同步 —— 侧栏一开一合, + # 输入框和消息列会一起挪,不会各走各的。 + self.bottom_row = tk.Frame(self.bottom, bg=T.c("bg")) + self.bottom_row.pack( + fill="x", padx=T.SPACE["xl"], pady=(T.SPACE["md"], T.SPACE["sm"]) + ) + + # 输入框:圆角方框 + 1px 描边,聚焦时描边变主色(DeepSeek 样式)。 + # tk 没有 CSS 圆角,所以复用引用卡片那套画法 —— Canvas 铺一张圆角底图并 + # tag_lower 沉底,再用 create_window 放进 Text 和两个按钮。Text 自己的底色 + # 和填充同色,于是方角被藏进圆角里,只有描边露出来。 + self.input_box = tk.Canvas( + self.bottom_row, bg=T.c("bg"), highlightthickness=0, bd=0, height=1 + ) + self.input_box.pack(fill="x", expand=True) + self._input_bg_id = None + self._input_win_id = None + self._input_photo = None + self._input_focused = False + self._input_pad = (T.SPACE["lg"], T.SPACE["md"]) # 文字到描边的距离 + + # 发送 / 停止按钮是画布里的窗口项,浮在方框右下角(DeepSeek 的按钮在框内)。 + # 底色取 input_bg 而不是 bg:它们压在圆角填充上,方角才不会露出来。 + # bd/padx/pady 全清零:Label 的默认边框和内边距会给按钮图套一圈看不见的 + # 外框,让底图看起来离描边比设定的间距更远。 + self.send_btn = tk.Label( + self.input_box, + bg=T.c("input_bg"), + bd=0, + padx=0, + pady=0, + highlightthickness=0, + cursor="hand2", + ) + self.send_btn.bind("", lambda _e: self._on_send()) + W.bind_hover( + self.send_btn, on_enter=self._send_hover_on, on_leave=self._send_hover_off + ) + W.make_focusable(self.send_btn, T.c("input_bg"), self._on_send) + + # 停止按钮:只在生成期间显示(位置一直预留着,文字区不会跟着跳) + self.stop_btn = tk.Label( + self.input_box, + bg=T.c("input_bg"), + bd=0, + padx=0, + pady=0, + highlightthickness=0, + cursor="hand2", + ) + self.stop_btn.bind("", lambda _e: self._on_stop()) + W.bind_hover( + self.stop_btn, on_enter=self._stop_hover_on, on_leave=self._stop_hover_off + ) + W.make_focusable(self.stop_btn, T.c("input_bg"), self._on_stop) + self._stop_shown = False + self._stop_item = None + self._send_item = None + self._btn_reserve = 0 # 文字右侧留给按钮的宽度(建完按钮才知道) + self._send_img_w = self._stop_img_w = 0 + self._bottom_inset = (-1, -1) # 底栏当前 (左, 右) 留白,用来跳过无变化的设置 + + sp1, sp2, sp3 = W.line_spacing(T.font("input")) + self.input = tk.Text( + self.input_box, + height=2, + bg=T.c("input_bg"), + fg=T.c("text"), + insertbackground=T.c("accent"), + relief="flat", + bd=0, + highlightthickness=0, + font=T.font("input"), + wrap="word", + padx=0, + pady=0, + spacing1=sp1, + spacing2=sp2, + spacing3=sp3, + ) + self.input.bind("", self._on_enter) + self.input.bind("", lambda _e: self._clear_input()) + self.input.bind("", self._select_all_input) + # ↑/↓ 召回历史:只在光标处于首行(↑)/已在召回态(↓)时接管,否则让 tk 正常移光标 + self.input.bind("", self._on_input_up) + self.input.bind("", self._on_input_down) + W.attach_text_menu(self.input, self.root, editable=True) + self.input.bind("", lambda _e: self._layout_input_box(True)) + self.input.bind("", lambda _e: self._layout_input_box(False)) + + # 先把两张按钮图渲染出来:一是量出预留宽度,二是保证第一次要显示停止按钮时 + # 手里已经有图(_update_stop_button 在未显示时是直接返回的)。 + # 预留宽度按"停止按钮也在"的最宽情况算一次就定住 —— 之后停止按钮显隐、发送 + # 按钮在「发送 / 生成中」之间切换,输入框里的文字都不会跟着伸缩。 + self._render_send_image() + self._render_stop_image() + # 发送按钮的宽度被 min_w 钉住(「发送」和「生成中」一样宽),所以按当前这张量 + # 就等于按最宽情况量。 + self._btn_reserve = ( + T.SPACE["sm"] + self._send_img_w + T.SPACE["sm"] + self._stop_img_w + ) + self._update_send_button() + self.stop_btn.configure(image=self._stop_photo) + self.stop_btn.image = self._stop_photo # 先装上,量宽度也让它有尺寸 + self.input_box.bind("", lambda _e: self._layout_input_box()) + self.root.after_idle(self._layout_input_box) + + hint = tk.Label( + self.bottom, + text="Enter 发送 · Shift+Enter 换行 · ↑ 历史 · Esc 清空", + bg=T.c("bg"), + fg=T.c("text_faint"), + font=T.font("micro"), + ) + hint.pack(side="top", pady=(0, T.SPACE["sm"])) + + def _layout_input_box(self, focused: bool | None = None): + """重画输入框的圆角底,并把 ``Text`` 摆到内边距里面。 + + ``focused=None`` 表示"焦点态没变,只是尺寸变了"(```` 用)。底图走 + ``W.rounded_photo``,两张(聚焦/未聚焦)按 key 缓存,来回切焦点不会重画。 + """ + box = self.input_box + if not box.winfo_exists(): + return + if focused is not None: + self._input_focused = focused + pad_x, pad_y = self._input_pad + # -height 是"几行",tk 已经把它折算成含 spacing1/2/3 的完整行盒;拿不到就按 + # 令牌兜底(理论上不会走到)。输入框不随内容长高,超长内容由 Text 自己滚动。 + text_h = int(self.input.winfo_reqheight() or 0) or 2 * T.line_box_px("input") + w = max(1, box.winfo_width()) + h = text_h + 2 * pad_y + if int(box.cget("height")) != h: + box.configure(height=h) + photo = W.rounded_photo( + w, + h, + T.RADIUS["lg"], + T.c("input_bg"), + T.c("bg"), + outline=T.c("focus") if self._input_focused else T.c("input_line"), + ) + self._input_photo = photo # 必须持有,否则会被 GC 成白块 + if self._input_bg_id is None: + self._input_bg_id = box.create_image(0, 0, anchor="nw", image=photo) + box.tag_lower(self._input_bg_id) + else: + box.itemconfigure(self._input_bg_id, image=photo) + box.coords(self._input_bg_id, 0, 0) + + # 文字区右端让开按钮区(预留宽度在 _build_bottom_bar 里按最宽情况定死) + edge = T.SPACE["sm"] + win_w = max(1, w - pad_x - self._btn_reserve) + if self._input_win_id is None: + self._input_win_id = box.create_window( + pad_x, pad_y, anchor="nw", window=self.input, width=win_w, height=text_h + ) + else: + box.coords(self._input_win_id, pad_x, pad_y) + box.itemconfigure(self._input_win_id, width=win_w, height=text_h) + + # 按钮贴在方框右下角:停止在左、发送在右,离描边各留一个小间距。 + # 焦点环(highlight)也算在控件外框里,锚点要把它让出来,底图才真的离描边 edge。 + ring = int(self.send_btn.cget("highlightthickness") or 0) + btn_y = h - edge + ring + send_w = self._send_img_w + if self._send_item is None: + self._send_item = box.create_window( + w - edge + ring, btn_y, anchor="se", window=self.send_btn + ) + else: + box.coords(self._send_item, w - edge + ring, btn_y) + stop_x = w - edge + ring - send_w - T.SPACE["sm"] + if self._stop_item is None: + self._stop_item = box.create_window( + stop_x, + btn_y, + anchor="se", + window=self.stop_btn, + state="normal" if self._stop_shown else "hidden", + ) + else: + box.coords(self._stop_item, stop_x, btn_y) + box.tag_raise(self._stop_item) + box.tag_raise(self._send_item) + + def _layout_bottom_row(self, avail_w: int): + """让输入框与消息列左右对齐(同宽同留白)。 + + ``avail_w`` 是聊天画布的宽度 —— 输入框要对齐的是正文那条窄栏,不是整个窗口, + 所以侧栏一开一合输入框也要跟着挪。 + """ + _, inset = self._content_metrics(avail_w) + # 底栏是通栏的(侧栏一开一合它不动),所以要让开聊天区左边那截侧栏、右边那截 + # 滚动条,才能和消息列真正对齐 —— 只补 inset 会在侧栏打开时整体左移一整个侧栏。 + left = right = inset + sb = getattr(self, "sidebar", None) + if sb is not None and sb.winfo_exists(): + left += sb.winfo_width() + 1 # 侧栏 + 它右边那条 1px 分隔线 + sc = getattr(self, "scroll", None) + if sc is not None and sc.winfo_exists(): + right += sc.winfo_width() + if (left, right) == self._bottom_inset: + return + self._bottom_inset = (left, right) + self.bottom_row.pack_configure(padx=(left, right)) + + def _build_body(self): + body = tk.Frame(self.root, bg=T.c("bg")) + body.pack(side="top", fill="both", expand=True) + self.body = body + + # 左侧历史栏(DeepSeek 式)。顶栏/底栏仍是通栏,只有中间分左右。 + self.sidebar = None + if self._sidebar_open: + self.sidebar = H.HistorySidebar( + body, + self.history, + on_open=self._open_history_item, + on_reask=self._reask, + on_new=self._new_chat, + ) + self.sidebar.pack(side="left", fill="y") + tk.Frame(body, bg=T.c("border"), width=1).pack(side="left", fill="y") + self.sidebar.set_current(self._current_hist) + + self.canvas = tk.Canvas(body, bg=T.c("bg"), highlightthickness=0, bd=0) + self.scroll = tk.Scrollbar( + body, + orient="vertical", + command=self.canvas.yview, + bg=T.c("scrollbar"), + troughcolor=T.c("bg"), + activebackground=T.c("border_strong"), + borderwidth=0, + width=T.SCROLLBAR_W, + relief="flat", + elementborderwidth=0, + ) + self.canvas.configure(yscrollcommand=self.scroll.set) + self.scroll.pack(side="right", fill="y") + self.canvas.pack(side="left", fill="both", expand=True) + + self.frame = tk.Frame(self.canvas, bg=T.c("bg")) + self._win = self.canvas.create_window((0, 0), window=self.frame, anchor="nw") + self.frame.bind("", self._on_frame_configure) + self.canvas.bind("", self._on_canvas_configure) + self.canvas.bind_all("", self._on_wheel) + + def _on_frame_configure(self, _e=None): + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + def _content_metrics(self, canvas_w: int) -> tuple[int, int]: + """消息列的 ``(列宽, 左右留白)``。 + + DeepSeek 的正文是一条居中的窄栏:列宽封顶 ``CONTENT_MAX_WIDTH``,窗口再宽也 + 不变宽,多出来的宽度两边均分。窗口窄的时候留白退回 ``SPACE["xl"]``(此时 + 列宽本身就等于可用宽度,没有富余可分)。 + """ + avail = max(1, canvas_w - 2 * T.SPACE["xl"]) + w = max(280, min(T.CONTENT_MAX_WIDTH, avail)) + return w, max(T.SPACE["xl"], (canvas_w - w) // 2) + + def _on_canvas_configure(self, event): + self.canvas.itemconfigure(self._win, width=event.width) + self._layout_bottom_row(event.width) # 输入框跟着消息列一起居中对齐 + new_w, new_inset = self._content_metrics(event.width) + # 画布宽变了也要重排(即使 _content_w 已到上限):两侧留白是按画布宽算的, + # 不重排的话窗口一宽正文就一直贴在左边,和居中的引用卡片对不齐。 + moved = abs(event.width - self._canvas_w) > 8 + self._canvas_w = event.width + if ( + abs(new_w - self._content_w) > 8 + or abs(new_inset - self._inset) > 8 + or moved + ): + self._content_w, self._inset = new_w, new_inset + self._schedule_reflow() + + def _schedule_reflow(self): + """窗口尺寸变化后防抖重排(圆角图与折行宽度都需要重算)。""" + if self._resize_job: + try: + self.root.after_cancel(self._resize_job) + except Exception: + pass + self._resize_job = self.root.after( + T.MOTION["resize_debounce"], self._render_all + ) + + def _on_wheel(self, event): + # 这个绑定是 bind_all 的,滚轮落在侧栏上时不该带着对话区一起滚 + if self.sidebar is not None and str(event.widget).startswith(str(self.sidebar)): + return + try: + self.canvas.yview_scroll(int(-event.delta / 120) * 3, "units") + except Exception: + pass + + # ---------- 状态 ---------- + + def _set_status(self, state: str | None = None): + if state == "starting": + self.status.configure(text="● 正在启动检索服务", fg=T.c("warning")) + elif state == "failed": + self.status.configure(text="● 服务启动失败(见控制台)", fg=T.c("danger")) + elif self._serve_ready: + self.status.configure(text="● 服务运行中 · 端口 30001", fg=T.c("success")) + else: + self.status.configure(text="● 服务启动中", fg=T.c("warning")) + + # ---------- 消息模型与渲染 ---------- + + def _append(self, msg: dict) -> dict: + stick = self._at_bottom() # 先取状态:追加后必然"不在底部" + self.messages.append(msg) + self._render_message(msg) + self._scroll_bottom(stick) + return msg + + def _add_welcome(self): + self._append( + { + "kind": "welcome", + "text": "输入问题,我会检索知识库里的论文页面截图,并基于截图内容作答。\n" + "答案下方给出引用来源,点击卡片可打开对应的 PDF。", + } + ) + + def _add_user(self, text: str): + self._append({"kind": "user", "text": text}) + + def _add_note(self, text: str): + self._append({"kind": "note", "text": text}) + + def _add_error(self, text: str, retry: bool = False): + """``retry=True`` 时在这条错误下挂一个「重新生成」按钮。""" + self._append({"kind": "error", "text": text, "retry": retry}) + + def _render_all(self, view_state: tuple[bool, float] | None = None): + """清空并重放全部消息(主题切换 / 窗口尺寸变化时调用)。 + + ``view_state`` 由调用方在重建界面前取得;不传则读当前画布。 + """ + self._resize_job = None + stick, top = view_state if view_state is not None else self._view_state() + was_loading = self._loading_active + self._cancel_loading_job() + self._loading_active = False + + for w in self.frame.winfo_children(): + w.destroy() + self._photos.clear() + self._answer_text = None + + # 每条消息只带"下边距 = SPACE['msg']"(消息间距只有这一个来源),所以最上面 + # 那条前面得补一份上留白,否则会被画布顶边贴住。 + if self.messages: + tk.Frame(self.frame, bg=T.c("bg"), height=T.SPACE["msg"]).pack(fill="x") + + for msg in self.messages: + self._render_message(msg) + if was_loading: + self._start_loading(self._loading_base) + + self.canvas.update_idletasks() # 让 bbox 反映新内容,否则滚动范围是旧的 + self._on_frame_configure() + try: + self.canvas.yview_moveto(1.0 if stick else top) + except Exception: + pass + if stick: + self._scroll_bottom(True) # 几何稳定后再吸一次,避免滚动范围随后变大 + + def _render_message(self, msg: dict): + kind = msg.get("kind") + if kind == "user": + self._render_user(msg) + elif kind == "welcome": + self._render_welcome(msg) + elif kind == "note": + self._render_note(msg) + elif kind == "error": + self._render_error(msg) + elif kind == "section": + self._render_section(msg["text"]) + elif kind == "answer": + self._render_answer(msg) + elif kind == "citations": + self._render_citations(msg) + + # ---- 各类消息 ---- + + def _render_welcome(self, msg: dict): + wrap = tk.Frame(self.frame, bg=T.c("bg")) + wrap.pack(fill="x", padx=self._inset, pady=(T.SPACE["2xl"], T.SPACE["lg"])) + tk.Label( + wrap, + text=msg["text"], + bg=T.c("bg"), + fg=T.c("text_muted"), + font=T.font("body"), + justify="center", + wraplength=self._content_w - 2 * T.SPACE["md"], + ).pack(anchor="center") + + def _render_user(self, msg: dict): + row = tk.Frame(self.frame, bg=T.c("bg")) + row.pack(fill="x", padx=self._inset, pady=(0, T.SPACE["msg"])) + pad_x, pad_y = T.BUBBLE_PAD + img = W.render_label_box( + msg["text"], + "bubble", + T.c("user_text"), + T.c("bg"), + box_fill=T.c("user_bubble"), + radius=T.RADIUS["md"], + pad_x=pad_x, + pad_y=pad_y, + max_w=int(self._content_w * 0.78), + ) + photo = W.photo_of(img) + self._photos.append(photo) + lbl = tk.Label(row, image=photo, bg=T.c("bg"), bd=0) + lbl.image = photo + lbl.pack(side="right") + + def _render_note(self, msg: dict): + tk.Label( + self.frame, + text=msg["text"], + bg=T.c("bg"), + fg=T.c("text_muted"), + font=T.font("body"), + justify="left", + anchor="w", + wraplength=self._content_w, + ).pack(fill="x", padx=self._inset, pady=(0, T.SPACE["msg"])) + + def _render_error(self, msg: dict): + row = tk.Frame(self.frame, bg=T.c("bg")) + row.pack(fill="x", padx=self._inset, pady=(0, T.SPACE["msg"])) + img = W.render_label_box( + msg["text"], + "body", + T.c("danger"), + T.c("bg"), + box_fill=T.c("danger_bg"), + radius=T.RADIUS["md"], + pad_x=T.SPACE["md"], + pad_y=T.SPACE["sm"], + max_w=self._content_w, + ) + photo = W.photo_of(img) + self._photos.append(photo) + lbl = tk.Label(row, image=photo, bg=T.c("bg"), bd=0) + lbl.image = photo + lbl.pack(anchor="w") # 竖排:气泡在上,重试按钮在下 + if msg.get("retry"): + self._render_retry_button(row) + + def _render_retry_button(self, parent): + """次要按钮:描边胶囊,悬停变主色。""" + btn = tk.Label(parent, bg=T.c("bg"), bd=0, cursor="hand2") + btn.pack(anchor="w", pady=(T.SPACE["sm"], 0)) + + def paint(hover: bool): + img = W.render_label_box( + "重新生成", + "button", + T.c("accent_text") if hover else T.c("text"), + T.c("bg"), + box_fill=T.c("card_hover") if hover else T.c("card"), + outline=T.c("accent") if hover else T.c("border_strong"), + radius=T.RADIUS_PILL, + pad_x=T.SPACE["md"], + pad_y=T.SPACE["sm"], + align="center", + ) + photo = W.photo_of(img) + self._photos.append(photo) + try: + btn.configure(image=photo) + btn.image = photo + except Exception: + pass + + paint(False) + btn.bind("", lambda _e: self._on_regenerate()) + W.bind_hover(btn, on_enter=lambda: paint(True), on_leave=lambda: paint(False)) + W.make_focusable(btn, T.c("bg"), self._on_regenerate) + + def _render_section(self, text: str): + wrap = tk.Frame(self.frame, bg=T.c("bg")) + # 区块标题属于它上面那条消息的一部分,所以贴着上面走(上边距 0),只留 + # 下边距 —— 它的"和上一条消息的距离"由上面那条的 msg 边距提供。 + wrap.pack(fill="x", padx=self._inset, pady=(0, T.SPACE["sm"])) + tk.Label( + wrap, + text=text, + bg=T.c("bg"), + fg=T.c("text_muted"), + font=T.font("h2"), + ).pack(side="left") + tk.Frame(wrap, bg=T.c("border"), height=1).pack( + side="left", + fill="x", + expand=True, + padx=(T.SPACE["sm"], 0), + pady=(T.SPACE["sm"], 0), + ) + + def _render_answer(self, msg: dict): + container = tk.Frame(self.frame, bg=T.c("bg")) + container.pack(fill="x", padx=self._inset, pady=(0, T.SPACE["msg"])) + msg["_widget"] = container + + # 正文用只读 Text 而不是 Label —— Label 不能选中,复制不出来。 + # Text 没有 wraplength,行宽靠容器的 padx=self._inset 压回 _content_w, + # 否则窗口一宽,长行就会横贯整个画布(和引用卡片对不齐)。 + txt = W.selectable_text(container, T.font("answer"), T.c("bg"), T.c("text")) + txt.pack(fill="x") + W.attach_text_menu(txt, self.root) + # 代码块同样按 LINE_HEIGHT 铺行距,但要按**等宽字体自己的**行盒算:两种字体 + # 的自然行高差得不少,照抄正文那个像素值会让代码行挤在一起。tk 的折行间距 + # 会被对半分给折点两侧,所以 spacing1/3 取 gap 的上/下半(与 selectable_text 同理)。 + code_font = T.font_mono(T.px_size("code")) + sp1, sp2, sp3 = W.line_spacing(code_font) + txt.tag_configure( + "code", + font=code_font, + background=T.c("code_bg"), + lmargin1=T.SPACE["md"], + lmargin2=T.SPACE["md"], + rmargin=T.SPACE["md"], + spacing1=sp1, + spacing2=sp2, + spacing3=sp3, + ) + # 行内加粗(答案里的小标题)。它自带行距,理由和代码块一样:粗体的行盒比 + # 正文字体高 1px,不补 spacing 的话同一段里会隔三差五冒出一行高 1px。 + bold_font = T.font("answer", "bold") + bs1, bs2, bs3 = W.line_spacing(bold_font) + txt.tag_configure( + "bold", font=bold_font, spacing1=bs1, spacing2=bs2, spacing3=bs3 + ) + self._answer_text = txt + + text = msg.get("text", "") + if msg.get("streaming"): + self._set_answer_into(txt, (text + " ▌") if text else "…", settle=True) + self._answer_msg = msg + return + + self._fill_answer(txt, text) + self._render_copy_button(container, text) + + def _set_answer_into(self, txt: tk.Text, s: str, settle: bool = False): + """整体重写正文。内容短、节流到 60ms 一次,所以不做增量插入。 + + 流式也走 :func:`_rich_runs`:否则生成过程中会先看到一堆星号,定稿时才消失。 + 代价是半个标记(``**开头`` 还没等到收尾)会短暂按原样显示,收尾一到就变粗。 + """ + txt.delete("1.0", "end") + self._insert_runs(txt, s) + W.fit_height(txt, settle=settle) + + def _fill_answer(self, txt: tk.Text, text: str): + """定稿排版:代码段套 code 标签(等宽 + 淡底 + 内缩),其余按行内标记分粗/不粗。""" + for seg_kind, seg_text in _split_code_blocks(text): + if seg_kind == "code": + txt.insert("end", seg_text + "\n", "code") + elif seg_text: + self._insert_runs(txt, seg_text) + W.fit_height(txt, settle=True) + + @staticmethod + def _insert_runs(txt: tk.Text, text: str): + """按 :func:`_rich_runs` 的分段插入:加粗的套 bold 标签,其余原样。""" + for s, tag in _rich_runs(text): + if tag: + txt.insert("end", s, tag) + else: + txt.insert("end", s) + + def _render_copy_button(self, parent, text: str): + bar = tk.Frame(parent, bg=T.c("bg")) + bar.pack(fill="x", pady=(T.SPACE["sm"], 0)) + btn = tk.Label( + bar, + text="复制答案", + bg=T.c("bg"), + fg=T.c("text_faint"), + font=T.font("micro"), + cursor="hand2", + padx=T.SPACE["xs"], + ) + btn.pack(side="left") + + def copy(_e=None): + self.root.clipboard_clear() + self.root.clipboard_append(text) + btn.configure(text="已复制", fg=T.c("success")) + self.root.after( + 1400, lambda: btn.configure(text="复制答案", fg=T.c("text_faint")) + ) + + btn.bind("", copy) + W.bind_hover( + btn, + on_enter=lambda: btn.configure(fg=T.c("accent_text")), + on_leave=lambda: btn.configure(fg=T.c("text_faint")), + ) + W.make_focusable(btn, T.c("bg"), copy) + + def _render_citations(self, msg: dict): + self._render_section("引用来源") + # 补检索来的引用页要写清楚来路:这些页不是当时那几页,不能让人当成本来的出处 + note = msg.get("note") + if note: + tk.Label( + self.frame, + text=note, + bg=T.c("bg"), + fg=T.c("text_faint"), + font=T.font("caption"), + justify="left", + anchor="w", + wraplength=self._content_w, + ).pack(fill="x", padx=self._inset, pady=(0, T.SPACE["sm"])) + tiles = msg["tiles"] + # 卡片是一组:卡与卡之间用 sm(挨着才像一组),整组结束后才轮到消息间距。 + for i, (b64, hit) in enumerate(tiles, 1): + self._render_citation_card(b64, hit, i, last=(i == len(tiles))) + + def _render_citation_card(self, b64: str, hit: dict, idx: int, last: bool = True): + """白底描边卡片:缩略图 + 标题 + 页码/相似度。点缩略图看大图,点其余开 PDF。""" + pad = T.SPACE["md"] + w = max(240, self._content_w) + thumb_box = (int(74 * T.UI_SCALE), int(96 * T.UI_SCALE)) + + card = tk.Canvas( + self.frame, + width=w, + height=int(104 * T.UI_SCALE), # 占位高度,量完信息块后修正 + bg=T.c("bg"), + highlightthickness=0, + bd=0, + cursor="hand2", + ) + card.pack( + fill="x", + padx=self._inset, + pady=(0, T.SPACE["msg"] if last else T.SPACE["sm"]), + ) + + title = self._titles.get(hit.get("article_id")) or title_of(hit.get("url", "")) + x = pad + thumb_lbl = None + thumb_h = 0 + if b64: + try: + img = decode_b64_image(b64) + img.thumbnail(thumb_box, Image.LANCZOS) + thumb = W.photo_of(img) + self._photos.append(thumb) + thumb_lbl = tk.Label( + card, image=thumb, bg=T.c("card"), bd=0, cursor="hand2" + ) + thumb_lbl.image = thumb + x += thumb.width() + pad + thumb_h = thumb.height() + except Exception: + thumb_lbl = None + + tw = max(120, w - x - pad) + + # 信息块:交给 Frame + pack 自己排垂直布局,避免手算 y 导致换行标题被盖住 + info = tk.Frame(card, bg=T.c("card")) + title_lbl = tk.Label( + info, + text=f"[{idx}] {title}", + bg=T.c("card"), + fg=T.c("accent_text"), # 白底上的强调色**文字**用 accent_text(4.33:1 的 + # accent 是给填充/描边用的,小字不加粗时对比度不够) + font=T.font("label"), + justify="left", + anchor="w", + wraplength=tw, + cursor="hand2", + ) + title_lbl.pack(anchor="w", fill="x") + + page = int(hit.get("tile_index", 0)) + 1 + meta = f"第 {page} 页" + if int(hit.get("chunk_index", 0) or 0) > 0: + meta += f" · 第 {int(hit['chunk_index']) + 1} 段" + meta += f" · 相似度 {float(hit.get('score', 0.0)):.3f}" + meta_lbl = tk.Label( + info, + text=meta, + bg=T.c("card"), + fg=T.c("text_muted"), + font=T.font("caption"), + justify="left", + anchor="w", + ) + meta_lbl.pack(anchor="w", fill="x", pady=(T.SPACE["xs"], 0)) + + hint_lbl = tk.Label( + info, + text="点击打开 PDF", + bg=T.c("card"), + fg=T.c("text_faint"), + font=T.font("micro"), + justify="left", + anchor="w", + cursor="hand2", + ) + hint_lbl.pack(anchor="w", fill="x", pady=(T.SPACE["sm"], 0)) + + info.update_idletasks() + h = max(int(80 * T.UI_SCALE), thumb_h, info.winfo_reqheight()) + 2 * pad + card.configure(height=h) + + # 三种外观:常态 / 悬停 / 键盘聚焦(聚焦用主色描边,与 hover 区分开) + normal = W.rounded_photo( + w, h, T.RADIUS["md"], T.c("card"), T.c("bg"), outline=T.c("border") + ) + hover = W.rounded_photo( + w, + h, + T.RADIUS["md"], + T.c("card_hover"), + T.c("bg"), + outline=T.c("border_strong"), + ) + focused = W.rounded_photo( + w, + h, + T.RADIUS["md"], + T.c("card_hover"), + T.c("bg"), + outline=T.c("focus"), + outline_w=2, + ) + self._photos.extend([normal, hover, focused]) + bg_id = card.create_image(0, 0, anchor="nw", image=normal) + card.tag_lower(bg_id) # 背景图是后建的,必须沉底,否则会盖住信息块 + + card.create_window(x, pad, anchor="nw", window=info, width=tw) + if thumb_lbl is not None: + card.create_window( + pad, max(pad, (h - thumb_h) // 2), anchor="nw", window=thumb_lbl + ) + + def repaint(color: str): + def walk(widget): + for child in widget.winfo_children(): + try: + child.configure(bg=color) + except Exception: + pass + walk(child) + + walk(card) + + def open_pdf(_e=None): + self._open_hit(hit, title) + + # 悬停与聚焦会同时发生,用一份状态决定最终外观(聚焦优先) + state = {"hover": False, "focus": False} + + def paint(_e=None): + if state["focus"]: + card.itemconfigure(bg_id, image=focused) + repaint(T.c("card_hover")) + elif state["hover"]: + card.itemconfigure(bg_id, image=hover) + repaint(T.c("card_hover")) + else: + card.itemconfigure(bg_id, image=normal) + repaint(T.c("card")) + + def set_state(key, value): + def apply(_e=None): + state[key] = value + paint() + + return apply + + W.bind_hover(card, set_state("hover", True), set_state("hover", False)) + card.bind("", set_state("focus", True), add="+") + card.bind("", set_state("focus", False), add="+") + + # 键盘可达:Tab 聚焦,Enter / Space 打开 PDF + card.configure(takefocus=1) + for key in ("", "", ""): + card.bind(key, open_pdf, add="+") + + for widget in (card, info, title_lbl, meta_lbl, hint_lbl): + widget.bind("", open_pdf, add="+") + if thumb_lbl is not None: + thumb_lbl.bind( + "", lambda _e: self._open_image(b64, title), add="+" + ) + + # ---- 加载态 ---- + + def _start_loading(self, text: str = "正在检索相关页面"): + if self._loading_active: + return + self._loading_active = True + self._loading_base = text + self._loading_frame = tk.Frame(self.frame, bg=T.c("bg")) + self._loading_frame.pack(fill="x", padx=self._inset, pady=(0, T.SPACE["msg"])) + self._loading_label = tk.Label( + self._loading_frame, + text=text, + bg=T.c("bg"), + fg=T.c("text_muted"), + font=T.font("body"), + anchor="w", + justify="left", + ) + self._loading_label.pack(fill="x") + + skel = tk.Frame(self._loading_frame, bg=T.c("bg")) + skel.pack(fill="x", pady=(T.SPACE["sm"], 0)) + for frac in (0.92, 0.68): + bar = W.rounded_photo( + max(40, int(self._content_w * frac)), + int(12 * T.UI_SCALE), + T.RADIUS["sm"], + T.c("skeleton"), + T.c("bg"), + ) + self._photos.append(bar) + lbl = tk.Label(skel, image=bar, bg=T.c("bg"), bd=0) + lbl.image = bar + lbl.pack(anchor="w", pady=(0, T.SPACE["xs"])) + + self._dot = 0 + self._tick_loading() + self._scroll_bottom(True) + + def _tick_loading(self): + if not self._loading_active: + return + self._dot = (self._dot + 1) % 4 + stick = self._at_bottom() + try: + self._loading_label.configure(text=self._loading_base + "·" * self._dot) + except Exception: + return + self._loading_job = self.root.after(420, self._tick_loading) + self._scroll_bottom(stick) + + def _set_loading_text(self, text: str): + self._loading_base = text + if self._loading_active: + try: + self._loading_label.configure(text=text) + except Exception: + pass + + def _cancel_loading_job(self): + if self._loading_job: + try: + self.root.after_cancel(self._loading_job) + except Exception: + pass + self._loading_job = None + + def _stop_loading(self): + self._cancel_loading_job() + self._loading_active = False + frame = getattr(self, "_loading_frame", None) + if frame is not None: + try: + frame.destroy() + except Exception: + pass + self._loading_frame = None + + # ---------- 滚动 ---------- + + def _at_bottom(self) -> bool: + """当前视图是否停在底部。必须在改动内容【之前】调用才有意义。""" + try: + return self.canvas.yview()[1] >= 0.995 + except Exception: + return True + + def _view_state(self) -> tuple[bool, float]: + """(是否停在底部, 顶部比例)。重建界面前取一次,重建后据此还原。""" + try: + top, bottom = self.canvas.yview() + return bottom >= 0.995, top + except Exception: + return True, 0.0 + + def _scroll_bottom(self, stick: bool = True): + """stick=True 时把视图吸到底部;False 表示用户正在上翻,不去抢。""" + if not stick: + return + + def go(): + try: + # 顺序很关键:先让几何算完并刷新滚动范围,再移动。 + # 否则 moveto(1.0) 会按【旧】范围钳制,结果停在中途。 + self.canvas.update_idletasks() + h = max(self.frame.winfo_reqheight(), self.canvas.winfo_height()) + self.canvas.configure(scrollregion=(0, 0, self.canvas.winfo_width(), h)) + self.canvas.yview_moveto(1.0) + except Exception: + pass + + self.root.after_idle(go) + + # ---------- 交互 ---------- + + def _on_enter(self, event): + if event.state & 0x0001: # Shift+Enter → 换行 + return None + self._on_send() + return "break" + + def _clear_input(self): + self.input.delete("1.0", "end") + + def _on_send(self): + if self._busy: + return + text = self.input.get("1.0", "end").strip() + if not text: + return + self.input.delete("1.0", "end") + self._add_user(text) + + if not self._serve_ready: + self._add_note("检索服务尚未就绪,正在启动,请稍候几秒再试…") + threading.Thread(target=self._ensure_serve, daemon=True).start() + return + + self._run_query(text) + + def _run_query(self, text: str): + """检索 + 生成。重试时复用同一个问题,不再追加用户气泡。""" + self._last_question = text + self._stopped = False + self._stop_event.clear() + # 记进历史("重新生成"同问同答,会在存储层合并成一条) + self._hist_entry = self.history.remember(text) + self._hist_pos = None + self._hist_draft = "" + # 新问一轮就离开"打开旧问答"的状态;这一步顺带把新条目刷进侧栏 + self._current_hist = None + if self.sidebar is not None: + self.sidebar.set_current(None) + self._busy = True + self._update_send_button() + self._show_stop(True) + self._start_loading("正在检索相关页面") + threading.Thread(target=self._do_search, args=(text,), daemon=True).start() + + def _on_stop(self): + """停止生成:置取消信号,后台循环下一轮就退出,已收到的文字保留。""" + if not self._busy: + return + self._stopped = True + self._stop_event.set() + if self._loading_active: + self._set_loading_text("正在停止…") + self._update_stop_button() + + def _on_regenerate(self): + """重新生成:用上一次的问题重跑。答案为空或生成失败时给出这个入口。""" + if self._busy or not self._last_question: + return + self._run_query(self._last_question) + + def _select_all_input(self, _e=None): + self.input.tag_add("sel", "1.0", "end-1c") + self.input.mark_set("insert", "1.0") + return "break" + + # ---------- 查询历史 ---------- + + def _on_history(self): + """顶栏「历史」= 收起 / 展开左侧栏。""" + self._sidebar_open = not self._sidebar_open + self._rebuild_ui() + + def _refresh_sidebar(self): + if self.sidebar is not None: + self.sidebar.refresh() + + def _new_chat(self): + """开一段新对话:主区域回到欢迎语,历史一条不动。""" + if self._busy: # 生成中换视图会让答案落到错误的对话里 + return + self._current_hist = None + self.messages = [] + self._render_all((True, 0.0)) + self._add_welcome() + self._clear_input() + if self.sidebar is not None: + self.sidebar.set_current(None) + self._scroll_bottom(True) + + def _open_history_item(self, item: dict): + """打开一条历史:还原当时的提问 / 答案 / 引用卡片。 + + 答案和引用页都存在历史里,所以这里不检索、不调模型;缩略图直接读本地 tiles。 + """ + if self._busy: + return + self._current_hist = item + if self.sidebar is not None: + self.sidebar.set_current(item) + self._clear_input() + self.messages = [] + self._render_all((False, 0.0)) + self._add_user(item.get("question", "")) + answer = item.get("answer") or "" + if answer: + self._append({"kind": "answer", "text": answer, "streaming": False}) + else: + self._add_note("这条提问没有存下答案,点它右边的「重问」可以重新生成。") + tiles = history_tiles(item) + if tiles: + self._append({"kind": "citations", "tiles": tiles}) + elif (item.get("question") or "").strip(): + # 这条记录里没有引用页(存引用页的功能上线前留下的老记录,或者检索成功 + # 但没走完生成的那一轮)。按原问题补跑一次检索,别让引用卡片就这么空着。 + self._refetch_refs(item) + self._scroll_bottom(True) + + def _refetch_refs(self, item: dict): + """给没存下引用页的记录补一次检索,把引用卡片补齐。 + + 只问本地检索服务,没有模型调用,所以打开旧问答依然是"不花一分钱"。补到的页 + 写回历史,下次点开直接就有;补不到就明说没补到 —— 空着不吭声会让人以为 + 这条记录本来就没有引用。 + """ + if self._refetching or not (item.get("question") or "").strip(): + return + if not self._serve_ready: + self._add_note("这条记录当初没有存下引用页,当前检索服务未就绪,没法补齐。") + return + self._refetching = True + question = item["question"] + self._start_loading("正在补齐这条记录的引用页") + + def work(): + try: + hits = search(question)["results"][0]["hits"] + except Exception: + hits = [] + self.root.after(0, lambda: apply(hits)) + + def apply(hits: list): + self._refetching = False + self._stop_loading() + tiles = tiles_from(hits) + if tiles: + # 数据先写回历史:"这条记录引用过哪几页"是跟这条记录有关的事实, + # 与用户此刻正在看哪一条无关。补到一次就存下来,下次点开不用再查。 + self.history.update( + item, hits=len(tiles), refs=refs_of([h for _b64, h in tiles]) + ) + self._refresh_sidebar() + # 这期间用户可能已经点开别的记录、或者又问了一轮 —— 内容就别往新对话里塞 + if self._current_hist is not item: + return + if not tiles: + self._add_note( + "这条记录当初没有存下引用页,刚按原问题补检索也没拿到截图。" + "「重问」可以重新生成完整的一轮。" + ) + return + self._append( + { + "kind": "citations", + "tiles": tiles, + "note": "这条记录当初没有存下引用页。以下是按原问题重新检索到的" + "页面,未必是当时那几页;要还原当时那一轮请点「重问」。", + } + ) + + threading.Thread(target=work, daemon=True).start() + + def _reask(self, question: str): + """从历史里点一条:填进输入框并直接提问。""" + self._replace_input(question) + self._on_send() + + def _replace_input(self, text: str): + self.input.delete("1.0", "end") + self.input.insert("1.0", text) + self.input.mark_set("insert", "end-1c") + self.input.focus_set() + + def _on_input_up(self, _e=None): + """↑:光标在首行时召回上一条历史问题。""" + on_first_line = self.input.index("insert").split(".")[0] == "1" + if self._hist_pos is not None or on_first_line: + return self._recall_history(+1) + return None # 交给 tk:光标上移一行 + + def _on_input_down(self, _e=None): + """↓:只有在召回态才接管(往回翻到更新的一条)。""" + if self._hist_pos is None: + return None + return self._recall_history(-1) + + def _recall_history(self, delta: int): + """``delta=+1`` 往更早翻,``-1`` 往更新翻;翻过头就恢复召回前的草稿。""" + items = self.history.questions() + if not items: + return None + if self._hist_pos is None: + self._hist_draft = self.input.get("1.0", "end-1c") + self._hist_pos = 0 + else: + self._hist_pos += delta + if self._hist_pos < 0: # 回到最新之后 = 回到草稿 + self._hist_pos = None + self._replace_input(self._hist_draft) + return "break" + self._hist_pos = min(self._hist_pos, len(items) - 1) + self._replace_input(items[self._hist_pos]) + return "break" + + def _send_hover_on(self): + self._send_hover = True + self._update_send_button() + + def _send_hover_off(self): + self._send_hover = False + self._update_send_button() + + def _stop_hover_on(self): + self._stop_hover = True + self._update_stop_button() + + def _stop_hover_off(self): + self._stop_hover = False + self._update_stop_button() + + def _show_stop(self, show: bool): + """停止按钮的显隐 = 画布窗口项的显示/隐藏(位置一直给它留着)。""" + if show == self._stop_shown: + return + self._stop_shown = show + if show: + self._update_stop_button() + if self._stop_item is not None: + self.input_box.itemconfigure( + self._stop_item, state="normal" if show else "hidden" + ) + + def _render_stop_image(self): + """画「停止生成」的按钮图(不管此刻显不显示 —— 建界面时要先量宽度)。""" + fill = T.c("border_strong") if self._stop_hover else T.c("card") + img = W.render_label_box( + "停止生成", + "button", + T.c("text"), + T.c("input_bg"), + box_fill=fill, + outline=T.c("border_strong"), + radius=T.RADIUS_PILL, + pad_x=T.SPACE["lg"], + pad_y=T.SPACE["sm"], + align="center", + ) + photo = W.photo_of(img) + self._stop_photo = photo + self._stop_img_w = img.width + return photo + + def _update_stop_button(self): + photo = self._render_stop_image() + if not self._stop_shown: + return + try: + self.stop_btn.configure(image=photo, cursor="hand2") + self.stop_btn.image = photo + except Exception: + pass + + def _render_send_image(self): + """画「发送 / 生成中」的按钮图(按当前 busy / hover 态)。""" + if self._busy: + fill, fg, label, cursor = ( + T.c("border"), + T.c("text_faint"), + "生成中", + "arrow", + ) + elif self._send_hover: + fill, fg, label, cursor = ( + T.c("accent_hover"), + T.c("on_accent"), + "发送", + "hand2", + ) + else: + fill, fg, label, cursor = T.c("accent"), T.c("on_accent"), "发送", "hand2" + img = W.render_label_box( + label, + "button", + fg, + T.c("input_bg"), + box_fill=fill, + radius=T.RADIUS_PILL, + pad_x=T.SPACE["lg"], + pad_y=T.SPACE["sm"], + align="center", + min_w=T.SEND_MIN_W, + ) + photo = W.photo_of(img) + self._send_photo = photo + self._send_img_w = img.width + return photo, cursor + + def _update_send_button(self): + photo, cursor = self._render_send_image() + try: + self.send_btn.configure(image=photo, cursor=cursor) + self.send_btn.image = photo + except Exception: + pass + + # ---------- 检索 → 生成 ---------- + + def _do_search(self, text: str): + try: + resp = search(text) + hits = resp["results"][0]["hits"] + except Exception as e: + self.root.after(0, self._stop_loading) + self.root.after(0, lambda err=e: self._show_error(err)) + self.root.after(0, self._clear_busy) + return + if not hits: + self.root.after(0, self._stop_loading) + self.root.after( + 0, lambda: self._add_note("没有找到相关结果,换个说法试试。") + ) + self.root.after(0, self._clear_busy) + return + self._generate(text, hits) # 仍在后台线程,继续生成答案 + + def _show_results(self, hits: list): + """VLM 不可用时的降级:只展示检索到的引用卡片。 + + 顺带把这轮检索到的页记进历史。这一轮没有答案,但"引用过哪几页"是检索的 + 结果、和答案不是一回事,不该跟着答案一起丢 —— 否则回头点开这条记录, + 侧栏写着「未完成」、引用卡片一张也不剩。 + """ + tiles = tiles_from(hits) + if tiles: + self._append({"kind": "citations", "tiles": tiles}) + else: + self._add_note("没有可显示的截图。") + self.history.update( + self._hist_entry, + hits=len(tiles), + refs=refs_of([h for _b64, h in tiles]), + ) + self._refresh_sidebar() + + def _generate(self, text: str, hits: list): + """准备截图 → 调 VLM 流式生成 → 前端流式显示答案 + 引用卡片。运行于后台线程。""" + if self._stop_event.is_set(): + # 检索期间用户就点了停止,别再开生成 + self.root.after(0, self._stop_loading) + self.root.after(0, lambda: self._add_note("已停止。")) + self.root.after(0, self._clear_busy) + return + + images = prepare_images(hits) + if not images: + self.root.after(0, self._stop_loading) + self.root.after( + 0, + lambda: self._add_error( + "检索到了结果,但截图加载失败。以下为检索到的相关页面:" + ), + ) + self.root.after(0, lambda: self._show_results(hits)) + self.root.after(0, self._clear_busy) + return + + cfg = _load_vlm_config() + if not cfg["api_key"]: + self.root.after(0, self._stop_loading) + self.root.after( + 0, + lambda: self._add_error( + "未找到 VLM API key。请设置环境变量 PIXELRAG_VLM_API_KEY,\n" + "或确保 ~/.claude/settings.json 里有 ANTHROPIC_AUTH_TOKEN。\n" + "以下为检索到的相关页面:" + ), + ) + self.root.after(0, lambda: self._show_results(hits)) + self.root.after(0, self._clear_busy) + return + + self.root.after(0, lambda: self._set_loading_text("正在生成答案")) + self._answer_ready = threading.Event() + self.root.after(0, self._begin_answer) + if not self._answer_ready.wait(timeout=5): + self.root.after(0, self._clear_busy) + return + + full = "" + stopped = False + failed = False + try: + self._gen = vlm_stream(images, text, cfg) + for chunk in self._gen: + if self._stop_event.is_set(): + stopped = True + break + full += chunk + self.root.after(0, lambda t=full: self._set_answer(t)) + except Exception as e: + if self._stop_event.is_set(): + stopped = True # 主动中止引发的异常不算失败,别再弹一次错 + else: + failed = True # 已经报过错了,_finish_answer 别再补一条"没返回内容" + err = str(e) + self.root.after( + 0, lambda m=err: self._add_error(f"生成失败:{m}", retry=True) + ) + if full: + self.root.after(0, lambda t=full: self._set_answer(t)) + finally: + # 显式 close 掉生成器,底层的 HTTP 流立刻断开,不用等 GC + gen, self._gen = self._gen, None + if gen is not None: + try: + gen.close() + except Exception: + pass + self.root.after(0, lambda: self._finish_answer(images, stopped, failed)) + + def _begin_answer(self): + self._stop_loading() + msg = {"kind": "answer", "text": "", "streaming": True} + self.messages.append(msg) + self._render_message(msg) + self._answer_msg = msg + self._scroll_bottom(True) + self._answer_ready.set() + + def _set_answer(self, text: str): + msg = self._answer_msg + if msg is None: + return + msg["text"] = text + if self._paint_job is None: + self._paint_job = self.root.after( + T.MOTION["stream_throttle"], self._paint_answer + ) + + def _paint_answer(self): + self._paint_job = None + msg, txt = self._answer_msg, self._answer_text + if msg is None or txt is None: + return + text = msg.get("text", "") + stick = self._at_bottom() + try: + self._set_answer_into(txt, (text + " ▌") if text else "…") + except tk.TclError: + return # 主题切换把控件重建了,这一帧直接丢掉 + self._scroll_bottom(stick) + + def _finish_answer(self, images, stopped: bool = False, failed: bool = False): + if self._paint_job: + try: + self.root.after_cancel(self._paint_job) + except Exception: + pass + self._paint_job = None + + msg = self._answer_msg + self._answer_msg = None + self._answer_text = None + + if msg is not None: + msg["streaming"] = False + text = msg.get("text", "") + if stopped: + # 保留已经吐出来的部分,补一句话说明是用户主动停的 + msg["text"] = ( + (text.rstrip() + "\n\n(已停止生成)") + if text.strip() + else "(已停止生成)" + ) + self._redraw_answer(msg) + elif not text.strip(): + # 一个字都没有:留个空气泡没意义,换成可重试的错误。 + # failed 时上面已经报过具体原因了,这里不再补一条重复的。 + self._drop_message(msg) + if not failed: + self._add_error("模型没有返回任何内容。", retry=True) + else: + self._redraw_answer(msg) + + # 回填历史:命中页数 + 答案 + 引用页。存下引用页是为了以后点开这条历史时 + # 能把引用卡片一起还原 —— 缩略图从本地 tiles 读,不用再问一次检索服务。 + self.history.update( + self._hist_entry, + hits=len(images), + answer=(msg or {}).get("text", ""), + refs=refs_of([h for _b64, h in images]), + ) + self._refresh_sidebar() + + if images: + self._append({"kind": "citations", "tiles": list(images)}) + self._scroll_bottom(True) + self._clear_busy() + + def _redraw_answer(self, msg: dict): + """定稿重绘:分段代码块 + 复制按钮。""" + widget = msg.get("_widget") + if widget is not None: + try: + widget.destroy() + except Exception: + pass + self._render_message(msg) + + def _drop_message(self, msg: dict): + """把一条消息从数据模型和界面上一起撤掉。""" + try: + self.messages.remove(msg) + except ValueError: + pass + widget = msg.get("_widget") + if widget is not None: + try: + widget.destroy() + except Exception: + pass + + def _show_error(self, e: Exception): + self._add_error( + f"查询失败:{e}\n请确认检索服务已启动、索引文件完整。", retry=True + ) + + def _clear_busy(self): + self._busy = False + self._stop_hover = False + self._show_stop(False) + self._update_send_button() + + # ---------- 主题 ---------- + + def _toggle_theme(self): + T.set_mode(T.other_mode()) + W._clear_cache() + self._apply_theme() + + def _apply_theme(self): + """换主题 = 用令牌重放全部界面(tkinter 不支持热改样式)。""" + self._rebuild_ui() + + def _rebuild_ui(self): + """按当前令牌重放整个界面。换主题、收起/展开侧栏都走这里。 + + tkinter 没有"只重画"这条路,一律销毁重建;输入到一半的内容和滚动位置 + 在销毁前取出来,重建后放回去。 + """ + draft = self.input.get("1.0", "end-1c") + state = self._view_state() # 必须在销毁旧界面之前取 + + for w in self.root.winfo_children(): + w.destroy() + self._photos.clear() + self._answer_text = None + + self.root.configure(bg=T.c("bg")) + self._build_ui() + self.input.insert("1.0", draft) + self._render_all(view_state=state) + self._set_status() + self._update_send_button() + + # ---------- 打开图片 / PDF ---------- + + def _open_hit(self, hit: dict, title: str): + url = hit.get("url", "") + if url and os.path.exists(url): + os.startfile(url) + else: + self._add_note(f"找不到 PDF 文件:{title}") + + def _open_image(self, b64: str, title: str): + try: + img = decode_b64_image(b64) + except Exception: + return + win = tk.Toplevel(self.root) + win.title(title or "页面截图") + win.configure(bg=T.c("bg")) + max_w = int(win.winfo_screenwidth() * 0.85) + max_h = int(win.winfo_screenheight() * 0.85) + img.thumbnail((max_w, max_h), Image.LANCZOS) + photo = W.photo_of(img) + self._photos.append(photo) + lbl = tk.Label(win, image=photo, bg=T.c("bg"), bd=0) + lbl.image = photo + lbl.pack() + # 焦点必须落在预览窗内,Escape 才会送到这里(否则被主窗吃掉) + win.bind("", lambda _e: win.destroy()) + win.transient(self.root) + win.focus_set() + + # ---------- 服务 ---------- + + def _ensure_serve(self): + if check_health(): + self._serve_ready = True + self.root.after(0, self._set_status) + return + self.root.after(0, self._set_status, "starting") + start_serve_process() + deadline = time.time() + 180 + while time.time() < deadline: + time.sleep(2) + if check_health(): + self._serve_ready = True + self.root.after(0, self._set_status) + return + self.root.after(0, self._set_status, "failed") + + +def main(): + try: + import ctypes + + ctypes.windll.shcore.SetProcessDpiAwareness(1) # 高分屏下不再被位图拉伸 + except Exception: + pass + T.set_ui_scale(screen_scale()) + root = tk.Tk() + App(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/pixelrag_history.py b/pixelrag_history.py new file mode 100644 index 0000000..fa027c6 --- /dev/null +++ b/pixelrag_history.py @@ -0,0 +1,556 @@ +"""查询历史:落盘存储 + 左侧历史栏 + 输入框上下键召回。 + +历史写在用户目录下的 ``~/.pixelrag/history.json``(不放仓库里,免得污染项目), +一条记录 = 时间 / 问题 / 命中页数 / 答案 / 引用页。存储只做增删查,不涉及任何检索逻辑。 + +答案与引用页都存下来,所以点开一条历史 = 直接还原当时那轮问答,不必重跑模型。 + +界面是主窗口左侧的一栏(DeepSeek 那种排版),不是弹窗 —— 弹窗会挡住内容, +而看历史本来就是为了对照着看。 +""" + +from __future__ import annotations + +import json +import os +import time +import tkinter as tk +import tkinter.font as tkfont +from collections.abc import Callable +from datetime import datetime, timezone + +import pixelrag_theme as T +import pixelrag_widgets as W + +HISTORY_PATH = os.path.join(os.path.expanduser("~"), ".pixelrag", "history.json") +MAX_ITEMS = 200 # 只留最近这么多条 +REUSE_WINDOW = 300 # 秒;同一个问题在这段时间内重复提交(含"重新生成")算同一条 +SIDEBAR_W = 260 # 侧栏逻辑宽度(会按屏幕 DPI 缩放) + + +# --------------------------------------------------------------------------- +# 存储 +# --------------------------------------------------------------------------- + + +class History: + """查询历史。读写都容错 —— 历史存不下来不应该影响问答。""" + + def __init__(self, path: str | None = None): + # 取调用时的模块级常量(而不是把它当默认参数固化),改 HISTORY_PATH 能生效 + self.path = path or HISTORY_PATH + self._items: list[dict] = self._load() + + def _load(self) -> list[dict]: + try: + with open(self.path, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return [] + if not isinstance(data, list): + return [] + return [d for d in data if isinstance(d, dict) and d.get("question")] + + def items(self) -> list[dict]: + """新的在前(侧栏按这个顺序显示)。""" + return list(reversed(self._items)) + + def questions(self) -> list[str]: + """问题列表,新的在前(输入框 ↑/↓ 用)。""" + return [d["question"] for d in self.items()] + + def remember(self, question: str) -> dict: + """记一条提问。刚问过的同一个问题(比如点"重新生成")复用原条目。""" + now = time.time() + if self._items: + last = self._items[-1] + if ( + last.get("question") == question + and now - float(last.get("ts") or 0) < REUSE_WINDOW + ): + last["time"] = _iso(now) + last["ts"] = now + self._save() + return last + item = { + "question": question, + "time": _iso(now), + "ts": now, + "hits": None, + "answer": "", + "refs": [], + } + self._items.append(item) + if len(self._items) > MAX_ITEMS: + del self._items[:-MAX_ITEMS] + self._save() + return item + + def update( + self, + item: dict | None, + hits: int | None = None, + answer: str | None = None, + refs: list[dict] | None = None, + ): + """问答结束后回填命中页数、答案与引用页。""" + if not item: + return + if hits is not None: + item["hits"] = int(hits) + if answer is not None: + item["answer"] = answer + if refs is not None: + item["refs"] = list(refs) + self._save() + + def clear(self) -> None: + self._items = [] + self._save() + + def _save(self) -> None: + try: + os.makedirs(os.path.dirname(self.path), exist_ok=True) + tmp = self.path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(self._items, f, ensure_ascii=False, indent=1) + os.replace(tmp, self.path) # 原子替换,写一半断电也不会留半截文件 + except Exception: + pass + + +def _iso(ts: float) -> str: + """带时区偏移的 ISO 时间戳(换时区/夏令时也不会看错)。""" + return ( + datetime.fromtimestamp(ts, tz=timezone.utc) + .astimezone() + .isoformat(timespec="seconds") + ) + + +def _pretty(iso: str) -> str: + """显示成本地时间。统一用 ``MM-DD HH:MM``:每行等宽,看起来才齐。""" + try: + dt = datetime.fromisoformat(iso).astimezone() + except Exception: + return "" + return dt.strftime("%m-%d %H:%M") + + +# --------------------------------------------------------------------------- +# 左侧历史栏 +# --------------------------------------------------------------------------- + + +def _elide(text: str, font_spec: tuple, max_px: int) -> str: + """单行截断:太长就砍到放得下再加省略号(侧栏一行只放得下一个问题)。""" + f = tkfont.Font(font=font_spec) + text = " ".join(text.split()) # 换行/多余空白压成单空格,免得标签里折行 + if f.measure(text) <= max_px: + return text + lo, hi = 0, len(text) + while lo < hi: + mid = (lo + hi + 1) // 2 + if f.measure(text[:mid] + "…") <= max_px: + lo = mid + else: + hi = mid - 1 + return text[:lo] + "…" + + +def _rounded_bg(host: tk.Widget, bg: str, radius: int): + """在 ``host`` 里铺一张"跟着宿主尺寸变"的圆角底图,返回换填充色的函数。 + + tk 没有 CSS 圆角,只能画一张图贴上去。要点有三个: + + - Canvas 用 ``place`` 铺满宿主、**不参与尺寸计算**,所以宿主的宽高仍然由里面的 + 内容(文字)决定 —— 行高不用自己算; + - Canvas 必须是最后创建、再 ``lower()``:同一父窗口里后创建的子窗口在上层, + 不沉底就会把文字盖住(Label 的文字是父窗口自己画的,子窗口一定盖得住); + - 同尺寸同色的图由 ``W.rounded_photo`` 缓存,200 行也只会画几张图。 + + ``bg`` 是圆角外面"挖空"那部分透出的颜色 —— 必须是宿主的底色,不是宿主的底色 + 就会在圆角处露出方块。 + """ + cv = tk.Canvas(host, bg=bg, highlightthickness=0, bd=0) + cv.place(x=0, y=0, relwidth=1, relheight=1) + item = cv.create_image(0, 0, anchor="nw") + state: dict = {"fill": None, "photo": None} + + def paint(_e=None): + w, h = cv.winfo_width(), cv.winfo_height() + if w <= 1 or h <= 1 or state["fill"] is None: + return + photo = W.rounded_photo(w, h, radius, state["fill"], bg) + if photo is not state["photo"]: + state["photo"] = photo # 必须持有引用,否则会被 GC 成白块 + cv.itemconfigure(item, image=photo) + + cv.bind("", paint) + host.after_idle(paint) + + def set_fill(fill: str): + state["fill"] = fill + paint() + + # 注意不能写 cv.lower():Canvas 把 lower 覆写成了 tag_lower(画布里的图层操作)。 + # 要沉的是**窗口**,得回到 Misc 的那个 lower。 + tk.Misc.lower(cv) + # 也返回画布:它铺满宿主,子控件够不到的边缘(圆角那两头)只有它收得到鼠标事件 + return cv, set_fill + + +class HistorySidebar(tk.Frame): + """主窗口左侧的历史栏:新对话 / 历史列表 / 清空。 + + - 左键点一条 = ``on_open(item)``,由调用方还原那轮问答 + - 行尾「重问」= ``on_reask(question)``,用同一个问题重新检索生成 + - 右键 = 打开 / 重问 / 复制问题 / 复制答案 + """ + + def __init__( + self, + parent, + history: History, + on_open: Callable[[dict], None], + on_reask: Callable[[str], None] | None = None, + on_new: Callable[[], None] | None = None, + on_clear: Callable[[], None] | None = None, + ): + super().__init__(parent, bg=T.c("surface"), width=int(SIDEBAR_W * T.UI_SCALE)) + self.pack_propagate(False) # 固定宽度,别被里面的长文本撑开 + self.history = history + self.on_open = on_open + self.on_reask = on_reask + self.on_new = on_new + self.on_clear = on_clear + self._current: dict | None = None + # 行内条目离侧栏边缘的留白:圆角块要缩进来一点才看得出是"块" + self._pad = T.SPACE["sm"] + # 一行放问题的宽度:去掉两端留白、文本块的左留白、行尾固定的「重问」槽, + # 再减掉滚动条 —— 滚动条一露面画布就窄这么多,不减的话省略号会被裁掉。 + self._text_w = max( + 80, + int(SIDEBAR_W * T.UI_SCALE) + - 2 * self._pad + - 2 * T.SPACE["sm"] + - int(34 * T.UI_SCALE) + - T.SCROLLBAR_W, + ) + self._build() + self.refresh() + + # ---------- 骨架 ---------- + + def _build(self): + top = tk.Frame(self, bg=T.c("surface")) + top.pack(side="top", fill="x", padx=T.SPACE["sm"], pady=T.SPACE["sm"]) + # 「+ 新对话」是整条圆角胶囊(DeepSeek 侧栏顶部就是这样),不是描边按钮。 + # 圆角底图必须**后建再沉底**:Label 的文字是它自己画的,子窗口一定盖得住, + # 底图先建就会被 Label 反过来糊住。Label 再向内缩一点,它的方角才不会啃到圆角。 + self.new_btn = tk.Label( + top, + text="+ 新对话", + bg=T.c("surface"), + fg=T.c("text"), + font=T.font("button"), + anchor="w", + cursor="hand2", + padx=T.SPACE["sm"], + pady=T.SPACE["sm"], + ) + self.new_btn.pack(fill="x", padx=T.SPACE["xs"], pady=T.SPACE["xs"]) + new_cv, set_new_fill = _rounded_bg(top, T.c("surface"), T.RADIUS["md"]) + + def new_hover(on: bool): + fill = T.c("nav_hover") if on else T.c("surface") + set_new_fill(fill) + self.new_btn.configure(bg=fill) + + self.new_btn.bind("", lambda _e: self._new()) + new_cv.configure(cursor="hand2") + new_cv.bind("", lambda _e: self._new()) + W.bind_hover( + self.new_btn, + on_enter=lambda: new_hover(True), + on_leave=lambda: new_hover(False), + ) + W.make_focusable(self.new_btn, T.c("surface"), self._new) + tk.Frame(self, bg=T.c("border"), height=1).pack(side="top", fill="x") + + head = tk.Frame(self, bg=T.c("surface")) + head.pack(side="top", fill="x", padx=T.SPACE["md"], pady=(T.SPACE["md"], 0)) + tk.Label( + head, + text="查询历史", + bg=T.c("surface"), + fg=T.c("text_faint"), + font=T.font("caption"), + ).pack(side="left") + self.count_lbl = tk.Label( + head, + text="", + bg=T.c("surface"), + fg=T.c("text_faint"), + font=T.font("micro"), + ) + self.count_lbl.pack(side="right") + + # 先 pack 底部,再让列表吃掉剩下的空间 —— 否则底部会被挤出去 + foot = tk.Frame(self, bg=T.c("surface")) + foot.pack(side="bottom", fill="x") + tk.Frame(foot, bg=T.c("border"), height=1).pack(side="top", fill="x") + self.clear_btn = tk.Label( + foot, + text="清空历史", + bg=T.c("surface"), + fg=T.c("text_faint"), + font=T.font("caption"), + anchor="w", + cursor="hand2", + padx=T.SPACE["sm"], + pady=T.SPACE["sm"], + ) + self.clear_btn.pack(fill="x", padx=T.SPACE["sm"], pady=T.SPACE["xs"]) + self.clear_btn.bind("", lambda _e: self._clear()) + W.bind_hover( + self.clear_btn, + on_enter=lambda: self.clear_btn.configure(fg=T.c("danger")), + on_leave=lambda: self.clear_btn.configure(fg=T.c("text_faint")), + ) + W.make_focusable(self.clear_btn, T.c("surface"), self._clear) + + body = tk.Frame(self, bg=T.c("surface")) + body.pack(side="top", fill="both", expand=True, pady=(T.SPACE["xs"], 0)) + self.canvas = tk.Canvas(body, bg=T.c("surface"), highlightthickness=0, bd=0) + self.scroll = tk.Scrollbar( + body, + orient="vertical", + command=self.canvas.yview, + bg=T.c("scrollbar"), + troughcolor=T.c("surface"), + activebackground=T.c("border_strong"), + borderwidth=0, + width=T.SCROLLBAR_W, + relief="flat", + elementborderwidth=0, + ) + self.canvas.pack(side="left", fill="both", expand=True) + self.canvas.configure(yscrollcommand=self.scroll.set) + + self.list = tk.Frame(self.canvas, bg=T.c("surface")) + self._win = self.canvas.create_window((0, 0), window=self.list, anchor="nw") + self.list.bind("", self._on_list_configure) + self.canvas.bind("", self._on_canvas_configure) + for w in (self, self.canvas, self.list): + w.bind("", self._on_wheel, add="+") + + def _on_list_configure(self, _event=None): + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + self._sync_scrollbar() + + def _on_canvas_configure(self, event): + self.canvas.itemconfigure(self._win, width=event.width) + self._sync_scrollbar() + + def _sync_scrollbar(self): + """列表放得下就收起滚动条 —— 空列表边上杵一根槽看着像坏了。 + + 重新 pack 时必须 ``before=self.canvas``:pack 顺序代表"谁先占位置", + 追加到末尾的话滚动条会被撑满的画布挤成 0 宽。 + """ + try: + need = self.list.winfo_reqheight() > self.canvas.winfo_height() + 1 + shown = bool(self.scroll.winfo_ismapped()) + except tk.TclError: + return + if need and not shown: + self.scroll.pack(side="right", fill="y", before=self.canvas) + elif not need and shown: + self.scroll.pack_forget() + + def _on_wheel(self, event): + try: + self.canvas.yview_scroll(int(-event.delta / 120) * 2, "units") + except Exception: + pass + + # ---------- 列表 ---------- + + def refresh(self): + """整表重绘。条数上限 200,重建的开销可以忽略。""" + for w in self.list.winfo_children(): + w.destroy() + # 请求高度要等布局算完才准,所以放到空闲时再决定滚动条要不要露面 + self.canvas.after_idle(self._sync_scrollbar) + items = self.history.items() + self.count_lbl.configure(text=f"{len(items)} 条" if items else "") + if not items: + tk.Label( + self.list, + text="还没有查询记录。\n问过的问题会记在这里,\n关掉程序也不会丢。", + bg=T.c("surface"), + fg=T.c("text_faint"), + font=T.font("caption"), + justify="left", + anchor="w", + wraplength=int(SIDEBAR_W * T.UI_SCALE) - 2 * T.SPACE["md"], + ).pack(anchor="w", fill="x", padx=T.SPACE["md"], pady=T.SPACE["md"]) + return + for it in items: + self._row(it) + + def set_current(self, item: dict | None): + """高亮"当前打开的那一轮问答",并整表重绘(顺带并入新记的一条)。""" + self._current = item + self.refresh() + + def _row(self, item: dict): + cur = item is self._current + # DeepSeek 的当前条目是一个中性灰圆角块(不是蓝底),文字仍是正文色 + fill = T.c("nav_active") if cur else T.c("surface") + row = tk.Frame(self.list, bg=T.c("surface")) + row.pack(fill="x", padx=self._pad, pady=1) + + # 行尾「重问」:固定宽度占位,平时留空 —— 显隐时不会挤动左边的问题 + ask = tk.Label( + row, + text="重问" if (cur and self.on_reask) else "", + width=4, + bg=fill, + fg=T.c("accent_text"), + font=T.font("micro"), + cursor="hand2", + anchor="e", + ) + ask.pack(side="right", padx=(0, T.SPACE["sm"]), pady=T.SPACE["xs"]) + if self.on_reask: + ask.bind("", lambda _e, q=item["question"]: self._reask(q)) + + box = tk.Frame(row, bg=fill) + box.pack( + side="left", + fill="x", + expand=True, + padx=(T.SPACE["sm"], 0), + pady=T.SPACE["xs"], + ) + tk.Label( + box, + text=_elide(item["question"], T.font("side"), self._text_w), + bg=fill, + fg=T.c("text"), + font=T.font("side"), + anchor="w", + justify="left", + ).pack(anchor="w", fill="x") + n = item.get("hits") + meta = _pretty(item.get("time", "")) + meta += f" · {n} 页" if n is not None else " · 未完成" + if item.get("answer"): + meta += " · 有答案" + tk.Label( + box, + text=meta, + bg=fill, + fg=T.c("text_faint"), + font=T.font("micro"), + anchor="w", + justify="left", + ).pack(anchor="w", fill="x") + + # 圆角底图最后建(于是它在 winfo_children() 里排第 3)再沉底。 + # 底色传 surface 而不是 fill:圆角挖掉的那四个角要透出列表底色, + # 传 fill 就成了一个方角块。 + bg_cv, set_fill = _rounded_bg(row, T.c("surface"), T.RADIUS["md"]) + set_fill(fill) + + def enter(_e=None): + if not cur: + set_fill(T.c("nav_hover")) + W.set_bg_recursive(row, T.c("nav_hover")) + if self.on_reask: + ask.configure(text="重问") + + def leave(_e=None): + if not cur: + set_fill(fill) + W.set_bg_recursive(row, fill) + ask.configure(text="") + + W.bind_hover(row, on_enter=enter, on_leave=leave) + W.bind_click_recursive(box, lambda i=item: self._open(i)) + W.set_cursor_recursive(box) + W.make_focusable(row, T.c("surface"), lambda i=item: self._open(i)) + # 圆角块的两头(子控件够不到的地方)也要能点、能呼出右键菜单 + bg_cv.configure(cursor="hand2") + bg_cv.bind("", lambda _e, i=item: self._open(i), add="+") + + menu = tk.Menu(row, tearoff=0, font=T.font("caption")) + menu.add_command(label="打开这轮问答", command=lambda i=item: self._open(i)) + if self.on_reask: + menu.add_command( + label="用这个问题重问", + command=lambda q=item["question"]: self._reask(q), + ) + menu.add_separator() + menu.add_command( + label="复制问题", command=lambda t=item["question"]: self._copy(t) + ) + if item.get("answer"): + menu.add_command( + label="复制答案", command=lambda t=item["answer"]: self._copy(t) + ) + for w in (row, bg_cv): + w.bind( + "", + lambda e, m=menu: self._popup(e, m), + add="+", + ) + + # ---------- 动作 ---------- + + def _popup(self, event, menu: tk.Menu): + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + return "break" + + def _open(self, item: dict): + self.set_current(item) + self.on_open(item) + + def _reask(self, question: str): + if self.on_reask: + self.on_reask(question) + + def _new(self): + if self.on_new: + self.on_new() + + def _copy(self, text: str): + try: + self.winfo_toplevel().clipboard_clear() + self.winfo_toplevel().clipboard_append(text) + except tk.TclError: + pass + + def _clear(self): + from tkinter import messagebox + + try: + ok = messagebox.askyesno( + "清空历史", + "确定清空全部查询历史?此操作不可撤销。", + parent=self.winfo_toplevel(), + ) + except Exception: + ok = True + if not ok: + return + self.history.clear() + self._current = None + self.refresh() + if self.on_clear: + self.on_clear() diff --git a/pixelrag_theme.py b/pixelrag_theme.py new file mode 100644 index 0000000..d9f53aa --- /dev/null +++ b/pixelrag_theme.py @@ -0,0 +1,270 @@ +"""PixelRAG 桌面客户端设计令牌 —— 对齐 DeepSeek 聊天界面。 + +这是全项目唯一的"样式表"。所有颜色 / 字号 / 间距 / 圆角 / 动效时长都必须 +从这里取,业务代码里不允许再出现硬编码色值或魔法数字。 + +浅色(light)为默认主题,深色(dark)是等价反转的备选主题。 +两个色板必须拥有完全相同的键,否则切换时会 KeyError。 +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# 色板 +# --------------------------------------------------------------------------- +PALETTES: dict[str, dict[str, str]] = { + # 取色原则:正文与提示文字对底色的对比度需 ≥ 4.5:1(WCAG AA 普通文本)。 + # 括号里是对"文字落到自己常见底色上"的实测对比度,改动色值时请一并复核。 + # ---- 浅色:Notion 纸感(默认)---- + "light": { + "bg": "#FFFFFF", # 页面底色 + "surface": "#F9F9F9", # 侧栏 / 顶栏 / 底栏(DeepSeek 冷灰) + "card": "#FFFFFF", # 卡片表面 + "card_hover": "#F5F6F8", # 卡片 hover + # 侧栏条目(DeepSeek 是浅灰圆角块,不是描边也不是蓝底): + # 底色是 surface(#F9F9F9),所以这两个值都要比它明显深一档才看得出来。 + "nav_hover": "#F0F0F2", # 条目 hover(11.9:1 上的文字仍清晰) + "nav_active": "#E4E6EA", # 当前打开的那一条(比 hover 再实一点) + "border": "#E5E7EB", # 常规分隔线 + "border_strong": "#D1D5DB", # hover / focus 时的边框 + "text": "#1F2329", # 正文(15.8:1 on bg) + "text_muted": "#4B5563", # 次要信息(7.6:1 on bg) + "text_faint": "#6B7280", # 极淡提示(4.8:1 on bg;再淡就跌破 4.5) + # 主色拆两个键:填充/边框用原值,白底上的**文字**用 accent_text。 + # #4D6BFE 落白底只有 4.33:1,不够正文级的 4.5:1,所以文字另取 #3D5AE0(5.6:1)。 + "accent": "#4D6BFE", # DeepSeek 蓝:填充 / 边框 / 焦点环 + "accent_text": "#3D5AE0", # 白底上的强调色文字(5.6:1 on bg) + "accent_hover": "#3D5AE0", # 实心按钮 hover(白字落其上 5.6:1) + "accent_soft": "#F0F3FF", # 主色淡底(hover 态底,非正文底) + # 实心按钮 = accent 底 + 白字 = 4.33:1,是全表唯一低于 4.5 的组合; + # 这是 DeepSeek 自己的做法,按用户指定的原值保留。 + "on_accent": "#FFFFFF", # 主色之上的文字 + "user_bubble": "#E8EEFF", # 用户消息浅蓝块(DeepSeek 风格) + "user_text": "#1F2329", # 用户消息文字(13.6:1 on user_bubble) + "code_bg": "#F6F7F9", # 行内代码 / 代码块底 + "success": "#0F7B6C", # 5.2:1 on bg + "warning": "#8A6100", # 5.5:1 on bg + "danger": "#C0392B", # 5.0:1 on danger_bg + "danger_bg": "#FEF2F2", # 错误提示底 + "input_bg": "#FFFFFF", + "input_line": "#E5E7EB", # 输入框 1px 边框(未聚焦) + "focus": "#4D6BFE", # 键盘焦点环 + "scrollbar": "#D1D5DB", + "skeleton": "#F1F2F4", # 加载骨架 + }, + # ---- 深色:照 DeepSeek 深色模式推的等价反转 ---- + "dark": { + "bg": "#1A1A1A", + "surface": "#1E1E1E", + "card": "#242424", + "card_hover": "#2A2A2A", + "nav_hover": "#2C2C2E", + "nav_active": "#38383C", + "border": "#333333", + "border_strong": "#454545", + "text": "#E8E8E8", # 14.2:1 on bg + "text_muted": "#B4B4BB", + "text_faint": "#A1A1AA", # 6.8:1 on bg + # 深色底上主色要提亮才够对比;同样拆成"填充用 / 文字用"两个键。 + "accent": "#7C93FF", # 6.2:1 on bg + "accent_text": "#8FA4FF", # 7.4:1 on bg + "accent_hover": "#8FA4FF", + "accent_soft": "#232B45", + "on_accent": "#1A1A1A", # 深色底上主色偏亮,配深墨字才够对比(6.2:1) + "user_bubble": "#253052", # 深色下的用户气泡:深蓝而非深灰 + "user_text": "#E8E8E8", # 10.2:1 on user_bubble + "code_bg": "#242424", + "success": "#4ADE80", + "warning": "#FACC15", + "danger": "#F87171", + "danger_bg": "#3A1D1D", + "input_bg": "#1E1E1E", + "input_line": "#333333", + "focus": "#7C93FF", + "scrollbar": "#4A4A4A", + "skeleton": "#2A2A2A", + }, +} + +DEFAULT_MODE = "light" + +_mode = DEFAULT_MODE + + +def mode() -> str: + """当前主题名(light / dark)。""" + return _mode + + +def set_mode(name: str) -> str: + """切换主题并返回生效的主题名。""" + global _mode + if name not in PALETTES: + raise ValueError(f"未知主题:{name!r}(可选:{sorted(PALETTES)})") + _mode = name + return _mode + + +def other_mode() -> str: + return "dark" if _mode == "light" else "light" + + +def c(key: str) -> str: + """按语义键取当前主题色。""" + try: + return PALETTES[_mode][key] + except KeyError: + raise KeyError(f"色板 {_mode!r} 中没有键 {key!r}") from None + + +# --------------------------------------------------------------------------- +# 排版 +# --------------------------------------------------------------------------- +FONT_FAMILY = "Microsoft YaHei UI" # 中文优先;缺失时 tk 会自行回退 +FONT_MONO = "Consolas" + +# token -> (字号 px, 字重)。**这里存的是 CSS 语义的像素值**,直接照 DeepSeek 的规格写, +# 肉眼可比。tk 只认点值,由 pt() 换算;Pillow 那边再从 pt() 换回像素,两边严格同源。 +# +# DeepSeek 的层级:正文统一 16(提问与答案同号,只靠蓝色气泡区分), +# 标题 20 / 16 / 14 / 12,界面文字最小 12。 +TYPE: dict[str, tuple[int, str]] = { + "title": (20, "bold"), # 顶栏品牌 + "h2": (16, "bold"), # 区块标题 / 引用来源 + "body": (16, "normal"), # 正文(欢迎语 / 提示 / 错误) + "body_lg": (16, "normal"), # 大号正文(保留键;DeepSeek 正文统一 16,暂与 body 同) + "bubble": (16, "normal"), # 用户提问气泡 + "answer": (16, "normal"), # 答案正文 + "input": (16, "normal"), # 输入框 + "code": (14, "normal"), # 代码块(等宽;字号走 font_mono,行高同样受 LINE_HEIGHT 管) + "side": (14, "normal"), # 侧栏条目 + "label": (14, "bold"), # 卡片标题 + "button": (14, "bold"), # 按钮 + "caption": (12, "normal"), # 卡片元信息 + "micro": (12, "normal"), # 角标 +} + + +# 全局字号倍率:**嫌字小就调大这一个数**。1.0 = 上面那套 DeepSeek 原值。 +# tk 绘制与 Pillow 离屏绘制都从这里换算,保证两边的字一样大。 +FONT_SCALE = 1.0 + +# 96dpi 下 1px = 0.75pt(CSS 的 px 与物理点的换算基准)。 +PX_PER_PT = 0.75 + + +def pt(token: str) -> int: + """取某个 token 的点值(tk 字体元组只接受整数,所以在这里取整)。""" + return max(1, round(TYPE[token][0] * PX_PER_PT * FONT_SCALE)) + + +def font(token: str, weight: str | None = None) -> tuple: + """取 tk 字体元组,如 ('Microsoft YaHei UI', 12, 'normal')。 + + ``weight`` 可以覆盖 token 自带的那一档(取值同 tk:``normal`` / ``bold`` / + ``italic`` / ``bold italic``)。正文里临时加粗(答案里的小标题)用它 —— + 字号与行高仍然归 token 管,免得为了一个粗体另起一套数字。 + """ + return (FONT_FAMILY, pt(token), weight or TYPE[token][1]) + + +def font_mono(size: int = 14) -> tuple: + """等宽字体(代码块)。``size`` 是设计像素值。""" + return (FONT_MONO, max(1, round(size * PX_PER_PT * FONT_SCALE)), "normal") + + +# 行高倍数:DeepSeek 正文 16px / 行高 1.6。tk 与 Pillow 两边都从这里取,所以屏幕上 +# 的正文和离屏画出来的气泡行距完全一致(改行距只动这一个数)。 +LINE_HEIGHT = 1.6 + + +def px_size(token: str) -> int: + """该 token 的字号,设计像素(含 FONT_SCALE)。""" + return max(1, round(TYPE[token][0] * FONT_SCALE)) + + +def line_box_px(token: str) -> int: + """该 token 一行该占多高(物理像素)= 字号 × 行高 × 屏幕缩放。 + + 注意这里含 ``UI_SCALE``:字号走 tk 的 pt→px 换算后实际就是放大了 UI_SCALE 倍, + 行盒必须用同一把尺子,否则高分屏上行距会显得比字紧。 + """ + return max(1, round(px_size(token) * LINE_HEIGHT * UI_SCALE)) + + +# tk 把点值换算成像素的系数。**必须实测,不能按 96dpi 猜成 4/3** —— 本机是 150% 缩放 +# (tk scaling ≈ 1.93),猜 4/3 会让 Pillow 画出来的字比 tk 那边小 31%,也就是用户气泡 +# 和所有按钮的字一直偏小。这里问一次 tk 再缓存,调用方无需关心。 +_TK_SCALING: float | None = None + + +def tk_scaling() -> float: + """tk 的点值→像素系数(需要已存在 Tk 根窗口;没有就回退到 96dpi)。""" + global _TK_SCALING + if _TK_SCALING is None: + try: + import tkinter + + root = tkinter._default_root + _TK_SCALING = ( + float(root.winfo_fpixels("1i")) / 72 if root else 1 / PX_PER_PT + ) + except Exception: + _TK_SCALING = 1 / PX_PER_PT # 拿不到就退回 96dpi,至少不比以前差 + return _TK_SCALING + + +# --------------------------------------------------------------------------- +# 间距(4px 基准) / 圆角 / 动效 +# --------------------------------------------------------------------------- +_BASE_SPACE = {"xs": 4, "sm": 8, "md": 12, "lg": 16, "xl": 24, "2xl": 32, "msg": 24} +_BASE_RADIUS = {"sm": 6, "md": 8, "lg": 12} +# 胶囊(整圆端头):给一个"远大于任何按钮高度"的值,绘制时会被自动夹到高度的一半 +# (见 widgets.render_label_box),于是按钮高度随字号 / DPI 变化时端头依然是半圆, +# 调用方不需要先知道按钮多高。DeepSeek 的发送/停止按钮都是这个形状。 +RADIUS_PILL = 999 +# 滚动条宽度(DeepSeek 是细条、无可见槽)。tk 只能给像素值,所以跟着 UI_SCALE 走。 +_BASE_SCROLLBAR_W = 8 +SCROLLBAR_W = _BASE_SCROLLBAR_W +# 发送按钮的最小宽度(设计像素):它在「发送 / 生成中」两种文案间切换,给个下限 +# 让它宽度不变,不然输入框里的文字会跟着一伸一缩。 +_BASE_SEND_MIN_W = 72 +SEND_MIN_W = _BASE_SEND_MIN_W +# 组件级尺寸(设计像素):不在 4px 栅格上、但 DeepSeek 有明确规格的那几个。 +# 单列出来是为了不被"顺手对齐到栅格"改掉 —— 改了就不是 DeepSeek 的比例了。 +_BASE_BUBBLE_PAD = (14, 10) # 用户气泡内边距 (横向, 纵向) +# 消息列的宽度上限(DeepSeek 是 768)。窗口再宽也不跟着变宽 —— 富余的宽度均分成 +# 两侧留白,正文始终是一条居中的窄栏,长行才不会横贯整屏。 +_BASE_CONTENT_MAX_WIDTH = 768 + +# 下面几个是"逻辑值 × UI_SCALE"的结果;启动时由 set_ui_scale 按屏幕 DPI 重算, +# 以便在高分屏上字变大的同时,内边距/圆角/行宽等比跟上,不会显得局促。 +SPACE: dict[str, int] = dict(_BASE_SPACE) +RADIUS: dict[str, int] = dict(_BASE_RADIUS) +BUBBLE_PAD: tuple[int, int] = _BASE_BUBBLE_PAD +CONTENT_MAX_WIDTH = _BASE_CONTENT_MAX_WIDTH +UI_SCALE = 1.0 + + +def set_ui_scale(factor: float) -> float: + """按屏幕缩放比例重算所有像素级令牌(0.75x ~ 2.0x)。""" + global UI_SCALE, CONTENT_MAX_WIDTH, BUBBLE_PAD, SCROLLBAR_W, SEND_MIN_W + UI_SCALE = max(0.75, min(2.0, float(factor))) + for k, v in _BASE_SPACE.items(): + SPACE[k] = max(1, round(v * UI_SCALE)) + for k, v in _BASE_RADIUS.items(): + RADIUS[k] = max(2, round(v * UI_SCALE)) + BUBBLE_PAD = tuple(max(1, round(v * UI_SCALE)) for v in _BASE_BUBBLE_PAD) + CONTENT_MAX_WIDTH = round(_BASE_CONTENT_MAX_WIDTH * UI_SCALE) + SCROLLBAR_W = max(4, round(_BASE_SCROLLBAR_W * UI_SCALE)) + SEND_MIN_W = max(24, round(_BASE_SEND_MIN_W * UI_SCALE)) + return UI_SCALE + + +# 动效时长(毫秒)。tkinter 无 CSS,统一用 root.after 驱动。 +MOTION: dict[str, int] = { + "fast": 120, + "base": 180, + "stream_throttle": 60, # 流式重绘节流间隔 + "resize_debounce": 120, # 窗口尺寸变化防抖 +} diff --git a/pixelrag_widgets.py b/pixelrag_widgets.py new file mode 100644 index 0000000..07047de --- /dev/null +++ b/pixelrag_widgets.py @@ -0,0 +1,574 @@ +"""PixelRAG 桌面客户端的绘制与交互原语。 + +tkinter 原生没有圆角、没有 hover 状态、文字也不可选中复制。本模块用 Pillow +离屏渲染补齐这些能力: + +- ``rounded_photo`` : 抗锯齿圆角/描边矩形(带缓存,避免重复渲染与 PhotoImage 被 GC) +- ``render_label_box``: 把文本直接画进圆角框,返回整块图片(用户气泡 / 按钮) +- ``bind_hover`` : 正确的 hover 绑定(处理指针移入子控件时的假 Leave) +- ``bind_click_recursive``: 让整张卡片(含所有子控件)都可点击 + +只做"画"和"绑事件",不涉及任何业务逻辑。 +""" + +from __future__ import annotations + +import re +import tkinter as tk +import tkinter.font as tkfont +from collections.abc import Callable +from functools import lru_cache + +from PIL import Image, ImageDraw, ImageFont, ImageTk + +import pixelrag_theme as T + +# --------------------------------------------------------------------------- +# 字体(PIL 侧需要真实字号像素值,而 tk 用点值) +# --------------------------------------------------------------------------- + + +@lru_cache(maxsize=4) +def _font_path(bold: bool) -> str | None: + candidates = ( + ( + "C:/Windows/Fonts/msyhbd.ttc", + "C:/Windows/Fonts/msyh.ttc", + "C:/Windows/Fonts/simhei.ttf", + "C:/Windows/Fonts/segoeuib.ttf", + ) + if bold + else ( + "C:/Windows/Fonts/msyh.ttc", + "C:/Windows/Fonts/simhei.ttf", + "C:/Windows/Fonts/segoeui.ttf", + "C:/Windows/Fonts/arial.ttf", + ) + ) + import os + + for p in candidates: + if os.path.exists(p): + return p + return None + + +@lru_cache(maxsize=64) +def pil_font(size_px: int, bold: bool = False) -> ImageFont.FreeTypeFont: + """取 PIL 字体;系统字体缺失时回退到位图字体(不会崩)。""" + path = _font_path(bold) + if not path: + return ImageFont.load_default() + try: + return ImageFont.truetype(path, size_px) + except Exception: + return ImageFont.load_default() + + +def font_px(token: str) -> int: + """把主题里的点值字号换算成像素,已含 FONT_SCALE。 + + 换算系数必须用 tk 自己的(实测 DPI),不能按 96dpi 写死 4/3 —— 在 150% 缩放的屏上 + 那会让 Pillow 画的字比同一 token 的 tk 文字小 31%(用户气泡/按钮长期偏小就是这原因)。 + """ + return max(1, round(T.pt(token) * T.tk_scaling())) + + +def line_gap_px(token: str, bold: bool = False) -> int: + """Pillow 侧的行距补偿:目标行盒(``T.line_box_px``)减去字体自然行高。 + + Pillow 的 ``getmetrics()`` 给出的是字体自身的 asc+desc(tk 的 ``linespace`` 同理), + 单倍行高约等于字号的 1.29 倍;要凑到 1.6 倍,补的就是这个差值。行高是"目标 - 实测", + 所以换字体、改 FONT_SCALE、换 DPI 都不用回来改这里的数字。 + """ + font = pil_font(font_px(token), bold=bold or T.TYPE[token][1] == "bold") + asc, desc = font.getmetrics() + return max(0, T.line_box_px(token) - (asc + desc)) + + +def tk_line_gap_px(font: tuple) -> int: + """tk 侧的同一个补偿量(送给 Text 的 ``spacing2``)。 + + tk 的字体元组只给点值,物理字号 = 点值 × ``tk_scaling()`` —— 拿它算目标行盒, + 再用 ``linespace`` 量字体自然行高,两边相减即可。这样即使传进来的是等宽字体 + (代码块)也算得对,不需要知道 token 名。 + """ + try: + f = tkfont.Font(font=font) + natural = int(f.metrics("linespace") or 0) + size_px = abs(int(f.actual("size"))) * T.tk_scaling() + except Exception: + return 0 + target = round(size_px * T.LINE_HEIGHT) + return max(0, target - natural) + + +def line_spacing(font: tuple) -> tuple[int, int, int]: + """给 ``tk.Text``(控件级或 tag 级)用的 ``(spacing1, spacing2, spacing3)``。 + + tk 会把 ``spacing2``(**段内折行**)对半分给折点两侧,于是同一个行高要求下, + 段首行只吃到下半份、段末行只吃到上半份,而"硬换行后面的那行"两个份都吃 —— + 按直觉写 spacing 会让整段行距忽紧忽松。只有 + ``spacing1 = ceil(gap/2)``、``spacing2 = gap``、``spacing3 = floor(gap/2)`` + 这一组能让下面五种行盒**完全相等**(都等于 字号 × LINE_HEIGHT): + + - 单行段落、多行段落的段首行 / 中间行 / 段末行、硬换行(``\\n``)之后那行。 + + 实测记录与逐行断言见仓库外的 ``_uicheck/linecheck.py``。 + """ + gap = tk_line_gap_px(font) + return -(-gap // 2), gap, gap // 2 + + +# --------------------------------------------------------------------------- +# 文本换行(CJK 友好) +# --------------------------------------------------------------------------- + +# 每个 token 要么是一个 CJK 字,要么是一段连续的非空白西文,要么是空白 +_TOKEN_RE = re.compile( + r"[\u2e80-\u9fff\u3000-\u303f\uff01-\uff60\uffe0-\uffe6]" + r"|[^\s\u2e80-\u9fff\u3000-\u303f\uff01-\uff60\uffe0-\uffe6]+" + r"|\s+" +) + + +def wrap_lines(text: str, font: ImageFont.FreeTypeFont, max_w: int) -> list[str]: + """按像素宽度贪心折行。西文按词断,CJK 按字断,保留显式换行。""" + scratch = ImageDraw.Draw(Image.new("RGB", (1, 1))) + out: list[str] = [] + for para in text.split("\n"): + if not para: + out.append("") + continue + cur = "" + for tok in _TOKEN_RE.findall(para): + cand = cur + tok + if cur and scratch.textlength(cand, font=font) > max_w: + out.append(cur.rstrip()) + cur = tok if tok.strip() else "" # 行首不留空白 + else: + cur = cand + out.append(cur.rstrip()) + return out + + +# --------------------------------------------------------------------------- +# 圆角矩形 +# --------------------------------------------------------------------------- + +_photo_cache: dict[tuple, ImageTk.PhotoImage] = {} +_SUPERSAMPLE = 4 # 先放大再缩小 = 抗锯齿 + + +def _clear_cache() -> None: + """主题切换后调用,丢弃旧底色的缓存图。""" + _photo_cache.clear() + + +def rounded_photo( + w: int, + h: int, + radius: int, + fill: str, + bg: str, + outline: str | None = None, + outline_w: int = 1, +) -> ImageTk.PhotoImage: + """抗锯齿圆角矩形。``bg`` 是"挖空"的角所透出的底色(必须是父容器底色)。""" + w, h = max(1, int(w)), max(1, int(h)) + key = (w, h, radius, fill, bg, outline, outline_w) + cached = _photo_cache.get(key) + if cached is not None: + return cached + + s = _SUPERSAMPLE + img = Image.new("RGB", (w * s, h * s), bg) + d = ImageDraw.Draw(img) + d.rounded_rectangle( + [0, 0, w * s - 1, h * s - 1], + radius=min(radius * s, w * s // 2, h * s // 2), + fill=fill, + outline=outline, + width=outline_w * s if outline else 0, + ) + img = img.resize((w, h), Image.LANCZOS) + photo = ImageTk.PhotoImage(img) + _photo_cache[key] = photo # 持有引用,防止 GC 后图上白 + return photo + + +# --------------------------------------------------------------------------- +# 文本块 / 气泡 / 胶囊按钮 +# --------------------------------------------------------------------------- + + +def render_label_box( + text: str, + font_token: str, + text_fill: str, + bg: str, + box_fill: str | None = None, + radius: int = 0, + pad_x: int = 12, + pad_y: int = 8, + max_w: int | None = None, + outline: str | None = None, + align: str = "left", + line_gap: int | None = None, + bold: bool = False, + min_w: int | None = None, +) -> Image.Image: + """把文本渲染成一张图片。``max_w=None`` 表示不折行(按钮用)。 + + ``line_gap=None`` 时按主题行高(``T.LINE_HEIGHT``)自动算,多行文本才有区别 —— + 单行时上下各减一次,最终高度与 line_gap 无关,所以按钮不会因此变胖。 + + ``min_w`` 给图片一个最小宽度(图片比它窄时两侧留白,``align="center"`` 仍然居中): + 按钮在「发送 / 生成中」这类不同文案之间切换时宽度不跳,旁边的控件就不会被推来推去。 + """ + bold = bold or T.TYPE[font_token][1] == "bold" + if line_gap is None: + line_gap = line_gap_px(font_token, bold=bold) + font = pil_font(font_px(font_token), bold=bold) + scratch = ImageDraw.Draw(Image.new("RGB", (1, 1))) + + if max_w is None: + lines = text.split("\n") + text_w = int(max(scratch.textlength(ln, font=font) for ln in lines)) + else: + lines = wrap_lines(text, font, max(40, max_w - 2 * pad_x)) + text_w = int(max(scratch.textlength(ln, font=font) for ln in lines)) + + asc, desc = font.getmetrics() + line_h = asc + desc + line_gap + w = text_w + 2 * pad_x + if min_w: + w = max(w, int(min_w)) + h = line_h * len(lines) + 2 * pad_y - line_gap + + if box_fill is None: + img = Image.new("RGB", (w, h), bg) + else: + # 直接在此图上画圆角(而不是复用 rounded_photo),避免缩放两次糊掉文字 + s = _SUPERSAMPLE + img = Image.new("RGB", (w * s, h * s), bg) + d = ImageDraw.Draw(img) + d.rounded_rectangle( + [0, 0, w * s - 1, h * s - 1], + radius=min(radius * s, w * s // 2, h * s // 2), + fill=box_fill, + outline=outline, + width=1 * s if outline else 0, + ) + img = img.resize((w, h), Image.LANCZOS) + + d = ImageDraw.Draw(img) + y = pad_y + for ln in lines: + if align == "center": + x = (w - scratch.textlength(ln, font=font)) / 2 + else: + x = pad_x + d.text((x, y), ln, font=font, fill=text_fill) + y += line_h + return img + + +def photo_of(img: Image.Image) -> ImageTk.PhotoImage: + """PIL 图 → tk 图片。调用方必须自己持有返回值,否则会被 GC 成白块。""" + return ImageTk.PhotoImage(img) + + +# --------------------------------------------------------------------------- +# 交互绑定 +# --------------------------------------------------------------------------- + + +def bind_hover( + widget, + on_enter: Callable[[], None] | None = None, + on_leave: Callable[[], None] | None = None, +) -> None: + """正确的 hover:指针移入子控件时 tk 会误发 ,这里下一帧复查真实位置。""" + state = {"inside": False} + + def _enter(_e=None): + if state["inside"]: + return + state["inside"] = True + if on_enter: + on_enter() + + def _leave(_e=None): + def check(): + try: + x, y = widget.winfo_pointerxy() + wx, wy = widget.winfo_rootx(), widget.winfo_rooty() + inside = ( + wx <= x < wx + widget.winfo_width() + and wy <= y < wy + widget.winfo_height() + ) + except Exception: + inside = False + if not inside and state["inside"]: + state["inside"] = False + if on_leave: + on_leave() + + widget.after_idle(check) + + widget.bind("", _enter, add="+") + widget.bind("", _leave, add="+") + + +def bind_click_recursive(widget, handler: Callable[[], None]) -> None: + """让 widget 及其所有后代都可点击(卡片里的 Label 也要能开 PDF)。""" + widget.bind("", lambda _e: handler(), add="+") + for child in widget.winfo_children(): + bind_click_recursive(child, handler) + + +def set_cursor_recursive(widget, cursor: str = "hand2") -> None: + try: + widget.configure(cursor=cursor) + except Exception: + pass + for child in widget.winfo_children(): + set_cursor_recursive(child, cursor) + + +def set_bg_recursive(widget, bg: str) -> None: + """把整棵子树的底色换成 ``bg`` —— 列表行做 hover 高亮用。""" + try: + widget.configure(bg=bg) + except Exception: + pass + for child in widget.winfo_children(): + set_bg_recursive(child, bg) + + +def make_focusable( + widget, + ring_bg: str, + on_activate: Callable[[], None] | None = None, + thickness: int | None = None, +) -> None: + """让控件能被 Tab 聚焦、用 Enter/Space 激活,并带可见焦点环。 + + 焦点环用的是 tk 自带的 highlight 描边:失焦时填 ``ring_bg``(即控件所在 + 容器的底色,视觉上不可见),因此**描边始终占位**,聚焦前后几何完全一致, + 不会引起布局跳动。聚焦时 tk 自动改用 ``highlightcolor``。 + + ``ring_bg`` 必须传控件父容器的底色,否则失焦时会看到一圈异色边框。 + """ + t = thickness if thickness is not None else max(1, round(2 * T.UI_SCALE)) + try: + widget.configure( + takefocus=1, + highlightthickness=t, + highlightbackground=ring_bg, + highlightcolor=T.c("focus"), + ) + except tk.TclError: + return + if on_activate is not None: + widget.bind("", lambda _e: on_activate(), add="+") + widget.bind("", lambda _e: on_activate(), add="+") + widget.bind("", lambda _e: on_activate(), add="+") + + +# --------------------------------------------------------------------------- +# 可选中 / 可复制的只读文本 +# --------------------------------------------------------------------------- + +# 允许穿透的按键:光标移动 / 选中 / 复制 / 全选。其余按键一律吞掉(只读)。 +_PASS_KEYS = frozenset( + { + "Left", + "Right", + "Up", + "Down", + "Home", + "End", + "Prior", + "Next", + "Shift_L", + "Shift_R", + "Control_L", + "Control_R", + "Tab", + "ISO_Left_Tab", + "Escape", + } +) +_CTRL = 0x0004 + + +def _readonly_guard(event) -> str | None: + """只读 Text 的按键闸门:放行导航与复制,拦下一切编辑键。""" + if event.keysym in _PASS_KEYS: + return None + # Ctrl+C / Ctrl+Insert 复制、Ctrl+A 全选放行;Ctrl+V/X 等编辑键继续拦 + if (event.state & _CTRL) and event.keysym.lower() in ("c", "insert", "a"): + return None + if event.keysym == "Insert" and not (event.state & _CTRL): + return "break" + return "break" + + +def selectable_text( + parent, + font: tuple, + bg: str, + fg: str, + pad_x: int = 0, + pad_y: int = 0, + line_gap: int | None = None, +) -> tk.Text: + """建一个只读但可选、可复制的正文 Text。 + + tk.Label 无法选中文字,所以"输出内容要能复制"只能靠 Text 承载。这里不用 + ``state="disabled"``(禁用态在 Windows 上会吃掉一部分选择/复制交互),而是 + 保持 normal 并在按键层拦截编辑,行为更接近网页上的只读文本。 + + 行距按主题行高(``T.LINE_HEIGHT``)铺开,``line_gap=None`` 时自动算 + (算法与理由见 ``line_spacing``)。单行段落的行盒 = linespace + spacing1 + + spacing3 正好也是 ``fit_height`` 折算 ``-height`` 用的基准,所以行距一改控件 + 高度自动跟上。段与段之间的空档由正文里的空行承担(空行本身也是一个"单行段落")。 + """ + sp1, sp2, sp3 = line_spacing(font) if line_gap is None else (0, line_gap, 0) + txt = tk.Text( + parent, + bg=bg, + fg=fg, + font=font, + relief="flat", + bd=0, + highlightthickness=0, + wrap="word", + height=1, + padx=pad_x, + pady=pad_y, + spacing1=sp1, + spacing2=sp2, + spacing3=sp3, + cursor="xterm", + insertwidth=0, + takefocus=1, + ) + txt.bind("", _readonly_guard) + return txt + + +def _base_line_px(txt: tk.Text) -> int: + """控件基准行高(像素):字体行距 + 控件级的上下留白。 + + tk 的 ``-height`` 就是按这个值折算的,不能只取字体行距 —— 否则算出来的行数 + 会偏多。 + """ + ls = tkfont.Font(font=txt.cget("font")).metrics("linespace") or 1 + sp1 = int(txt.cget("spacing1") or 0) + sp3 = int(txt.cget("spacing3") or 0) + return int(ls) + sp1 + sp3 + + +def _display_line_px(txt: tk.Text, index: str, base: int) -> int: + """推算某个显示行的高度:取该行首字符上各 tag 里最高的那套字体 + 上下留白。 + + tag 没写 spacing 时用控件级的值(和 tk 的解析规则一致)。 + """ + w1 = int(txt.cget("spacing1") or 0) + w3 = int(txt.cget("spacing3") or 0) + base_ls = base - w1 - w3 # 控件基准字体本身的行距 + h = base + try: + for tag in txt.tag_names(index): + spec = txt.tag_cget(tag, "font") + ls = tkfont.Font(font=spec).metrics("linespace") if spec else base_ls + sp1 = txt.tag_cget(tag, "spacing1") + sp3 = txt.tag_cget(tag, "spacing3") + sp1 = int(sp1) if str(sp1).strip() else w1 + sp3 = int(sp3) if str(sp3).strip() else w3 + h = max(h, int(ls) + sp1 + sp3) + except Exception: + pass + return h + + +def fit_height(txt: tk.Text, settle: bool = False) -> None: + """把只读 Text 的高度收到刚好放下内容,免得内部出现第二条滚动条。 + + ``settle=True`` 会先 ``update_idletasks()`` 逼出真实宽度;窗口刚建好或宽度 + 变过之后必须传 True,否则按旧宽度算出来的行数会偏少。流式刷新时宽度不变, + 可以走 False 这条便宜路径。 + + 注意 ``-height`` 的单位是"控件基准字体的行数",而代码段换了字体、还带上下 + 留白,一行比正文高 —— 只按显示行数设高,末尾几行会落到视口外被切掉(行数 + ≠ 像素高度)。所以这里按 **像素** 累加:已经排版出来的行直接量,还没排到的 + 行(在视口下方)按该行的 tag 推算,最后向上取整换算回行数 —— 宁可多一行 + 留白,也不切字。 + """ + try: + if settle: + txt.update_idletasks() + n = max(1, int(txt.count("1.0", "end", "displaylines")[0])) + base = _base_line_px(txt) + except Exception: + return + + # 视口外的行只能推算,推算值会比实际略高(末行没有换行符,矮一点),所以定稿 + # 时再量一遍:第二遍整块内容都已排版,能拿到精确值。流式刷新不做第二遍。 + for attempt in range(2 if settle else 1): + total = 0 + for i in range(n): + idx = f"1.0 + {i} displaylines" + info = txt.dlineinfo(idx) + total += info[3] if info is not None else _display_line_px(txt, idx, base) + + need = max(1, -(-total // base)) # 向上取整 + if int(txt.cget("height")) == need: + break + txt.configure(height=need) + if attempt == 0 and settle: + txt.update_idletasks() + + +def attach_text_menu(widget: tk.Text, root, editable: bool = False) -> None: + """给 Text 挂上右键菜单。``editable=False`` 时只给复制/全选。""" + menu = tk.Menu(root, tearoff=0, font=T.font("caption")) + + def add(label, accel, fn, state="normal"): + menu.add_command(label=label, accelerator=accel, command=fn, state=state) + + add("复制", "Ctrl+C", lambda: widget.event_generate("<>")) + if editable: + add("剪切", "Ctrl+X", lambda: widget.event_generate("<>")) + add("粘贴", "Ctrl+V", lambda: widget.event_generate("<>")) + menu.add_separator() + else: + menu.add_separator() + + def select_all(): + widget.tag_add("sel", "1.0", "end-1c") + widget.mark_set("insert", "1.0") + + add("全选", "Ctrl+A", select_all) + + def popup(event): + try: + widget.focus_set() + except Exception: + pass + # 没有选中内容时,"复制"没有意义 —— 置灰而不是点了没反应 + try: + has_sel = bool(widget.tag_ranges("sel")) + except Exception: + has_sel = False + menu.entryconfigure(0, state="normal" if has_sel else "disabled") + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + return "break" + + widget.bind("", popup, add="+") diff --git a/query.bat b/query.bat new file mode 100644 index 0000000..b553eda --- /dev/null +++ b/query.bat @@ -0,0 +1,5 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +.venv\Scripts\python.exe query.py +pause diff --git a/query.py b/query.py new file mode 100644 index 0000000..fcd925c --- /dev/null +++ b/query.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""PixelRAG 知识库中文查询脚本(交互式)。 + +用法:先双击 start_serve.bat 启动服务,再双击 query.bat(或运行本脚本)。 +输入中文问题,返回最相关的论文页面;输入编号可直接打开该页截图, +输入 p+编号 打开对应论文的 PDF。 +""" +import json +import os +import sys +import urllib.request + +HOST = "localhost" +PORT = 30001 +N_DOCS = 5 + +_ROOT = os.path.dirname(os.path.abspath(__file__)) +TILES_DIR = os.path.join(_ROOT, "knowledge_index", "tiles") + + +def _safe(s: str) -> str: + """把控制台无法编码的字符替换掉,避免 UnicodeEncodeError(如 ∕、₃)。""" + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + try: + s.encode(enc) + return s + except UnicodeEncodeError: + return s.encode(enc, errors="replace").decode(enc) + + +def search(text: str, n_docs: int = N_DOCS) -> dict: + payload = json.dumps( + {"queries": [{"text": text}], "n_docs": n_docs} + ).encode("utf-8") + req = urllib.request.Request( + f"http://{HOST}:{PORT}/search", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=900) as r: + return json.loads(r.read().decode("utf-8")) + + +def title_of(url: str) -> str: + name = os.path.basename(url.replace("\\", "/")) + if name.lower().endswith(".pdf"): + name = name[:-4] + return name + + +def page_image_path(article_id: int, tile_index: int) -> str: + d = os.path.join(TILES_DIR, f"{article_id}.png.tiles") + for ext in ("jpg", "jpeg", "png"): + p = os.path.join(d, f"tile_{tile_index:04d}.{ext}") + if os.path.exists(p): + return p + return "" + + +def main() -> None: + print("=" * 62) + print("PixelRAG 知识库检索") + print("输入中文问题后回车查询;直接回车退出。") + print("=" * 62) + + while True: + try: + text = input("\n查询 > ").strip() + except (EOFError, KeyboardInterrupt): + print("\n再见。") + break + if not text: + print("再见。") + break + + print(f"正在检索「{_safe(text)}」...(约 1 分钟)") + try: + resp = search(text) + except Exception as e: + print(f"查询失败:{e}") + print("请确认服务已启动(双击 start_serve.bat)。") + continue + + hits = resp["results"][0]["hits"] + if not hits: + print("没有找到相关结果,换个说法试试。") + continue + + print(f"\n最相关的 {len(hits)} 个页面:") + for i, h in enumerate(hits, 1): + t = title_of(h.get("url", "")) + score = h.get("score", 0) + ti = h.get("tile_index", 0) + print(f" [{i}] {_safe(t)} (tile_{ti:04d}, 相似度 {score:.3f})") + + print("\n输入编号打开该页截图;输入 p+编号 打开论文 PDF;直接回车继续:") + try: + choice = input("> ").strip().lower() + except (EOFError, KeyboardInterrupt): + break + if not choice: + continue + + open_pdf = choice.startswith("p") + num = choice[1:] if open_pdf else choice + try: + idx = int(num) - 1 + h = hits[idx] + except (ValueError, IndexError): + print("编号无效。") + continue + + if open_pdf: + pdf = h.get("url", "") + if pdf and os.path.exists(pdf): + os.startfile(pdf) + print(f"已打开论文:{_safe(title_of(pdf))}") + else: + print("找不到 PDF 文件。") + else: + img = page_image_path(h["article_id"], h["tile_index"]) + if img: + os.startfile(img) + print(f"已打开页面截图:{_safe(os.path.basename(img))}") + else: + print("找不到该页截图,可能未渲染。") + + +if __name__ == "__main__": + main() diff --git a/rebuild.bat b/rebuild.bat new file mode 100644 index 0000000..8c5b836 --- /dev/null +++ b/rebuild.bat @@ -0,0 +1,92 @@ +@echo off +cd /d "%~dp0" +setlocal + +rem --------------------------------------------------------------- +rem PixelRAG index rebuild. +rem Run this after dropping new PDFs into papers\. +rem step 1 rebuild the index +rem step 2 stop the old search server. DO NOT SKIP: serve reads +rem the index into memory once at startup and never reloads +rem it, so without a restart it keeps answering from the +rem stale index and the rebuild is wasted. +rem step 3 start a fresh server +rem NOTE: this file is saved as GBK (cp936), NOT UTF-8, and has no +rem chcp line. Under chcp 65001 cmd's batch reader desyncs on +rem multi-byte characters and starts executing echo lines as +rem commands. Keep rem comments ASCII; edit this file as GBK. +rem --------------------------------------------------------------- + +set "NPAPERS=0" +for %%f in ("papers\*.pdf") do set /a NPAPERS+=1 + +echo ============================================================ +echo PixelRAG Ë÷ÒýÖؽ¨ +echo ============================================================ +echo. +echo ÂÛÎÄĿ¼ papers\ ÓÐ %NPAPERS% ¸ö PDF +echo Ë÷ÒýÊä³ö knowledge_index\ +echo. +echo עÒâ: ǶÈë½׶ÎÊÇȫÁ¿ÖØÅÜ,²»»áÒòΪֻ¼ÓÁ˼¸ƪ¾ÍÉÙÅܡ£ +echo µ±ǰ¹æģÔÚ CPU ÉÏԼÐè 20 СʱÒÔÉϡ£ +echo ÖÐ;¹صô´°¿Úû¹Øϵ,ÖØÅÜʱ´ӶϵãÐøÅÜ,²»»á´ÓͷÀ´¡£ +echo. +pause + +set HF_HUB_OFFLINE=1 +set TRANSFORMERS_OFFLINE=1 + +echo. +echo ------------------------------------------------------------ +echo [1/3] Öؽ¨Ë÷Òý(Õâһ²½×î¾Ã,¿ÉÒԷÅ×Ų»¹Ü)... +echo ------------------------------------------------------------ +.venv\Scripts\pixelrag.exe index build -c pixelrag.yaml +if errorlevel 1 goto :failed + +echo. +echo ------------------------------------------------------------ +echo [2/3] ͣµô¾ɵļìË÷·þÎñ... +echo ------------------------------------------------------------ +call :stop_serve +rem 2s wait for the port to free up. ping, not timeout: timeout wants a +rem console on stdin and errors out when the script runs headless. +ping -n 3 127.0.0.1 >nul + +echo. +echo ------------------------------------------------------------ +echo [3/3] Æô¶¯ÐµļìË÷·þÎñ(¼ÓÔØģÐÍԼÐè 1 ·ÖÖÓ)... +echo ------------------------------------------------------------ +start "PixelRAG serve" cmd /k ".venv\Scripts\pixelrag.exe serve --index-dir knowledge_index --tiles-dir knowledge_index/tiles --articles-json knowledge_index/articles.json --device cpu --port 30001" + +echo. +echo ============================================================ +echo Íê³ɡ£µÈԼ 1 ·ÖÖӺó˫»÷ PixelRAG¿ͻ§¶Ë.bat ²éѯÐÂË÷Òý¡£ +echo пªÄǸö´°¿ھÍÊǼìË÷·þÎñ,±ð¹ØËü¡£ +echo ============================================================ +pause +exit /b 0 + + +:stop_serve +rem Kill whatever listens on 30001 (the old serve). Skip if nothing does. +set "KILLED=" +for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":30001" ^| findstr "LISTENING"') do ( + taskkill /f /pid %%p >nul 2>&1 + if not errorlevel 1 ( + echo ÒѽáÊø¾ɷþÎñ PID %%p + set "KILLED=1" + ) +) +if not defined KILLED echo (¶˿Ú 30001 ÉÏûÓÐÔÚÅܵķþÎñ,Ìø¹ý) +exit /b 0 + + +:failed +echo. +echo ============================================================ +echo [X] Öؽ¨ʧ°Ü,Ë÷ÒýûÓиüÐÂ(¾ÉË÷ÒýÈÔÊÇÍêºõÄ)¡£ +echo ÍùÉϷ­Äܿ´µ½¿¨ÔÚÄÄһ²½;Ð޺úóÖØÅܱ¾½ű¾¼´¿É, +echo ÒѾ­ǶºõIJ¿·ֻá´ӶϵãÐøÅÜ,²»»á°×Åܡ£ +echo ============================================================ +pause +exit /b 1 diff --git a/render/src/pixelrag_render/backends/pdf.py b/render/src/pixelrag_render/backends/pdf.py index 7241105..44f1530 100644 --- a/render/src/pixelrag_render/backends/pdf.py +++ b/render/src/pixelrag_render/backends/pdf.py @@ -123,7 +123,7 @@ def render_pdf( # complete — see #139 on what an unconditional flag costs downstream. "complete": pages is None, } - with open(tile_dir / "tiles.json", "w") as f: + with open(tile_dir / "tiles.json", "w", encoding="utf-8") as f: json.dump(manifest, f) # Write chunks.json so the chunker skips this directory — @@ -137,7 +137,7 @@ def render_pdf( "num_chunks": len(chunks_info), "chunks": chunks_info, } - with open(tile_dir / "chunks.json", "w") as f: + with open(tile_dir / "chunks.json", "w", encoding="utf-8") as f: json.dump(chunks_manifest, f) logger.info("PDF rendered: %d pages → %s", len(saved_tiles), tile_dir) diff --git a/serve/src/pixelrag_serve/api.py b/serve/src/pixelrag_serve/api.py index 01c4716..59c914b 100644 --- a/serve/src/pixelrag_serve/api.py +++ b/serve/src/pixelrag_serve/api.py @@ -711,7 +711,9 @@ def load(args): import torch device = args.device - dtype = torch.float32 if device == "cpu" else torch.bfloat16 + # 本机 16GB 内存:CPU 也用 bfloat16(4GB),与 embed 阶段一致。float32(8GB) + # 会挤爆内存并把模型权重换出到 pagefile,导致单次查询被拖到几十分钟。 + dtype = torch.bfloat16 # Load summary (index loading itself lives in the backend: FaissBackend # honors PIXELRAG_INDEX_MMAP for memory-mapped multi-100G indexes). diff --git a/start_serve.bat b/start_serve.bat new file mode 100644 index 0000000..73efe88 --- /dev/null +++ b/start_serve.bat @@ -0,0 +1,24 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" + +rem 若服务已在运行则不再重复启动 +curl -s --max-time 2 http://localhost:30001/health >nul 2>&1 +if %errorlevel%==0 ( + echo 服务已在运行(端口 30001),无需重复启动。 + echo 直接双击 query.bat 开始查询即可。 + pause + exit /b 0 +) + +echo 正在启动 PixelRAG 检索服务,加载模型约需 1 分钟... +echo 看到 "Uvicorn running on http://0.0.0.0:30001" 即表示启动成功。 +echo. +echo 提示:请保持本窗口开启,关闭本窗口即停止服务。 +echo. + +set HF_HUB_OFFLINE=1 +set TRANSFORMERS_OFFLINE=1 +.venv\Scripts\pixelrag.exe serve --index-dir knowledge_index --tiles-dir knowledge_index/tiles --articles-json knowledge_index/articles.json --device cpu --port 30001 + +pause diff --git "a/\347\237\245\350\257\206\345\272\223\344\275\277\347\224\250\350\257\264\346\230\216.txt" "b/\347\237\245\350\257\206\345\272\223\344\275\277\347\224\250\350\257\264\346\230\216.txt" new file mode 100644 index 0000000..3879e7e --- /dev/null +++ "b/\347\237\245\350\257\206\345\272\223\344\275\277\347\224\250\350\257\264\346\230\216.txt" @@ -0,0 +1,30 @@ +======================================== + 你的知识库目录 —— 把论文 PDF 放这里 +======================================== + +【怎么添加论文】 + 用文件资源管理器,把你下载的 PDF 论文 + 直接复制 / 粘贴到这个文件夹里即可。 + 支持 .pdf / .html / .png / .jpg / .md / .txt 等格式。 + +【怎么构建索引】 + 在项目根目录 D:\ccproject\PixelRAG 下运行: + + .venv\Scripts\pixelrag.exe index build + + 首次构建会自动加载 Qwen3-VL-Embedding-2B 模型(已缓存,不需再下载)。 + +【怎么启动搜索】 + 构建完成后运行: + + .venv\Scripts\pixelrag.exe serve --index-dir D:/ccproject/PixelRAG/knowledge_index --port 30001 + + 然后查询: + + curl -X POST http://localhost:30001/search ^ + -H "Content-Type: application/json" ^ + -d "{\"queries\":[{\"text\":\"你的问题\"}],\"n_docs\":5}" + +【小提示】 + - 攒一批论文(10~20 篇)再 build,效率更高(嵌入阶段会重新处理全部文档)。 + - 新文件建议用日期/序号前缀命名,避免插入中间导致旧文档重渲染。