deeptutor-server 1.7.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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What this deployment's capability registry actually resolved at boot.
|
|
2
|
+
|
|
3
|
+
Chat surfaces address a capability by name — ``deep_research``,
|
|
4
|
+
``visualize``, and so on — but not every name ships in this
|
|
5
|
+
repository. Capabilities also arrive from plugins, and the Whisper practice
|
|
6
|
+
room is one of those: its pages live here while ``whisper_visitor`` /
|
|
7
|
+
``whisper_trainee`` are served by an out-of-tree capability. A page had no way
|
|
8
|
+
to ask whether the backend could honour the name it was about to send, so a
|
|
9
|
+
stock install offered the entry, sent the turn anyway, and the learner got
|
|
10
|
+
``Unknown capability: whisper_visitor. Available: [...]`` (#963).
|
|
11
|
+
|
|
12
|
+
This endpoint exposes the backend-owned identity, manifest, and validated
|
|
13
|
+
configuration schema for every turn capability. Presentation remains a
|
|
14
|
+
frontend concern.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter
|
|
20
|
+
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/registered")
|
|
25
|
+
async def list_registered_capabilities() -> dict[str, list[dict[str, object]]]:
|
|
26
|
+
"""Describe every turn capability the deployment can execute."""
|
|
27
|
+
from deeptutor.runtime.registry.capability_registry import get_capability_registry
|
|
28
|
+
|
|
29
|
+
descriptors: list[dict[str, object]] = []
|
|
30
|
+
for item in get_capability_registry().get_manifests():
|
|
31
|
+
manifest = {
|
|
32
|
+
key: value for key, value in item.items() if key not in {"request_schema", "kind"}
|
|
33
|
+
}
|
|
34
|
+
descriptors.append(
|
|
35
|
+
{
|
|
36
|
+
"id": str(item["name"]),
|
|
37
|
+
"kind": str(item.get("kind") or "turn"),
|
|
38
|
+
"available": True,
|
|
39
|
+
"manifest": manifest,
|
|
40
|
+
"config_schema": item.get("request_schema") or {},
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
descriptors.sort(key=lambda item: str(item["id"]))
|
|
44
|
+
return {"capabilities": descriptors}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Capabilities settings endpoint.
|
|
2
|
+
|
|
3
|
+
Surfaces the per-capability tunables (temperature, max_tokens, stage
|
|
4
|
+
budgets, iteration limits) currently scattered across
|
|
5
|
+
``data/user/settings/agents.yaml`` and ``data/user/settings/main.yaml``.
|
|
6
|
+
|
|
7
|
+
Mirrors the pattern used by ``/api/memory/settings``:
|
|
8
|
+
|
|
9
|
+
* ``GET /settings`` → returns the full schema with defaults merged in.
|
|
10
|
+
* ``PUT /settings`` → merges payload back into both YAML files and
|
|
11
|
+
returns the new state.
|
|
12
|
+
|
|
13
|
+
Validation lives in
|
|
14
|
+
:mod:`deeptutor.services.config.capabilities_settings` so the API stays
|
|
15
|
+
a thin transport layer.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter
|
|
23
|
+
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/settings")
|
|
28
|
+
async def get_capabilities_settings_endpoint() -> dict[str, Any]:
|
|
29
|
+
from deeptutor.services.config.capabilities_settings import capabilities_settings_dict
|
|
30
|
+
|
|
31
|
+
return capabilities_settings_dict()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.put("/settings")
|
|
35
|
+
async def put_capabilities_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
from deeptutor.services.config.capabilities_settings import save_capabilities_settings
|
|
37
|
+
|
|
38
|
+
return save_capabilities_settings(payload)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Dashboard API — what the home screen shows before a conversation starts.
|
|
2
|
+
|
|
3
|
+
Recent activity comes from the unified SQLite session store; the starter lines
|
|
4
|
+
come from :mod:`deeptutor.services.suggestions`, which reads memory.
|
|
5
|
+
|
|
6
|
+
Route order matters here: ``/{entry_id}`` at the bottom of this module matches
|
|
7
|
+
any single segment, so every literal path must be declared above it or it will
|
|
8
|
+
never be reached.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException
|
|
15
|
+
|
|
16
|
+
from deeptutor.services.session import get_session_store
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _workspace_rows(reader) -> list[dict[str, Any]]:
|
|
23
|
+
"""Read one index in the request's workspace, naming it on every row.
|
|
24
|
+
|
|
25
|
+
Switching workspace is switching stores: these indexes never merge another
|
|
26
|
+
workspace's content. The name stays on the row so follow-up requests and
|
|
27
|
+
links remain pinned to the store the row came from.
|
|
28
|
+
"""
|
|
29
|
+
from deeptutor.services.workspace import get_content_workspace_service
|
|
30
|
+
from deeptutor.services.workspace.context import current_workspace_id
|
|
31
|
+
|
|
32
|
+
workspace_id = current_workspace_id()
|
|
33
|
+
names = {
|
|
34
|
+
row["workspace_id"]: row["display_name"]
|
|
35
|
+
for row in get_content_workspace_service().list_workspaces()
|
|
36
|
+
}
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
**row,
|
|
40
|
+
"content_workspace_id": workspace_id,
|
|
41
|
+
"content_workspace_name": names.get(workspace_id, ""),
|
|
42
|
+
}
|
|
43
|
+
for row in await reader()
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/recent")
|
|
48
|
+
async def get_recent_activities(limit: int = 50, type: str | None = None):
|
|
49
|
+
store = get_session_store()
|
|
50
|
+
sessions = await store.list_sessions(limit=limit, offset=0)
|
|
51
|
+
activities: list[dict[str, Any]] = []
|
|
52
|
+
|
|
53
|
+
for session in sessions:
|
|
54
|
+
capability = str(session.get("capability") or "chat")
|
|
55
|
+
activity_type = capability.replace("deep_", "")
|
|
56
|
+
if type is not None and activity_type != type:
|
|
57
|
+
continue
|
|
58
|
+
activities.append(
|
|
59
|
+
{
|
|
60
|
+
"id": session.get("session_id"),
|
|
61
|
+
"type": activity_type,
|
|
62
|
+
"capability": capability,
|
|
63
|
+
"title": session.get("title", "Untitled"),
|
|
64
|
+
"timestamp": session.get("updated_at", session.get("created_at", 0)),
|
|
65
|
+
"summary": (session.get("last_message") or "")[:160],
|
|
66
|
+
"session_ref": f"sessions/{session.get('session_id')}",
|
|
67
|
+
"message_count": session.get("message_count", 0),
|
|
68
|
+
"status": session.get("status", "idle"),
|
|
69
|
+
"active_turn_id": session.get("active_turn_id"),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return activities[:limit]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/suggestions")
|
|
77
|
+
async def get_starter_suggestions():
|
|
78
|
+
"""The three starting points for the home composer.
|
|
79
|
+
|
|
80
|
+
Returns immediately, even when the set is stale — regeneration happens
|
|
81
|
+
behind the response. An empty ``suggestions`` list means there is nothing
|
|
82
|
+
in memory to ground a suggestion in, and the client renders nothing.
|
|
83
|
+
|
|
84
|
+
No language parameter: the output language is the learner's own
|
|
85
|
+
model-output setting, resolved server-side. See
|
|
86
|
+
:mod:`deeptutor.services.suggestions`.
|
|
87
|
+
"""
|
|
88
|
+
from deeptutor.services.suggestions import get_suggestions
|
|
89
|
+
|
|
90
|
+
return await get_suggestions()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.post("/suggestions/refresh")
|
|
94
|
+
async def refresh_starter_suggestions():
|
|
95
|
+
"""Generate a new set now. Backs the reroll control.
|
|
96
|
+
|
|
97
|
+
Synchronous, unlike the read: a human clicked and is waiting for a
|
|
98
|
+
different set.
|
|
99
|
+
"""
|
|
100
|
+
from deeptutor.services.suggestions import refresh_suggestions
|
|
101
|
+
|
|
102
|
+
result = await refresh_suggestions()
|
|
103
|
+
return {**result.to_dict(), "stale": False}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/learning-index")
|
|
107
|
+
async def get_learning_index():
|
|
108
|
+
"""Read-only entry points into the request's workspace."""
|
|
109
|
+
from deeptutor.modules.catalog import module_enabled
|
|
110
|
+
from deeptutor.multi_user.learning_access import learning_surface_allowed
|
|
111
|
+
|
|
112
|
+
async def books():
|
|
113
|
+
from deeptutor.api.routers.book import list_books
|
|
114
|
+
|
|
115
|
+
return (await list_books())["books"]
|
|
116
|
+
|
|
117
|
+
async def mastery():
|
|
118
|
+
from deeptutor.api.routers.mastery_path import list_topics
|
|
119
|
+
|
|
120
|
+
return (await list_topics())["topics"]
|
|
121
|
+
|
|
122
|
+
async def reading():
|
|
123
|
+
from deeptutor.api.routers.reading import list_workspaces
|
|
124
|
+
|
|
125
|
+
return (await list_workspaces(search=""))["workspaces"]
|
|
126
|
+
|
|
127
|
+
async def watching():
|
|
128
|
+
from deeptutor.services.session.organization import list_all_sessions_snapshot
|
|
129
|
+
|
|
130
|
+
rows = await list_all_sessions_snapshot(get_session_store())
|
|
131
|
+
return [
|
|
132
|
+
row
|
|
133
|
+
for row in rows
|
|
134
|
+
if not (row.get("preferences") or {}).get("archived")
|
|
135
|
+
and (
|
|
136
|
+
(row.get("preferences") or {}).get("workspace_mode") == "immersive_watching"
|
|
137
|
+
or (row.get("preferences") or {}).get("capability") == "immersive_watching"
|
|
138
|
+
)
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
async def practice():
|
|
142
|
+
from deeptutor.services.session import get_sqlite_session_store
|
|
143
|
+
|
|
144
|
+
return await get_sqlite_session_store().list_notebook_navigation()
|
|
145
|
+
|
|
146
|
+
result = {}
|
|
147
|
+
failed = []
|
|
148
|
+
for kind, surface, reader in (
|
|
149
|
+
("books", "books", books),
|
|
150
|
+
("mastery", "chat", mastery),
|
|
151
|
+
("reading", "reading", reading),
|
|
152
|
+
("watching", "reading", watching),
|
|
153
|
+
("practice", "chat", practice),
|
|
154
|
+
):
|
|
155
|
+
# Calling a route function directly bypasses its HTTP dependencies.
|
|
156
|
+
# Never load a source that the learner could not open individually.
|
|
157
|
+
result[kind] = []
|
|
158
|
+
owner = {"watching": "reading", "practice": "notebook"}.get(kind, kind)
|
|
159
|
+
if not module_enabled(owner) or not learning_surface_allowed(surface):
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
result[kind] = await _workspace_rows(reader)
|
|
163
|
+
except Exception:
|
|
164
|
+
logger.warning("Workspace learning index unavailable: %s", kind, exc_info=True)
|
|
165
|
+
failed.append(kind)
|
|
166
|
+
return {"sources": result, "failed": failed}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.get("/source-library/{kind}")
|
|
170
|
+
async def get_source_library(kind: str):
|
|
171
|
+
"""Source indexes of the request's workspace, for the topic and book pickers."""
|
|
172
|
+
|
|
173
|
+
async def read():
|
|
174
|
+
if kind == "notebooks":
|
|
175
|
+
from deeptutor.services.notebook import notebook_manager
|
|
176
|
+
|
|
177
|
+
return notebook_manager.list_notebooks()
|
|
178
|
+
if kind == "chats":
|
|
179
|
+
from deeptutor.services.session.organization import list_all_sessions_snapshot
|
|
180
|
+
|
|
181
|
+
return await list_all_sessions_snapshot(get_session_store())
|
|
182
|
+
if kind == "drafts":
|
|
183
|
+
from deeptutor.api.routers.co_writer import list_documents
|
|
184
|
+
|
|
185
|
+
return [row.model_dump() for row in (await list_documents())["documents"]]
|
|
186
|
+
raise HTTPException(404, "Unknown source library")
|
|
187
|
+
|
|
188
|
+
if kind == "knowledge":
|
|
189
|
+
# The workspace's own knowledge bases plus the ones assigned to it.
|
|
190
|
+
from deeptutor.multi_user.knowledge_access import list_visible_knowledge_bases
|
|
191
|
+
from deeptutor.services.workspace.context import current_workspace_id
|
|
192
|
+
from deeptutor.services.workspace.knowledge import parse_kb_id
|
|
193
|
+
|
|
194
|
+
items = []
|
|
195
|
+
for row in list_visible_knowledge_bases():
|
|
196
|
+
# Own bases carry a local id; assigned ones name their home store.
|
|
197
|
+
parsed = parse_kb_id(str(row.get("id") or ""))
|
|
198
|
+
origin = parsed[0] if parsed is not None else current_workspace_id()
|
|
199
|
+
items.append(
|
|
200
|
+
{
|
|
201
|
+
**row,
|
|
202
|
+
"content_workspace_id": origin,
|
|
203
|
+
"content_workspace_name": row.get("provenance_label", ""),
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return {"items": items, "unavailable_workspaces": []}
|
|
207
|
+
if kind in {"books", "practice"}:
|
|
208
|
+
return await get_learning_library(kind)
|
|
209
|
+
if kind not in {"notebooks", "chats", "drafts"}:
|
|
210
|
+
raise HTTPException(404, "Unknown source library")
|
|
211
|
+
return {"items": await _workspace_rows(read), "unavailable_workspaces": []}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.get("/learning-library/{kind}")
|
|
215
|
+
async def get_learning_library(kind: str):
|
|
216
|
+
"""The request's workspace library of one kind."""
|
|
217
|
+
can_create = True
|
|
218
|
+
|
|
219
|
+
async def read():
|
|
220
|
+
nonlocal can_create
|
|
221
|
+
if kind == "books":
|
|
222
|
+
from deeptutor.api.routers.book import list_books
|
|
223
|
+
|
|
224
|
+
result = await list_books()
|
|
225
|
+
can_create = result["can_create"]
|
|
226
|
+
return result["books"]
|
|
227
|
+
if kind == "mastery":
|
|
228
|
+
from deeptutor.api.routers.mastery_path import list_topics
|
|
229
|
+
|
|
230
|
+
return (await list_topics())["topics"]
|
|
231
|
+
if kind == "reading":
|
|
232
|
+
from deeptutor.api.routers.reading import list_workspaces
|
|
233
|
+
|
|
234
|
+
return (await list_workspaces(search=""))["workspaces"]
|
|
235
|
+
if kind == "materials":
|
|
236
|
+
from deeptutor.api.routers.reading import list_library_materials
|
|
237
|
+
|
|
238
|
+
return (await list_library_materials(search="", status=None, library_filter="all"))[
|
|
239
|
+
"materials"
|
|
240
|
+
]
|
|
241
|
+
if kind == "practice":
|
|
242
|
+
from deeptutor.services.session import get_sqlite_session_store
|
|
243
|
+
|
|
244
|
+
store = get_sqlite_session_store()
|
|
245
|
+
items = []
|
|
246
|
+
offset = 0
|
|
247
|
+
while True:
|
|
248
|
+
result = await store.list_notebook_entries(limit=200, offset=offset)
|
|
249
|
+
items.extend(result["items"])
|
|
250
|
+
offset += len(result["items"])
|
|
251
|
+
if not result["items"] or offset >= result["total"]:
|
|
252
|
+
return items
|
|
253
|
+
from deeptutor.services.session.organization import list_all_sessions_snapshot
|
|
254
|
+
|
|
255
|
+
rows = await list_all_sessions_snapshot(get_session_store())
|
|
256
|
+
return [
|
|
257
|
+
row
|
|
258
|
+
for row in rows
|
|
259
|
+
if not (row.get("preferences") or {}).get("archived")
|
|
260
|
+
and (
|
|
261
|
+
(row.get("preferences") or {}).get("workspace_mode") == "immersive_watching"
|
|
262
|
+
or (row.get("preferences") or {}).get("capability") == "immersive_watching"
|
|
263
|
+
)
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
if kind not in {"books", "mastery", "reading", "materials", "practice", "watching"}:
|
|
267
|
+
raise HTTPException(status_code=404, detail="Unknown learning library")
|
|
268
|
+
rows = await _workspace_rows(read)
|
|
269
|
+
return {"items": rows, "unavailable_workspaces": [], "can_create": can_create}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@router.get("/{entry_id}")
|
|
273
|
+
async def get_activity_entry(entry_id: str):
|
|
274
|
+
store = get_session_store()
|
|
275
|
+
session = await store.get_session_with_messages(entry_id)
|
|
276
|
+
if session is None:
|
|
277
|
+
raise HTTPException(status_code=404, detail="Entry not found")
|
|
278
|
+
|
|
279
|
+
capability = str(session.get("capability") or "chat")
|
|
280
|
+
return {
|
|
281
|
+
"id": session.get("session_id"),
|
|
282
|
+
"type": capability.replace("deep_", ""),
|
|
283
|
+
"capability": capability,
|
|
284
|
+
"title": session.get("title"),
|
|
285
|
+
"timestamp": session.get("updated_at", session.get("created_at")),
|
|
286
|
+
"content": {
|
|
287
|
+
"messages": session.get("messages", []),
|
|
288
|
+
"active_turns": session.get("active_turns", []),
|
|
289
|
+
"status": session.get("status", "idle"),
|
|
290
|
+
"summary": session.get("compressed_summary", ""),
|
|
291
|
+
},
|
|
292
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""HTTP endpoints for the Persistent File Library (issue #1437).
|
|
2
|
+
|
|
3
|
+
URL shape::
|
|
4
|
+
|
|
5
|
+
GET /files/library/ — list files (newest first)
|
|
6
|
+
POST /files/library/ — upload a file (multipart)
|
|
7
|
+
GET /files/library/search — search by filename (query param: q)
|
|
8
|
+
GET /files/library/{file_id} — get one entry
|
|
9
|
+
GET /files/library/{file_id}/download — download the file
|
|
10
|
+
DELETE /files/library/{file_id} — soft-delete a file
|
|
11
|
+
POST /files/library/{file_id}/restore — restore a soft-deleted file
|
|
12
|
+
|
|
13
|
+
The library is scoped to the authenticated user (via ``require_auth``) so
|
|
14
|
+
each user sees only their own files.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import mimetypes
|
|
21
|
+
|
|
22
|
+
from fastapi import (
|
|
23
|
+
APIRouter,
|
|
24
|
+
Depends,
|
|
25
|
+
File,
|
|
26
|
+
Form,
|
|
27
|
+
HTTPException,
|
|
28
|
+
Query,
|
|
29
|
+
)
|
|
30
|
+
from fastapi.responses import FileResponse
|
|
31
|
+
|
|
32
|
+
from deeptutor.api.routers.auth import require_auth
|
|
33
|
+
from deeptutor.services.auth import TokenPayload
|
|
34
|
+
from deeptutor.services.storage import file_library as _fl
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
router = APIRouter()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_store():
|
|
42
|
+
"""Resolve the store at call time so tests can monkey-patch ``_fl``."""
|
|
43
|
+
return _fl.get_file_library_store()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _mime_type(filename: str) -> str:
|
|
47
|
+
mt, _ = mimetypes.guess_type(filename)
|
|
48
|
+
return mt or "application/octet-stream"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── endpoints ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.get("/", operation_id="library_list")
|
|
55
|
+
async def list_library_files(
|
|
56
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
57
|
+
offset: int = Query(default=0, ge=0),
|
|
58
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
59
|
+
) -> list[dict]:
|
|
60
|
+
"""List active library entries, newest first."""
|
|
61
|
+
return await _get_store().list_files(limit=limit, offset=offset)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.post("/", operation_id="library_add")
|
|
65
|
+
async def add_library_file(
|
|
66
|
+
filename: str = File(..., description="Original filename"),
|
|
67
|
+
mime_type: str = Form(""),
|
|
68
|
+
file: bytes = File(..., description="Raw file bytes"),
|
|
69
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""Add a file to the library.
|
|
72
|
+
|
|
73
|
+
The request must be ``multipart/form-data`` with a ``file`` field containing
|
|
74
|
+
the raw bytes. If a file with the same SHA-256 content hash already exists
|
|
75
|
+
in the library, returns the existing entry (deduplication).
|
|
76
|
+
"""
|
|
77
|
+
entry = await _get_store().add_file(
|
|
78
|
+
data=file,
|
|
79
|
+
filename=filename,
|
|
80
|
+
mime_type=mime_type,
|
|
81
|
+
)
|
|
82
|
+
return entry
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.get("/search", operation_id="library_search")
|
|
86
|
+
async def search_library_files(
|
|
87
|
+
q: str = Query(..., min_length=1, max_length=200),
|
|
88
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
89
|
+
) -> list[dict]:
|
|
90
|
+
"""Search library entries by filename (case-insensitive substring)."""
|
|
91
|
+
return await _get_store().search_files(query=q)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.get("/{file_id}", operation_id="library_get")
|
|
95
|
+
async def get_library_file(
|
|
96
|
+
file_id: str,
|
|
97
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
98
|
+
) -> dict:
|
|
99
|
+
"""Get a single library entry by id (includes soft-deleted)."""
|
|
100
|
+
entry = await _get_store().get_file(file_id)
|
|
101
|
+
if entry is None:
|
|
102
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
103
|
+
return entry
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/{file_id}/download", operation_id="library_download")
|
|
107
|
+
async def download_library_file(
|
|
108
|
+
file_id: str,
|
|
109
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
110
|
+
) -> FileResponse:
|
|
111
|
+
"""Download a library file by id."""
|
|
112
|
+
store = _get_store()
|
|
113
|
+
entry = await store.get_file(file_id)
|
|
114
|
+
if entry is None or entry.get("is_deleted"):
|
|
115
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
116
|
+
|
|
117
|
+
path = store.resolve_path(file_id)
|
|
118
|
+
if path is None or not path.is_file():
|
|
119
|
+
raise HTTPException(status_code=404, detail="File not found on disk")
|
|
120
|
+
|
|
121
|
+
media_type = _mime_type(entry["filename"])
|
|
122
|
+
return FileResponse(
|
|
123
|
+
path,
|
|
124
|
+
media_type=media_type,
|
|
125
|
+
filename=entry["filename"],
|
|
126
|
+
headers={
|
|
127
|
+
"Cache-Control": "private, max-age=31536000, immutable",
|
|
128
|
+
"X-Content-Type-Options": "nosniff",
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.head("/{file_id}/download", operation_id="library_download_head")
|
|
134
|
+
async def download_library_file_head(
|
|
135
|
+
file_id: str,
|
|
136
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Check whether a library file exists (HEAD only)."""
|
|
139
|
+
store = _get_store()
|
|
140
|
+
entry = await store.get_file(file_id)
|
|
141
|
+
if entry is None or entry.get("is_deleted"):
|
|
142
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
143
|
+
path = store.resolve_path(file_id)
|
|
144
|
+
if path is None or not path.is_file():
|
|
145
|
+
raise HTTPException(status_code=404, detail="File not found on disk")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.delete("/{file_id}", operation_id="library_delete")
|
|
149
|
+
async def delete_library_file(
|
|
150
|
+
file_id: str,
|
|
151
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
152
|
+
) -> dict:
|
|
153
|
+
"""Soft-delete a library file (sets is_deleted=1)."""
|
|
154
|
+
deleted = await _get_store().delete_file(file_id)
|
|
155
|
+
if not deleted:
|
|
156
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
157
|
+
return {"id": file_id, "deleted": True}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.post("/{file_id}/restore", operation_id="library_restore")
|
|
161
|
+
async def restore_library_file(
|
|
162
|
+
file_id: str,
|
|
163
|
+
_auth: TokenPayload = Depends(require_auth),
|
|
164
|
+
) -> dict:
|
|
165
|
+
"""Restore a soft-deleted library file."""
|
|
166
|
+
restored = await _get_store().restore_file(file_id)
|
|
167
|
+
if not restored:
|
|
168
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
169
|
+
return {"id": file_id, "restored": True}
|