diff --git a/README.md b/README.md index 4527044..a808af4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。 -当前发布版本为 **0.3.0-alpha.1**,主要支持 Windows x64。Alpha 版本用于验证完整业务闭环和部署方案,升级前请备份 Vault。 +当前发布版本为 **0.3.1-alpha.2**,主要支持 Windows x64。Alpha 版本仍处于快速迭代阶段,升级前请备份 Vault。 ## 主要能力 @@ -34,6 +34,10 @@ flowchart LR ## 使用发布包 +本版提供 Windows x64 便携包和独立的 Server Sync 包,下载入口见 [v0.3.1-alpha.2 发布页](https://gitea.kronecker.cc/Kronecker/NotesAgentic/releases/tag/v0.3.1-alpha.2)。发布页同时附带 `SHA256.json`,用于核对文件完整性。 + +便携包是干净的首次安装环境,不包含任何 Vault 或用户数据,也不预装已下载的社区主题、本地模型权重、CUDA 与 PyTorch 运行时。相关功能仍完整保留;需要时可在客户端内按需安装主题、选择模型或配置 CUDA 环境。程序自带的基础界面样式属于客户端资源,不视为社区主题。 + 1. 下载 Windows x64 软件包,并核对发布页中的 SHA-256。 2. 将便携版完整解压到可写目录,不要单独移动可执行文件。 3. 启动 `OpenNexus.exe`,选择已有 Vault 或创建新 Vault。 @@ -42,6 +46,12 @@ flowchart LR 凭据不会写入前端 `localStorage`。首次试用建议复制一份现有笔记目录,再用副本验证索引和同步行为。 +### 工作区图片存储 + +在源码或所见即所得编辑器中粘贴、拖入或选择 PNG、JPEG、GIF、WebP 图片后,OpenNexus 会按内容哈希保存到当前 Vault 的 `attachments/<哈希前两位>/.<扩展名>`。Markdown 使用相对路径引用图片,因此笔记目录整体复制、导出或同步后仍可定位原图;单张图片上限为 5 MiB,相同内容只保存一份。 + +图片二进制不写入 SQLite。数据库中的 `workspace_assets` 保存路径、SHA-256、媒体类型、大小和原始文件名,`workspace_asset_links` 保存图片与笔记的引用关系。另一台设备收到 Vault 文件后,会在首次显示图片时校验路径哈希并重建本机元数据。 + ## 开发环境 | 工具 | 版本 | @@ -124,6 +134,8 @@ uv run uvicorn sync_server.main:app --host 0.0.0.0 --port 18080 管理控制台构建后由 Sync Server 一并提供。正式环境应使用 PostgreSQL、S3 兼容对象存储、独立密钥、TLS 终止、进程守护和定期备份;完整变量与部署方式见 [`server sync/README.md`](server%20sync/README.md)。 +新建 Sync 实例首次启动时会生成仅对本次启动有效的随机管理员密码。管理员首次登录后必须修改账户和密码;修改成功后凭据写入数据库,后续重启不再随机更换。升级已有实例会保留已固定的凭据、Vault、设备和修订记录。 + ## 仓库结构 ```text @@ -150,7 +162,7 @@ OpenNexus/ OpenNexus 将 Vault 内容、模型凭据和扩展权限视为敏感数据。请只安装可信来源的 Skill、Plugin 与主题包,并在授权前检查其权限。服务端部署不得使用示例密钥或开发数据库。 -正式发行物通过 Git 标签追踪,并在发布页提供校验和。Windows 安装包的生产门禁还会验证 Authenticode 和 Core 清单签名。无法通过签名门禁的构建只能作为预发布测试包分发。 +正式发行物通过 Git 标签追踪,并在发布页提供校验和。Windows 安装包的生产门禁还会验证 Authenticode 和 Core 清单签名。本版提供的便携包尚未进行 Authenticode 签名,Windows 可能显示未知发布者提示。 ## 参与开发 diff --git a/backend/app/contracts.py b/backend/app/contracts.py index e739ad3..50dc54a 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -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 diff --git a/backend/app/database/migrations.py b/backend/app/database/migrations.py index 088384e..a708cfd 100644 --- a/backend/app/database/migrations.py +++ b/backend/app/database/migrations.py @@ -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); + """, ] diff --git a/backend/app/local_models/worker.py b/backend/app/local_models/worker.py index 8674c76..0102dc6 100644 --- a/backend/app/local_models/worker.py +++ b/backend/app/local_models/worker.py @@ -8,6 +8,23 @@ import sys import threading import time +# Worker 在发布包的临时挂载目录中运行,不能留下会触发 Core 完整性校验的字节码。 +sys.dont_write_bytecode = True + +# 桌面 Host 只向 Core 传入最小环境。PyTorch 编译缓存会通过 getpass +# 读取用户名;在 Windows 上缺少 USERNAME 时,它会误尝试导入 Unix 的 pwd。 +os.environ.setdefault( + "USERNAME", os.path.basename(os.environ.get("USERPROFILE", "OpenNexus")) +) +os.environ.setdefault( + "TORCHINDUCTOR_CACHE_DIR", + os.path.join( + os.environ.get("LOCALAPPDATA", os.environ.get("TEMP", ".")), + "OpenNexus", + "torchinductor", + ), +) + def decode(path, *, limit_seconds=3600, warnings=None): import av diff --git a/backend/app/routes.py b/backend/app/routes.py index aec5a8e..6faa8a5 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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( diff --git a/backend/app/services/workspace_asset_service.py b/backend/app/services/workspace_asset_service.py new file mode 100644 index 0000000..d389961 --- /dev/null +++ b/backend/app/services/workspace_asset_service.py @@ -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 diff --git a/backend/tests/test_workspace.py b/backend/tests/test_workspace.py index 4794917..8ee3b5c 100644 --- a/backend/tests/test_workspace.py +++ b/backend/tests/test_workspace.py @@ -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() diff --git a/backend/tests/test_workspace_assets.py b/backend/tests/test_workspace_assets.py new file mode 100644 index 0000000..eeed822 --- /dev/null +++ b/backend/tests/test_workspace_assets.py @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 23dcc79..a7e59ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "notes-agent-frontend", "private": true, - "version": "0.3.0-alpha.1", + "version": "0.3.1-alpha.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 25ca456..9f8b35d 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -3242,7 +3242,7 @@ dependencies = [ [[package]] name = "notesagent-desktop" -version = "0.3.0-alpha.1" +version = "0.3.1-alpha.2" dependencies = [ "argon2", "base64 0.22.1", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 7203cdf..6a3e8db 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notesagent-desktop" -version = "0.3.0-alpha.1" +version = "0.3.1-alpha.2" edition = "2021" rust-version = "1.89" @@ -14,7 +14,7 @@ required-features = ["desktop"] [features] default = [] -desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64", "dep:tokio"] +desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:tokio"] [dependencies] serde = { version = "1", features = ["derive"] } @@ -27,7 +27,7 @@ fs2 = "0.4" tauri = { version = "2", optional = true, features = ["tray-icon"] } rfd = { version = "0.15", optional = true } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } -base64 = { version = "0.22", optional = true } +base64 = "0.22" tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true } hmac = { version = "0.12", default-features = false } rand = { version = "0.8", default-features = false, features = ["getrandom"] } diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index bde0b03..75a9897 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -35,6 +35,7 @@ fn main() { "sync_unbind", "sync_pause", "sync_status", + "sync_set_scope", "sync_resolve", "sync_logout", "sync_run", diff --git a/frontend/src-tauri/permissions/autogenerated/sync_set_scope.toml b/frontend/src-tauri/permissions/autogenerated/sync_set_scope.toml new file mode 100644 index 0000000..fdc75ea --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_set_scope.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-set-scope" +description = "Enables the sync_set_scope command without any pre-configured scope." +commands.allow = ["sync_set_scope"] + +[[permission]] +identifier = "deny-sync-set-scope" +description = "Denies the sync_set_scope command without any pre-configured scope." +commands.deny = ["sync_set_scope"] diff --git a/frontend/src-tauri/src/workspace_broker.rs b/frontend/src-tauri/src/workspace_broker.rs index 8a8e8ad..9400357 100644 --- a/frontend/src-tauri/src/workspace_broker.rs +++ b/frontend/src-tauri/src/workspace_broker.rs @@ -1,5 +1,6 @@ //! 受限的 Core RPC;每个请求都绑定到 Host 传输捕获的 Vault。 use crate::workspace::Workspace; +use base64::{engine::general_purpose::STANDARD, Engine}; use serde::Deserialize; use serde_json::{json, Value}; @@ -27,6 +28,47 @@ struct Write { } #[derive(Deserialize)] #[serde(deny_unknown_fields)] +struct AssetWrite { + vault_id: String, + path: String, + content_base64: String, + operation_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AssetRead { + vault_id: String, + path: String, +} + +const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024; + +fn valid_asset_path(path: &str) -> bool { + let normalized = path.replace('\\', "/"); + let parts: Vec<_> = normalized.split('/').collect(); + parts.len() == 3 + && parts[0] == "attachments" + && parts[1].len() == 2 + && !parts + .iter() + .any(|part| part.is_empty() || *part == "." || *part == "..") + && ["png", "jpg", "gif", "webp"] + .iter() + .any(|suffix| normalized.ends_with(&format!(".{suffix}"))) +} + +fn valid_image_bytes(path: &str, bytes: &[u8]) -> bool { + let extension = path.rsplit('.').next().unwrap_or(""); + match extension { + "png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "jpg" => bytes.starts_with(b"\xff\xd8\xff"), + "gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + "webp" => bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP", + _ => false, + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] struct Operation { vault_id: String, operation_id: String, @@ -253,6 +295,57 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { .map_err(|e| e.code)?; Ok(json!({"operation_id":p.operation_id,"state":"committed","result":entry})) } + "workspace.assets.write" => { + let p: AssetWrite = decode(params)?; + bound(ws, &p.vault_id)?; + if !valid_asset_path(&p.path) { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + let bytes = STANDARD + .decode(&p.content_base64) + .map_err(|_| "WORKSPACE_REQUEST_INVALID")?; + if bytes.is_empty() + || bytes.len() > MAX_IMAGE_BYTES + || !valid_image_bytes(&p.path, &bytes) + || crate::workspace::hash(&bytes) + != p.path + .split('/') + .last() + .unwrap_or("") + .split('.') + .next() + .unwrap_or("") + { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + let target = ws.resolve(&p.path).map_err(|e| e.code)?; + if target.exists() { + let existing = std::fs::read(target).map_err(|_| "FILESYSTEM_ERROR")?; + if existing != bytes { + return Err("REVISION_CONFLICT".into()); + } + return Ok( + json!({"path":p.path,"hash":crate::workspace::hash(&bytes),"size":bytes.len()}), + ); + } + let entry = ws + .write_operation(&p.path, "", &bytes, "local", &p.operation_id) + .map_err(|e| e.code)?; + Ok(json!({"path":entry.path,"hash":entry.hash,"size":bytes.len()})) + } + "workspace.assets.read" => { + let p: AssetRead = decode(params)?; + bound(ws, &p.vault_id)?; + if !valid_asset_path(&p.path) { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + let bytes = std::fs::read(ws.resolve(&p.path).map_err(|e| e.code)?) + .map_err(|_| "FILE_NOT_FOUND")?; + if bytes.len() > MAX_IMAGE_BYTES { + return Err("CORE_NOTE_TOO_LARGE".into()); + } + Ok(json!({"content_base64":STANDARD.encode(bytes)})) + } "workspace.operation" => { let p: Operation = decode(params)?; bound(ws, &p.vault_id)?; @@ -285,6 +378,39 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { mod tests { use super::*; #[test] + fn workspace_images_are_binary_content_addressed_and_vault_bound() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let bytes = b"\x89PNG\r\n\x1a\nfixture"; + let digest = crate::workspace::hash(bytes); + let path = format!("attachments/{}/{}.png", &digest[..2], digest); + let write = json!({"rpc":"workspace.assets.write","params":{ + "vault_id":ws.vault_id,"path":path,"content_base64":STANDARD.encode(bytes), + "operation_id":uuid::Uuid::new_v4().to_string() + }}); + let stored = dispatch(&mut ws, &write).unwrap(); + assert_eq!(stored["hash"], digest); + assert_eq!(dispatch(&mut ws, &write).unwrap()["hash"], digest); + let read = + json!({"rpc":"workspace.assets.read","params":{"vault_id":ws.vault_id,"path":path}}); + assert_eq!( + STANDARD + .decode( + dispatch(&mut ws, &read).unwrap()["content_base64"] + .as_str() + .unwrap() + ) + .unwrap(), + bytes + ); + let mut denied = write; + denied["params"]["vault_id"] = json!("other-vault"); + assert_eq!( + dispatch(&mut ws, &denied).unwrap_err(), + "VAULT_PERMISSION_CHANGED" + ); + } + #[test] fn user_skills_are_vault_bound_listed_and_deleted_as_logical_records() { let root = tempfile::tempdir().unwrap(); let mut ws = Workspace::open(root.path()).unwrap(); diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index ccb350f..a4d434e 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenNexus", - "version": "0.3.0-alpha.1", + "version": "0.3.1-alpha.2", "identifier": "cc.kronecker.notesagent", "build": { "beforeDevCommand": "pnpm dev", diff --git a/frontend/src/features/editor/SourceMarkdownEditor.spec.ts b/frontend/src/features/editor/SourceMarkdownEditor.spec.ts index 18b9099..99732aa 100644 --- a/frontend/src/features/editor/SourceMarkdownEditor.spec.ts +++ b/frontend/src/features/editor/SourceMarkdownEditor.spec.ts @@ -47,3 +47,16 @@ it('冲突文档禁用命令,保持原始内容', async () => { expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' }) expect(store.content).toBe(original) }) + +it('选择图片后写入工作区并插入相对 Markdown 引用', async () => { + vi.spyOn(workspace, 'storeWorkspaceImage').mockResolvedValue({ + asset_id: 'asset-fixture', path: 'attachments/aa/hash.png', content_hash: 'hash', + media_type: 'image/png', size: 12, original_name: '截图.png', reference: 'attachments/aa/hash.png', + }) + const input = wrapper!.get('input[type="file"]') + const file = new File(['png'], '截图.png', { type: 'image/png' }) + Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }) + await input.trigger('change') + await vi.waitFor(() => expect(useEditorStore().content).toContain('![截图.png](attachments/aa/hash.png)')) + expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/fixture.md', 'note-fixture') +}) diff --git a/frontend/src/features/editor/SourceMarkdownEditor.vue b/frontend/src/features/editor/SourceMarkdownEditor.vue index 30969fa..f42ea36 100644 --- a/frontend/src/features/editor/SourceMarkdownEditor.vue +++ b/frontend/src/features/editor/SourceMarkdownEditor.vue @@ -9,10 +9,12 @@ import { useSettingsStore } from '@/stores/settings' import { registerEditorCommands } from '@/services/editorCommandService' import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties' import AppDialog from '@/components/common/AppDialog.vue' +import { storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService' const props = defineProps<{ initialContent: string }>() const editor = useEditorStore(), settings = useSettingsStore() const root = ref(null), error = ref('') +const imageInput = ref(null) const conflicts = ref([]), choices = ref({}) const proofing = new Compartment() let view: EditorView | undefined, dispose: (() => void) | undefined @@ -22,6 +24,31 @@ function attributes() { 'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' }) } function available() { return !!view && !!editor.currentFilePath && !['conflict', 'external_changed'].includes(editor.saveStatus) } +function imageFiles(list: FileList | null): File[] { + return [...(list ?? [])].filter(file => file.type.startsWith('image/')) +} +async function insertImages(files: File[], source: WorkspaceAssetSource, position?: number) { + if (!view || !available() || !files.length) return + const targetView = view, targetPath = editor.currentFilePath + const at = position ?? targetView.state.selection.main.from + const document = targetView.state.doc + error.value = '' + try { + const assets = [] + for (const file of files) assets.push(await storeWorkspaceImage(file, source, targetPath!, editor.currentNoteId)) + if (view !== targetView || editor.currentFilePath !== targetPath || !available()) return + const markdown = assets.map(asset => `![${asset.original_name.replace(/[\]\\]/g, '\\$&')}](${asset.reference})`).join('\n\n') + const insertion = targetView.state.doc.eq(document) ? Math.min(at, targetView.state.doc.length) : targetView.state.selection.main.from + targetView.dispatch({ changes: { from: insertion, insert: markdown } }) + targetView.focus() + } catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) } +} +function chooseImages() { imageInput.value?.click() } +function selectedImages(event: Event) { + const input = event.target as HTMLInputElement + void insertImages(imageFiles(input.files), 'upload') + input.value = '' +} function importProperties() { if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const } error.value = ''; choices.value = {} @@ -57,6 +84,20 @@ onMounted(() => { view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [ history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()), EditorView.lineWrapping, + EditorView.domEventHandlers({ + paste(event) { + const files = imageFiles(event.clipboardData?.files ?? null) + if (!files.length) return false + event.preventDefault(); void insertImages(files, 'paste'); return true + }, + drop(event, currentView) { + const files = imageFiles(event.dataTransfer?.files ?? null) + if (!files.length) return false + event.preventDefault() + const position = currentView.posAtCoords({ x: event.clientX, y: event.clientY }) ?? currentView.state.selection.main.from + void insertImages(files, 'drop', position); return true + }, + }), EditorView.updateListener.of(update => { if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) } }), @@ -82,7 +123,11 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })