feat(workspace): 完成图片资产存储与引用
This commit is contained in:
@@ -81,6 +81,15 @@ class FolderDeleteRequest(Contract):
|
||||
path: str
|
||||
|
||||
|
||||
class WorkspaceAsset(Contract):
|
||||
asset_id: str
|
||||
path: str
|
||||
content_hash: str
|
||||
media_type: str
|
||||
size: int
|
||||
original_name: str
|
||||
|
||||
|
||||
# 笔记与检索
|
||||
class NoteBlock(Contract):
|
||||
block_id: str
|
||||
|
||||
@@ -172,6 +172,28 @@ MIGRATIONS: list[str] = [
|
||||
"""ALTER TABLE chat_messages ADD COLUMN workspace_context_json TEXT;""",
|
||||
"""ALTER TABLE chat_messages ADD COLUMN attachments_json TEXT NOT NULL DEFAULT '[]';""",
|
||||
"""ALTER TABLE chat_messages ADD COLUMN context_captured INTEGER NOT NULL DEFAULT 0;""",
|
||||
# v13:工作区图片本体保存在 Vault;数据库只保存可检索元数据和笔记引用关系。
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS workspace_assets (
|
||||
asset_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL UNIQUE,
|
||||
media_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL CHECK(size >= 0),
|
||||
original_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS workspace_asset_links (
|
||||
asset_id TEXT NOT NULL REFERENCES workspace_assets(asset_id) ON DELETE CASCADE,
|
||||
note_id TEXT NOT NULL DEFAULT '',
|
||||
note_path TEXT NOT NULL,
|
||||
source TEXT NOT NULL CHECK(source IN ('paste', 'drop', 'upload', 'sync')),
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(asset_id, note_id, note_path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_asset_links_note
|
||||
ON workspace_asset_links(note_id, note_path);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+34
-1
@@ -3,9 +3,10 @@ import json
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import aclosing
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Header, Query, Request
|
||||
from fastapi import APIRouter, Header, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
@@ -106,6 +107,7 @@ from app.contracts import (
|
||||
TranscriptionJob,
|
||||
TranscriptionRequest,
|
||||
WorkspaceEntry,
|
||||
WorkspaceAsset,
|
||||
WorkspaceInfo,
|
||||
WorkspaceOpenRequest,
|
||||
WorkspaceSnapshot,
|
||||
@@ -134,6 +136,7 @@ from app.services import (
|
||||
task_service,
|
||||
transcription_service,
|
||||
workspace_service,
|
||||
workspace_asset_service,
|
||||
)
|
||||
from app.services.attachment_service import attachment_path
|
||||
|
||||
@@ -258,6 +261,36 @@ async def delete_workspace_folder(request: FolderDeleteRequest) -> OperationResp
|
||||
return await workspace_service.delete_folder(request.path)
|
||||
|
||||
|
||||
@router.post("/workspace/assets", response_model=WorkspaceAsset, tags=["Workspace"])
|
||||
async def create_workspace_asset(
|
||||
request: Request,
|
||||
filename: str = Query(min_length=1, max_length=255),
|
||||
note_id: str = Query(default="", max_length=200),
|
||||
note_path: str = Query(min_length=1, max_length=2000),
|
||||
source: Literal["paste", "drop", "upload"] = Query(default="upload"),
|
||||
) -> WorkspaceAsset:
|
||||
content = bytearray()
|
||||
async for chunk in request.stream():
|
||||
content.extend(chunk)
|
||||
if len(content) > workspace_asset_service.MAX_IMAGE_BYTES:
|
||||
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片不能超过 5 MiB。")
|
||||
result = workspace_asset_service.store(
|
||||
bytes(content), original_name=filename, note_id=note_id,
|
||||
note_path=note_path, source=source,
|
||||
)
|
||||
return WorkspaceAsset(**result)
|
||||
|
||||
|
||||
@router.get("/workspace/assets/content", tags=["Workspace"])
|
||||
async def get_workspace_asset_content(
|
||||
path: str = Query(min_length=1, max_length=500),
|
||||
note_id: str = Query(default="", max_length=200),
|
||||
note_path: str = Query(default="", max_length=2000),
|
||||
) -> Response:
|
||||
data, media_type = workspace_asset_service.read(path, note_id=note_id, note_path=note_path)
|
||||
return Response(data, media_type=media_type, headers={"Cache-Control": "private, max-age=31536000, immutable"})
|
||||
|
||||
|
||||
# 笔记
|
||||
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
|
||||
async def list_notes(
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""工作区图片资产:原图归 Vault,SQLite 保存元数据与笔记引用。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from uuid import uuid4
|
||||
|
||||
from app import host_bridge
|
||||
from app.config import get_settings
|
||||
from app.database.db import connect_knowledge, transaction
|
||||
from app.errors import ApiError
|
||||
from app.services.vault_paths import resolve_in_vault
|
||||
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _image_kind(data: bytes) -> tuple[str, str]:
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "png", "image/png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return "jpg", "image/jpeg"
|
||||
if data.startswith((b"GIF87a", b"GIF89a")):
|
||||
return "gif", "image/gif"
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "webp", "image/webp"
|
||||
raise ApiError(415, "WORKSPACE_IMAGE_UNSUPPORTED", "仅支持 PNG、JPEG、GIF 和 WebP 图片。")
|
||||
|
||||
|
||||
def _desktop() -> bool:
|
||||
return get_settings().environment == "desktop"
|
||||
|
||||
|
||||
def _vault_id() -> str:
|
||||
return host_bridge.vault_id.get() or "default"
|
||||
|
||||
|
||||
def _validate_asset_path(path: str) -> str:
|
||||
normalized = PurePosixPath(path.replace("\\", "/"))
|
||||
parts = normalized.parts
|
||||
if normalized.is_absolute() or ".." in parts or len(parts) != 3 or parts[0] != "attachments":
|
||||
raise ApiError(400, "INVALID_PATH", "图片路径不属于工作区附件目录。")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
def _write_web(path: str, data: bytes) -> None:
|
||||
target = resolve_in_vault(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
if target.read_bytes() != data:
|
||||
raise ApiError(409, "RESOURCE_CONFLICT", "附件路径已有不同内容。")
|
||||
return
|
||||
temporary = target.with_name(f".{target.name}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(data)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _write_desktop(path: str, data: bytes) -> None:
|
||||
if host_bridge.active is None:
|
||||
raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。")
|
||||
try:
|
||||
host_bridge.active.call(
|
||||
"workspace.assets.write", vault_id=_vault_id(), path=path,
|
||||
content_base64=base64.b64encode(data).decode("ascii"), operation_id=str(uuid4()),
|
||||
)
|
||||
except RuntimeError as error:
|
||||
raise ApiError(409 if str(error) == "REVISION_CONFLICT" else 503,
|
||||
str(error), "写入工作区图片失败。") from None
|
||||
|
||||
|
||||
def _record(*, digest: str, path: str, media_type: str, size: int, original_name: str,
|
||||
note_id: str, note_path: str, source: str) -> None:
|
||||
asset_id = f"asset_{digest}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = connect_knowledge()
|
||||
try:
|
||||
with transaction(conn):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_assets(asset_id,path,content_hash,media_type,size,original_name,created_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(asset_id, path, digest, media_type, size, Path(original_name).name[:255], now),
|
||||
)
|
||||
if note_path:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_asset_links(asset_id,note_id,note_path,source,created_at) VALUES(?,?,?,?,?)",
|
||||
(asset_id, note_id, note_path.replace("\\", "/").lstrip("/"), source, now),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def store(data: bytes, *, original_name: str, note_id: str, note_path: str, source: str) -> dict:
|
||||
if not data:
|
||||
raise ApiError(400, "WORKSPACE_IMAGE_EMPTY", "图片内容为空。")
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片不能超过 5 MiB。")
|
||||
if source not in {"paste", "drop", "upload"}:
|
||||
raise ApiError(400, "WORKSPACE_IMAGE_SOURCE_INVALID", "图片来源无效。")
|
||||
extension, media_type = _image_kind(data)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
asset_id = f"asset_{digest}"
|
||||
path = f"attachments/{digest[:2]}/{digest}.{extension}"
|
||||
(_write_desktop if _desktop() else _write_web)(path, data)
|
||||
|
||||
_record(digest=digest, path=path, media_type=media_type, size=len(data),
|
||||
original_name=Path(original_name).name or f"image.{extension}", note_id=note_id,
|
||||
note_path=note_path, source=source)
|
||||
return {"asset_id": asset_id, "path": path, "content_hash": digest,
|
||||
"media_type": media_type, "size": len(data), "original_name": Path(original_name).name}
|
||||
|
||||
|
||||
def read(path: str, *, note_id: str = "", note_path: str = "") -> tuple[bytes, str]:
|
||||
path = _validate_asset_path(path)
|
||||
if _desktop():
|
||||
if host_bridge.active is None:
|
||||
raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。")
|
||||
try:
|
||||
result = host_bridge.active.call("workspace.assets.read", vault_id=_vault_id(), path=path)
|
||||
data = base64.b64decode(result["content_base64"], validate=True)
|
||||
except (RuntimeError, KeyError, ValueError):
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。") from None
|
||||
else:
|
||||
target = resolve_in_vault(path)
|
||||
if not target.is_file() or target.is_symlink():
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。")
|
||||
data = target.read_bytes()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片超过读取上限。")
|
||||
_, media_type = _image_kind(data)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
expected = PurePosixPath(path).stem
|
||||
if digest != expected:
|
||||
raise ApiError(409, "WORKSPACE_IMAGE_HASH_MISMATCH", "工作区图片内容与路径哈希不一致。")
|
||||
_record(digest=digest, path=path, media_type=media_type, size=len(data),
|
||||
original_name=PurePosixPath(path).name, note_id=note_id, note_path=note_path,
|
||||
source="sync")
|
||||
return data, media_type
|
||||
@@ -112,6 +112,8 @@ def test_workspace_openapi_paths_are_published() -> None:
|
||||
"/api/workspace/folders",
|
||||
"/api/workspace/folders/rename",
|
||||
"/api/workspace/folders/delete",
|
||||
"/api/workspace/assets",
|
||||
"/api/workspace/assets/content",
|
||||
"/api/notes/{note_id}/rename",
|
||||
} <= paths.keys()
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database.db import connect
|
||||
from app.main import app
|
||||
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"fixture-image"
|
||||
|
||||
|
||||
def test_workspace_image_is_content_addressed_and_linked() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/workspace/assets",
|
||||
params={"filename": "截图.png", "note_id": "note-1", "note_path": "课程/笔记.md", "source": "paste"},
|
||||
content=PNG,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
asset = response.json()
|
||||
target = get_settings().vault_path / asset["path"]
|
||||
assert target.read_bytes() == PNG
|
||||
assert asset["path"].startswith("attachments/")
|
||||
|
||||
content = client.get("/api/workspace/assets/content", params={"path": asset["path"]})
|
||||
assert content.status_code == 200
|
||||
assert content.content == PNG
|
||||
assert content.headers["content-type"] == "image/png"
|
||||
|
||||
duplicate = client.post(
|
||||
"/api/workspace/assets",
|
||||
params={"filename": "same.png", "note_id": "note-2", "note_path": "另一篇.md", "source": "upload"},
|
||||
content=PNG,
|
||||
)
|
||||
assert duplicate.json()["asset_id"] == asset["asset_id"]
|
||||
conn = connect()
|
||||
try:
|
||||
assert conn.execute("SELECT count(*) FROM workspace_assets").fetchone()[0] == 1
|
||||
assert conn.execute("SELECT count(*) FROM workspace_asset_links").fetchone()[0] == 2
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 模拟另一台设备只同步 Vault 文件;读取时会重建本机派生元数据。
|
||||
conn = connect()
|
||||
try:
|
||||
conn.execute("DELETE FROM workspace_asset_links")
|
||||
conn.execute("DELETE FROM workspace_assets")
|
||||
finally:
|
||||
conn.close()
|
||||
restored = client.get(
|
||||
"/api/workspace/assets/content",
|
||||
params={"path": asset["path"], "note_id": "synced-note", "note_path": "同步/笔记.md"},
|
||||
)
|
||||
assert restored.status_code == 200
|
||||
conn = connect()
|
||||
try:
|
||||
assert conn.execute("SELECT source FROM workspace_asset_links").fetchone()[0] == "sync"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_workspace_image_rejects_unknown_content_and_traversal() -> None:
|
||||
with TestClient(app) as client:
|
||||
unsupported = client.post(
|
||||
"/api/workspace/assets",
|
||||
params={"filename": "fake.png", "note_path": "笔记.md", "source": "upload"},
|
||||
content=b"not an image",
|
||||
)
|
||||
assert unsupported.status_code == 415
|
||||
traversal = client.get("/api/workspace/assets/content", params={"path": "../secret.png"})
|
||||
assert traversal.status_code == 400
|
||||
Reference in New Issue
Block a user