mdpad 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdpad/__init__.py +5 -0
- mdpad/app.py +619 -0
- mdpad/applog.py +30 -0
- mdpad/configure.py +73 -0
- mdpad/context.py +187 -0
- mdpad/example/book.md +31 -0
- mdpad/provider.py +195 -0
- mdpad/py.typed +0 -0
- mdpad/sections.py +252 -0
- mdpad/static/app.js +1120 -0
- mdpad/static/favicon.svg +10 -0
- mdpad/static/index.html +166 -0
- mdpad/static/styles.css +453 -0
- mdpad/static/vendor/katex/contrib/auto-render.min.js +1 -0
- mdpad/static/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- mdpad/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- mdpad/static/vendor/katex/katex.min.css +1 -0
- mdpad/static/vendor/katex/katex.min.js +1 -0
- mdpad/static/vendor/markdown-it.min.js +2 -0
- mdpad/static/vendor/mermaid/mermaid.min.js +2029 -0
- mdpad/summaries.py +393 -0
- mdpad-0.1.0.dist-info/METADATA +97 -0
- mdpad-0.1.0.dist-info/RECORD +84 -0
- mdpad-0.1.0.dist-info/WHEEL +4 -0
- mdpad-0.1.0.dist-info/entry_points.txt +4 -0
- mdpad-0.1.0.dist-info/licenses/LICENSE +201 -0
mdpad/__init__.py
ADDED
mdpad/app.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""FastAPI application: serves the single-page workspace and its JSON endpoints.
|
|
2
|
+
|
|
3
|
+
Provider config lives in the browser (localStorage) and travels with each revise
|
|
4
|
+
request. The base URL and API key can also be supplied server-side via a
|
|
5
|
+
``.env`` file (``MDPAD_BASE_URL`` / ``MDPAD_API_KEY`` — see ``mdpad-configure``)
|
|
6
|
+
so the key is never exposed to client code; the browser then only picks a
|
|
7
|
+
model. A blank field in a request falls back to the server default.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
import webbrowser
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import pystray
|
|
22
|
+
import uvicorn
|
|
23
|
+
|
|
24
|
+
from dotenv import load_dotenv
|
|
25
|
+
from fastapi import FastAPI, HTTPException
|
|
26
|
+
from fastapi.responses import FileResponse
|
|
27
|
+
from fastapi.staticfiles import StaticFiles
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
|
|
30
|
+
from . import applog
|
|
31
|
+
from . import context as ctx
|
|
32
|
+
from . import provider as prov
|
|
33
|
+
from . import summaries as summ
|
|
34
|
+
from .sections import block_at, parse_blocks, parse_sections
|
|
35
|
+
|
|
36
|
+
load_dotenv()
|
|
37
|
+
# Server-side provider defaults, set via `.env` (see `mdpad-configure`). Never
|
|
38
|
+
# sent to the browser; blank means no server default (offline mock unless a
|
|
39
|
+
# request supplies its own base URL + key). GPUSTACK_TEST_API_KEY is a legacy
|
|
40
|
+
# alias kept for existing `.env` files.
|
|
41
|
+
_server_api_key = os.getenv("MDPAD_API_KEY") or os.getenv("GPUSTACK_TEST_API_KEY")
|
|
42
|
+
SERVER_API_KEY: str = _server_api_key if _server_api_key else ""
|
|
43
|
+
SERVER_BASE_URL = (os.getenv("MDPAD_BASE_URL") or "").rstrip("/")
|
|
44
|
+
|
|
45
|
+
_HERE = Path(__file__).parent
|
|
46
|
+
_STATIC = _HERE / "static"
|
|
47
|
+
_DEFAULT_DOC = (_HERE / "example" / "book.md").read_text(encoding="utf-8")
|
|
48
|
+
_PROJECT_SUMMARY = (
|
|
49
|
+
"A vision/review manuscript on representation dynamics and the physics of "
|
|
50
|
+
"information processing. Audience: researchers in representation learning. "
|
|
51
|
+
"Tone: precise, hypothesis-aware, mathematically careful."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# The manuscript served to the browser. Defaults to the bundled example; a file
|
|
55
|
+
# path passed to `mdpad` (e.g. `uv run mdpad test.md`) replaces it at startup.
|
|
56
|
+
_current_doc = _DEFAULT_DOC
|
|
57
|
+
_current_name: str | None = None
|
|
58
|
+
|
|
59
|
+
# Projects: each is a folder of flat .md files (+ .summ.json sidecars) plus a
|
|
60
|
+
# shared history/ of save snapshots. PROJECTS_ROOT is where "New Project"
|
|
61
|
+
# creates one by default; PROJECT_DIR is whichever project is currently open
|
|
62
|
+
# (None until one is opened/created -- open/save/history are gated on it).
|
|
63
|
+
PROJECTS_ROOT = Path.cwd() / "projects"
|
|
64
|
+
PROJECT_DIR: Path | None = None
|
|
65
|
+
_NAME_RE = re.compile(r"^[\w.\-]+\.md$")
|
|
66
|
+
_PROJECT_NAME_RE = re.compile(r"^[\w.\- ]+$")
|
|
67
|
+
HISTORY_RETENTION_DAYS = 30
|
|
68
|
+
DEFAULT_PORT = 8670
|
|
69
|
+
|
|
70
|
+
app = FastAPI(title="mdpad")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_project() -> Path:
|
|
74
|
+
if PROJECT_DIR is None:
|
|
75
|
+
raise HTTPException(400, "No project is open. Create or open a project first.")
|
|
76
|
+
return PROJECT_DIR
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_doc_path(name: str) -> Path:
|
|
80
|
+
"""Resolve ``name`` to a .md file directly inside PROJECT_DIR (no traversal)."""
|
|
81
|
+
project_dir = _require_project()
|
|
82
|
+
if not _NAME_RE.match(name):
|
|
83
|
+
raise HTTPException(400, "Invalid filename (use letters, digits, . - _ and a .md extension).")
|
|
84
|
+
path = (project_dir / name).resolve()
|
|
85
|
+
if path.parent != project_dir.resolve():
|
|
86
|
+
raise HTTPException(400, "Filename must not point outside the project folder.")
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _summ_path(name: str) -> Path:
|
|
91
|
+
"""Sidecar file caching a document's summaries: ``<name>.summ.json``."""
|
|
92
|
+
return _require_project() / f"{name}.summ.json"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _history_dir() -> Path:
|
|
96
|
+
return _require_project() / "history"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _snapshot_timestamp() -> str:
|
|
100
|
+
now = datetime.now()
|
|
101
|
+
return now.strftime("%y%m%d_%H%M%S") + f".{now.microsecond:06d}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _safe_project_path(name: str) -> Path:
|
|
105
|
+
"""Resolve ``name`` to a project directory directly inside PROJECTS_ROOT."""
|
|
106
|
+
if not _PROJECT_NAME_RE.match(name):
|
|
107
|
+
raise HTTPException(400, "Invalid project name (use letters, digits, spaces, . - _).")
|
|
108
|
+
path = (PROJECTS_ROOT / name).resolve()
|
|
109
|
+
if path.parent != PROJECTS_ROOT.resolve():
|
|
110
|
+
raise HTTPException(400, "Project name must not point outside the projects folder.")
|
|
111
|
+
return path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _open_project(project_dir: Path) -> None:
|
|
115
|
+
global PROJECT_DIR, _current_doc, _current_name
|
|
116
|
+
PROJECT_DIR = project_dir
|
|
117
|
+
files = sorted(p.name for p in project_dir.glob("*.md") if p.is_file())
|
|
118
|
+
first = files[0] if files else None
|
|
119
|
+
if first:
|
|
120
|
+
_current_doc = (project_dir / first).read_text(encoding="utf-8")
|
|
121
|
+
_current_name = first
|
|
122
|
+
summ.load(_summ_path(first))
|
|
123
|
+
else:
|
|
124
|
+
_current_doc = ""
|
|
125
|
+
_current_name = None
|
|
126
|
+
summ.reset()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ProviderSettings(BaseModel):
|
|
130
|
+
base_url: str = ""
|
|
131
|
+
api_key: str = ""
|
|
132
|
+
model: str = ""
|
|
133
|
+
temperature: float = 0.3
|
|
134
|
+
max_tokens: int = 1024
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ReviseRequest(BaseModel):
|
|
138
|
+
text: str
|
|
139
|
+
sel_start: int
|
|
140
|
+
sel_end: int
|
|
141
|
+
instruction: str
|
|
142
|
+
provider: ProviderSettings = Field(default_factory=ProviderSettings)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SummariesRefreshRequest(BaseModel):
|
|
146
|
+
text: str
|
|
147
|
+
mode: str = "invalidated"
|
|
148
|
+
provider: ProviderSettings = Field(default_factory=ProviderSettings)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SummariesEstimateRequest(BaseModel):
|
|
152
|
+
text: str
|
|
153
|
+
mode: str = "all"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SummariesStatusRequest(BaseModel):
|
|
157
|
+
text: str = ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ContextMapRequest(BaseModel):
|
|
161
|
+
text: str
|
|
162
|
+
offset: int = 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve_endpoint(base_url: str, api_key: str) -> tuple[str, str]:
|
|
166
|
+
"""Resolve (base_url, api_key) without ever leaking the server key.
|
|
167
|
+
|
|
168
|
+
The server-side .env key is only ever paired with the server's own base URL.
|
|
169
|
+
A caller-supplied custom base URL must bring its own key -- otherwise a
|
|
170
|
+
forged request (e.g. ?base_url=http://attacker/) could exfiltrate the key.
|
|
171
|
+
Exception: a local endpoint (e.g. Ollama/LM Studio on localhost) carries
|
|
172
|
+
no server secret either way, so it's fine with no key at all.
|
|
173
|
+
"""
|
|
174
|
+
bu = (base_url or SERVER_BASE_URL).rstrip("/")
|
|
175
|
+
if bu == SERVER_BASE_URL:
|
|
176
|
+
return bu, (api_key or SERVER_API_KEY)
|
|
177
|
+
if not api_key and not prov.is_local(bu):
|
|
178
|
+
raise ValueError("A custom (non-local) base URL requires its own API key.")
|
|
179
|
+
return bu, api_key
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Model ids whose names indicate a non-chat model (won't work with /chat/completions).
|
|
183
|
+
_NON_CHAT_HINTS = ("whisper", "embedding", "rerank", "-tts", "-stt")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _chat_models(ids: list[str]) -> list[str]:
|
|
187
|
+
return [m for m in ids if not any(h in m.lower() for h in _NON_CHAT_HINTS)]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.get("/")
|
|
191
|
+
def index() -> FileResponse:
|
|
192
|
+
return FileResponse(_STATIC / "index.html")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.get("/api/document")
|
|
196
|
+
def document() -> dict:
|
|
197
|
+
return {"text": _current_doc, "project_summary": _PROJECT_SUMMARY, "name": _current_name}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.get("/api/config")
|
|
201
|
+
def provider_config() -> dict:
|
|
202
|
+
"""Non-secret provider defaults for the UI (never returns the key itself)."""
|
|
203
|
+
return {"base_url": SERVER_BASE_URL, "has_key": bool(SERVER_API_KEY)}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.get("/api/usage")
|
|
207
|
+
def usage() -> dict:
|
|
208
|
+
"""Cumulative token usage for this server session."""
|
|
209
|
+
return prov.usage_totals()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.get("/api/logs")
|
|
213
|
+
def get_logs(since: int = 0) -> dict:
|
|
214
|
+
"""Backend events (provider errors, mock-fallback notices, summary-refresh
|
|
215
|
+
results) newer than ``since`` -- polled by the dock's Logs tab."""
|
|
216
|
+
return {"logs": applog.get_logs(since)}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.post("/api/logs/clear")
|
|
220
|
+
def clear_logs() -> dict:
|
|
221
|
+
applog.clear()
|
|
222
|
+
return {"ok": True}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.get("/api/models")
|
|
226
|
+
def list_models_endpoint(base_url: str = "", api_key: str = "") -> dict:
|
|
227
|
+
"""Fetch the OpenAI-compatible model list. Blank args use the server defaults."""
|
|
228
|
+
bu = (base_url or SERVER_BASE_URL).rstrip("/")
|
|
229
|
+
try:
|
|
230
|
+
_, ak = _resolve_endpoint(base_url, api_key)
|
|
231
|
+
models = _chat_models(prov.list_models(base_url=bu, api_key=ak))
|
|
232
|
+
return {"models": models, "base_url": bu, "has_key": bool(ak), "error": None}
|
|
233
|
+
except (ValueError, prov.ProviderError) as e:
|
|
234
|
+
applog.log("error", "models", str(e))
|
|
235
|
+
return {"models": [], "base_url": bu, "has_key": bool(api_key), "error": str(e)}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.get("/api/files")
|
|
239
|
+
def list_files() -> dict:
|
|
240
|
+
if PROJECT_DIR is None:
|
|
241
|
+
return {"dir": None, "files": [], "current": _current_name}
|
|
242
|
+
files = sorted(p.name for p in PROJECT_DIR.glob("*.md") if p.is_file())
|
|
243
|
+
return {"dir": str(PROJECT_DIR), "files": files, "current": _current_name}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class SaveRequest(BaseModel):
|
|
247
|
+
name: str
|
|
248
|
+
text: str
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.get("/api/file/{name}")
|
|
252
|
+
def read_file(name: str) -> dict:
|
|
253
|
+
global _current_doc, _current_name
|
|
254
|
+
path = _safe_doc_path(name)
|
|
255
|
+
if not path.is_file():
|
|
256
|
+
raise HTTPException(404, f"No such file in {PROJECT_DIR}: {name}")
|
|
257
|
+
_current_doc = path.read_text(encoding="utf-8")
|
|
258
|
+
_current_name = name
|
|
259
|
+
summ.load(_summ_path(name)) # reuse this doc's cached summaries if present
|
|
260
|
+
return {"name": name, "text": _current_doc}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.post("/api/file")
|
|
264
|
+
def save_file(req: SaveRequest) -> dict:
|
|
265
|
+
global _current_doc, _current_name
|
|
266
|
+
path = _safe_doc_path(req.name)
|
|
267
|
+
path.write_text(req.text, encoding="utf-8")
|
|
268
|
+
_current_doc = req.text
|
|
269
|
+
_current_name = req.name
|
|
270
|
+
if summ._STORE: # persist current summaries alongside the document
|
|
271
|
+
summ.save(_summ_path(req.name))
|
|
272
|
+
history_dir = _history_dir()
|
|
273
|
+
history_dir.mkdir(exist_ok=True) # defensive: projects predating this feature
|
|
274
|
+
(history_dir / f"{req.name}_{_snapshot_timestamp()}.md").write_text(req.text, encoding="utf-8")
|
|
275
|
+
return {"ok": True, "name": req.name}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class ProjectCreateRequest(BaseModel):
|
|
279
|
+
name: str = ""
|
|
280
|
+
path: str = ""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ProjectOpenRequest(BaseModel):
|
|
284
|
+
name: str = ""
|
|
285
|
+
path: str = ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_README_TEMPLATE = "# {name}\n"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.get("/api/projects")
|
|
292
|
+
def list_projects() -> dict:
|
|
293
|
+
PROJECTS_ROOT.mkdir(exist_ok=True)
|
|
294
|
+
projects = sorted(p.name for p in PROJECTS_ROOT.iterdir() if p.is_dir())
|
|
295
|
+
return {"projects": projects, "current": PROJECT_DIR.name if PROJECT_DIR else None}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.post("/api/projects")
|
|
299
|
+
def create_project(req: ProjectCreateRequest) -> dict:
|
|
300
|
+
if req.path:
|
|
301
|
+
project_dir = Path(req.path).expanduser().resolve()
|
|
302
|
+
name = project_dir.name
|
|
303
|
+
elif req.name:
|
|
304
|
+
project_dir = _safe_project_path(req.name)
|
|
305
|
+
name = req.name
|
|
306
|
+
else:
|
|
307
|
+
raise HTTPException(400, "A project name or path is required.")
|
|
308
|
+
if project_dir.exists():
|
|
309
|
+
raise HTTPException(400, f"Project already exists: {project_dir}")
|
|
310
|
+
project_dir.mkdir(parents=True)
|
|
311
|
+
(project_dir / "history").mkdir()
|
|
312
|
+
(project_dir / "README.md").write_text(_README_TEMPLATE.format(name=name), encoding="utf-8")
|
|
313
|
+
_open_project(project_dir)
|
|
314
|
+
return list_files()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@app.post("/api/projects/open")
|
|
318
|
+
def open_project(req: ProjectOpenRequest) -> dict:
|
|
319
|
+
if req.path:
|
|
320
|
+
project_dir = Path(req.path).expanduser().resolve()
|
|
321
|
+
elif req.name:
|
|
322
|
+
project_dir = _safe_project_path(req.name)
|
|
323
|
+
else:
|
|
324
|
+
raise HTTPException(400, "A project name or path is required.")
|
|
325
|
+
if not project_dir.is_dir():
|
|
326
|
+
raise HTTPException(404, f"No such project folder: {project_dir}")
|
|
327
|
+
_open_project(project_dir)
|
|
328
|
+
return list_files()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@app.get("/api/history/status")
|
|
332
|
+
def history_status() -> dict:
|
|
333
|
+
if PROJECT_DIR is None:
|
|
334
|
+
return {"total_bytes": 0}
|
|
335
|
+
history_dir = PROJECT_DIR / "history"
|
|
336
|
+
if not history_dir.is_dir() or (history_dir / "keepforever.md").exists():
|
|
337
|
+
return {"total_bytes": 0}
|
|
338
|
+
cutoff = time.time() - HISTORY_RETENTION_DAYS * 86400
|
|
339
|
+
total = sum(
|
|
340
|
+
f.stat().st_size for f in history_dir.glob("*.md") if f.name != "keepforever.md" and f.stat().st_mtime < cutoff
|
|
341
|
+
)
|
|
342
|
+
return {"total_bytes": total}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class HistoryCleanupRequest(BaseModel):
|
|
346
|
+
action: str # "delete" | "keep_forever" | "skip"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@app.post("/api/history/cleanup")
|
|
350
|
+
def history_cleanup(req: HistoryCleanupRequest) -> dict:
|
|
351
|
+
if PROJECT_DIR is None:
|
|
352
|
+
return {"ok": True}
|
|
353
|
+
history_dir = PROJECT_DIR / "history"
|
|
354
|
+
if req.action == "keep_forever":
|
|
355
|
+
history_dir.mkdir(exist_ok=True)
|
|
356
|
+
(history_dir / "keepforever.md").write_text("", encoding="utf-8")
|
|
357
|
+
elif req.action == "delete":
|
|
358
|
+
if history_dir.is_dir():
|
|
359
|
+
cutoff = time.time() - HISTORY_RETENTION_DAYS * 86400
|
|
360
|
+
for f in history_dir.glob("*.md"):
|
|
361
|
+
if f.name != "keepforever.md" and f.stat().st_mtime < cutoff:
|
|
362
|
+
f.unlink()
|
|
363
|
+
elif req.action != "skip":
|
|
364
|
+
raise HTTPException(400, f"Unknown action: {req.action!r}")
|
|
365
|
+
return {"ok": True}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.post("/api/revise")
|
|
369
|
+
def revise(req: ReviseRequest) -> dict:
|
|
370
|
+
selection = req.text[req.sel_start : req.sel_end]
|
|
371
|
+
sections = parse_sections(req.text)
|
|
372
|
+
blocks = parse_blocks(req.text)
|
|
373
|
+
target_block = block_at(blocks, req.sel_start, req.text)
|
|
374
|
+
owner_section = None
|
|
375
|
+
if target_block is not None:
|
|
376
|
+
owner_section = next((s for s in sections if s.id == target_block.section_id), None)
|
|
377
|
+
package = ctx.build_context(
|
|
378
|
+
project_summary=_PROJECT_SUMMARY,
|
|
379
|
+
sections=sections,
|
|
380
|
+
blocks=blocks,
|
|
381
|
+
target_block=target_block,
|
|
382
|
+
selection=selection,
|
|
383
|
+
instruction=req.instruction,
|
|
384
|
+
store=summ._STORE,
|
|
385
|
+
)
|
|
386
|
+
proposal = None
|
|
387
|
+
error = None
|
|
388
|
+
try:
|
|
389
|
+
d = req.provider.model_dump()
|
|
390
|
+
d["base_url"], d["api_key"] = _resolve_endpoint(d.get("base_url", ""), d.get("api_key", ""))
|
|
391
|
+
proposal = prov.revise(
|
|
392
|
+
package_text=package.as_prompt(),
|
|
393
|
+
selection=selection,
|
|
394
|
+
instruction=req.instruction,
|
|
395
|
+
provider=d,
|
|
396
|
+
)
|
|
397
|
+
except (ValueError, prov.ProviderError) as e:
|
|
398
|
+
error = str(e)
|
|
399
|
+
applog.log("error", "revise", error)
|
|
400
|
+
if proposal is not None and proposal.mock:
|
|
401
|
+
applog.log("info", "revise", "No provider configured -- returned the offline mock.")
|
|
402
|
+
return {
|
|
403
|
+
"section": owner_section.title if owner_section else None,
|
|
404
|
+
"context": package.as_dict(),
|
|
405
|
+
"prompt": {"system": prov.SYSTEM_PROMPT, "user": package.as_prompt()},
|
|
406
|
+
"proposal": proposal.as_dict() if proposal else None,
|
|
407
|
+
"error": error,
|
|
408
|
+
"usage": prov.usage_totals(),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@app.post("/api/summaries/refresh")
|
|
413
|
+
def summaries_refresh(req: SummariesRefreshRequest) -> dict:
|
|
414
|
+
sections = parse_sections(req.text)
|
|
415
|
+
blocks = parse_blocks(req.text)
|
|
416
|
+
d = req.provider.model_dump()
|
|
417
|
+
try:
|
|
418
|
+
d["base_url"], d["api_key"] = _resolve_endpoint(d.get("base_url", ""), d.get("api_key", ""))
|
|
419
|
+
except ValueError as e:
|
|
420
|
+
return {"generated": 0, "checked": 0, "skipped": 0, "tokens": 0, "error": str(e)}
|
|
421
|
+
result = summ.refresh(sections, blocks, req.text, d, req.mode)
|
|
422
|
+
if _current_name and summ._STORE: # only persist when there's something to save
|
|
423
|
+
summ.save(_summ_path(_current_name)) # so a reopen reuses them
|
|
424
|
+
result["usage"] = prov.usage_totals()
|
|
425
|
+
return result
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@app.post("/api/summaries/estimate")
|
|
429
|
+
def summaries_estimate(req: SummariesEstimateRequest) -> dict:
|
|
430
|
+
sections = parse_sections(req.text)
|
|
431
|
+
blocks = parse_blocks(req.text)
|
|
432
|
+
return summ.estimate(sections, blocks, req.mode)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@app.get("/api/summaries/progress")
|
|
436
|
+
def summaries_progress() -> dict:
|
|
437
|
+
"""Live done/total for whichever summaries/refresh call is in flight (if
|
|
438
|
+
any) -- polled by the frontend so a multi-item batch isn't silent."""
|
|
439
|
+
return summ.get_progress()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.post("/api/summaries")
|
|
443
|
+
def summaries_status(req: SummariesStatusRequest) -> dict:
|
|
444
|
+
text = req.text or _current_doc
|
|
445
|
+
sections = parse_sections(text)
|
|
446
|
+
blocks = parse_blocks(text)
|
|
447
|
+
return summ.status(sections, blocks, text)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@app.post("/api/context/map")
|
|
451
|
+
def context_map(req: ContextMapRequest) -> dict:
|
|
452
|
+
"""The summary hierarchy for the paragraph at ``offset`` (no LLM): the same
|
|
453
|
+
items build_context assembles, plus per-unit validity and counts."""
|
|
454
|
+
sections = parse_sections(req.text)
|
|
455
|
+
blocks = parse_blocks(req.text)
|
|
456
|
+
target = block_at(blocks, req.offset, req.text)
|
|
457
|
+
package = ctx.build_context(
|
|
458
|
+
project_summary=_PROJECT_SUMMARY,
|
|
459
|
+
sections=sections,
|
|
460
|
+
blocks=blocks,
|
|
461
|
+
target_block=target,
|
|
462
|
+
selection="",
|
|
463
|
+
instruction="",
|
|
464
|
+
store=summ._STORE,
|
|
465
|
+
)
|
|
466
|
+
return {
|
|
467
|
+
"items": package.as_dict()["items"],
|
|
468
|
+
"counts": summ.status(sections, blocks, req.text),
|
|
469
|
+
"current": target.id if target else None,
|
|
470
|
+
"tree": ctx.build_tree(
|
|
471
|
+
sections=sections,
|
|
472
|
+
blocks=blocks,
|
|
473
|
+
target_block=target,
|
|
474
|
+
store=summ._STORE,
|
|
475
|
+
states=summ.validity(sections, blocks, req.text),
|
|
476
|
+
),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@app.middleware("http")
|
|
481
|
+
async def _revalidate_assets(request, call_next):
|
|
482
|
+
"""Always revalidate the app's HTML/CSS/JS so edits show up on a normal reload
|
|
483
|
+
(no hard-refresh needed). Vendored libs (fonts/KaTeX) stay cacheable."""
|
|
484
|
+
resp = await call_next(request)
|
|
485
|
+
p = request.url.path
|
|
486
|
+
if p == "/" or (p.startswith("/static/") and "/vendor/" not in p):
|
|
487
|
+
resp.headers["Cache-Control"] = "no-cache"
|
|
488
|
+
return resp
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
app.mount("/static", StaticFiles(directory=_STATIC), name="static")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _free_port(preferred: int) -> int:
|
|
495
|
+
"""Return ``preferred`` if bindable, else an OS-assigned free port."""
|
|
496
|
+
import socket
|
|
497
|
+
|
|
498
|
+
for candidate in (preferred, 0):
|
|
499
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
500
|
+
try:
|
|
501
|
+
s.bind(("127.0.0.1", candidate))
|
|
502
|
+
return s.getsockname()[1]
|
|
503
|
+
except OSError:
|
|
504
|
+
continue
|
|
505
|
+
return preferred
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _open_when_ready(url: str) -> None:
|
|
509
|
+
"""Open the browser only once the server answers (avoids ERR_EMPTY_RESPONSE)."""
|
|
510
|
+
import time
|
|
511
|
+
import urllib.request
|
|
512
|
+
|
|
513
|
+
for _ in range(60):
|
|
514
|
+
try:
|
|
515
|
+
urllib.request.urlopen(url, timeout=0.5)
|
|
516
|
+
break
|
|
517
|
+
except Exception:
|
|
518
|
+
time.sleep(0.2)
|
|
519
|
+
try:
|
|
520
|
+
webbrowser.open(url)
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _tray_image():
|
|
526
|
+
"""A small rounded-square icon mirroring static/favicon.svg's spectrum-bar
|
|
527
|
+
motif (teal bars on graphite), drawn with Pillow -- no asset file needed."""
|
|
528
|
+
from PIL import Image, ImageDraw
|
|
529
|
+
|
|
530
|
+
size = 64
|
|
531
|
+
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
532
|
+
draw = ImageDraw.Draw(img)
|
|
533
|
+
draw.rounded_rectangle([0, 0, size - 1, size - 1], radius=14, fill=(26, 29, 33, 255))
|
|
534
|
+
bars = [(12, 36, 6, 16), (22, 14, 6, 38), (32, 28, 6, 24), (42, 10, 6, 42), (52, 38, 6, 14)]
|
|
535
|
+
for x, y, w, h in bars:
|
|
536
|
+
draw.rounded_rectangle([x, y, x + w, y + h], radius=2, fill=(47, 111, 107, 255))
|
|
537
|
+
return img
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _build_tray_icon(url: str, server: uvicorn.Server) -> pystray.Icon | None:
|
|
541
|
+
"""A pystray.Icon with Open/Quit, or None if no tray backend is available
|
|
542
|
+
(headless CI, SSH session, missing OS integration, etc.) -- callers must
|
|
543
|
+
fall back to the plain blocking server in that case."""
|
|
544
|
+
try:
|
|
545
|
+
import pystray
|
|
546
|
+
except ImportError:
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
def _open(icon=None, item=None) -> None:
|
|
550
|
+
webbrowser.open(url)
|
|
551
|
+
|
|
552
|
+
def _quit(icon, item) -> None:
|
|
553
|
+
server.should_exit = True
|
|
554
|
+
icon.stop()
|
|
555
|
+
|
|
556
|
+
return pystray.Icon(
|
|
557
|
+
"mdpad",
|
|
558
|
+
_tray_image(),
|
|
559
|
+
"mdpad",
|
|
560
|
+
menu=pystray.Menu(
|
|
561
|
+
pystray.MenuItem("Open mdpad", _open, default=True),
|
|
562
|
+
pystray.MenuItem("Quit", _quit),
|
|
563
|
+
),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def main() -> None:
|
|
568
|
+
"""Launch the local server and open the workspace in a browser.
|
|
569
|
+
|
|
570
|
+
Usage: ``mdpad [FILE.md]`` -- an optional Markdown file to open instead of
|
|
571
|
+
the bundled example. Its parent directory becomes the open project.
|
|
572
|
+
|
|
573
|
+
Where a tray backend is available (Windows/macOS/most Linux desktops),
|
|
574
|
+
mdpad runs as a tray icon with "Open mdpad" / "Quit", and the server runs
|
|
575
|
+
in a background thread. Otherwise (headless/CI/SSH) it falls back to the
|
|
576
|
+
previous behavior: block in the foreground, open one browser tab.
|
|
577
|
+
"""
|
|
578
|
+
import sys
|
|
579
|
+
import threading
|
|
580
|
+
|
|
581
|
+
import uvicorn
|
|
582
|
+
|
|
583
|
+
global PROJECT_DIR, _current_doc, _current_name
|
|
584
|
+
paths = [a for a in sys.argv[1:] if not a.startswith("-")]
|
|
585
|
+
if paths:
|
|
586
|
+
target = Path(paths[0]).resolve()
|
|
587
|
+
if target.is_file():
|
|
588
|
+
PROJECT_DIR = target.parent
|
|
589
|
+
(PROJECT_DIR / "history").mkdir(exist_ok=True)
|
|
590
|
+
_current_doc = target.read_text(encoding="utf-8")
|
|
591
|
+
_current_name = target.name
|
|
592
|
+
n = summ.load(_summ_path(target.name))
|
|
593
|
+
print(f"mdpad: loaded {target}" + (f" (+{n} cached summaries)" if n else ""))
|
|
594
|
+
else:
|
|
595
|
+
print(f"mdpad: file not found, using the bundled example: {target}")
|
|
596
|
+
|
|
597
|
+
host = "127.0.0.1"
|
|
598
|
+
port = _free_port(DEFAULT_PORT)
|
|
599
|
+
url = f"http://{host}:{port}"
|
|
600
|
+
if port != DEFAULT_PORT:
|
|
601
|
+
print(f"mdpad: port {DEFAULT_PORT} busy, using {port}")
|
|
602
|
+
print(f"mdpad: serving on {url}")
|
|
603
|
+
|
|
604
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
|
605
|
+
server = uvicorn.Server(config)
|
|
606
|
+
icon = _build_tray_icon(url, server)
|
|
607
|
+
threading.Thread(target=_open_when_ready, args=(url,), daemon=True).start()
|
|
608
|
+
|
|
609
|
+
if icon is None:
|
|
610
|
+
server.run()
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
server_thread = threading.Thread(target=server.run, daemon=True)
|
|
614
|
+
server_thread.start()
|
|
615
|
+
try:
|
|
616
|
+
icon.run() # blocks on the main thread (required on macOS) until Quit
|
|
617
|
+
except Exception as e:
|
|
618
|
+
print(f"mdpad: tray icon unavailable ({e}); running without it. Ctrl+C to stop.")
|
|
619
|
+
server_thread.join()
|
mdpad/applog.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""In-memory ring buffer of backend events (provider errors, mock-fallback
|
|
2
|
+
notices, summary-refresh results) -- surfaced in the frontend's Logs tab.
|
|
3
|
+
Nothing here is persisted to disk; it resets on restart.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
|
|
11
|
+
_MAX_LOGS = 200
|
|
12
|
+
_LOGS: deque[dict] = deque(maxlen=_MAX_LOGS)
|
|
13
|
+
_seq = 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def log(level: str, source: str, message: str) -> None:
|
|
17
|
+
"""Record one event. ``level`` is "info" | "warn" | "error"."""
|
|
18
|
+
global _seq
|
|
19
|
+
_seq += 1
|
|
20
|
+
_LOGS.append({"id": _seq, "ts": time.time(), "level": level, "source": source, "message": message})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_logs(since_id: int = 0) -> list[dict]:
|
|
24
|
+
"""Entries with id > since_id, oldest first. ``since_id=0`` returns all
|
|
25
|
+
currently retained entries (older ones fall off the ring buffer)."""
|
|
26
|
+
return [e for e in _LOGS if e["id"] > since_id]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clear() -> None:
|
|
30
|
+
_LOGS.clear()
|