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
|
||||
Reference in New Issue
Block a user