feat(workspace): 完成图片资产存储与引用

This commit is contained in:
2026-09-13 21:52:14 +08:00
parent c87b56a13f
commit 9c35f54560
16 changed files with 655 additions and 10 deletions
+9
View File
@@ -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
+22
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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()
+71
View File
@@ -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