Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb9a2b106 | ||
|
|
0e2889643c | ||
|
|
7551bda29c | ||
|
|
4c5011bf5f | ||
|
|
9c35f54560 | ||
|
|
a4d852fc6a | ||
|
|
8cd6a9121b | ||
|
|
c24fdf38a3 | ||
|
|
480aaff1c4 | ||
|
|
4f06d82a76 | ||
|
|
31733ca8f4 | ||
|
|
8f1e208adf | ||
|
|
439e43d144 | ||
|
|
6368563633 | ||
|
|
52c9a95c60 | ||
|
|
3b653176dc | ||
|
|
96fd7aa74c | ||
|
|
301ed3b614 | ||
|
|
c138bc81d2 | ||
|
|
874f8f7583 |
+43
-18
@@ -7,47 +7,60 @@ on:
|
||||
branches: [main, "feat/**", "fix/**", "chore/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
APP_EXPORT_FONT: /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
|
||||
RUSTUP_DIST_SERVER: https://rsproxy.cn
|
||||
RUSTUP_UPDATE_ROOT: https://rsproxy.cn/rustup
|
||||
UV_INSTALLER_GITHUB_BASE_URL: https://ghfast.top/https://github.com
|
||||
UV_DEFAULT_INDEX: https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- run: git diff --check
|
||||
- run: python scripts/check-doc-links.py
|
||||
- run: python3 scripts/check-doc-links.py
|
||||
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install uv==0.9.24
|
||||
- name: 切换 Python 锁文件下载源
|
||||
run: python3 scripts/prepare-ci-uv-mirror.py
|
||||
- name: 安装 Rust 工具链
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: 安装 uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/0.9.24/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- run: uv sync --frozen
|
||||
working-directory: backend
|
||||
- run: uv run python -m compileall -q app
|
||||
working-directory: backend
|
||||
- run: uv run pytest
|
||||
working-directory: backend
|
||||
- run: python scripts/phase3-production-acceptance.py --list-cases --json
|
||||
- run: python3 scripts/phase3-production-acceptance.py --list-cases --json
|
||||
|
||||
service-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "22", cache: pnpm, cache-dependency-path: "server sync/console/pnpm-lock.yaml" }
|
||||
- name: 切换 Python 锁文件下载源
|
||||
run: python3 scripts/prepare-ci-uv-mirror.py
|
||||
- run: corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
- run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: server sync/console
|
||||
- run: git diff --exit-code -- "server sync/sync_server/static"
|
||||
- run: pip install uv==0.9.24
|
||||
- name: 安装 uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/0.9.24/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- run: uv sync --frozen
|
||||
working-directory: backend
|
||||
- run: uv sync --frozen && uv run pytest
|
||||
- run: uv sync --frozen && uv run pytest --deselect='tests/test_upload_benchmark.py::test_four_concurrent_uploads_over_real_http[104857600]'
|
||||
working-directory: server sync
|
||||
- run: uv sync --frozen && uv run pytest
|
||||
working-directory: community-server
|
||||
@@ -57,8 +70,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "22", cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml }
|
||||
- run: corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
- run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
@@ -69,7 +80,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: rustfmt, clippy }
|
||||
- run: cargo fmt --check && cargo test --lib --locked && cargo clippy --lib --locked -- -D warnings
|
||||
- name: 切换 Python 锁文件下载源
|
||||
run: python3 scripts/prepare-ci-uv-mirror.py
|
||||
- name: 安装 Rust 工具链
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --component rustfmt,clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: 准备协议测试所需的后端环境
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/0.9.24/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
uv sync --frozen
|
||||
working-directory: backend
|
||||
- name: 运行 Rust 基础检查
|
||||
run: |
|
||||
cargo fmt --check
|
||||
cargo test --lib --locked -- --skip credentials::tests::b04_migration_survives_twenty_hard_terminations_per_boundary
|
||||
cargo clippy --lib --locked -- -D warnings
|
||||
working-directory: frontend/src-tauri
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。
|
||||
|
||||
当前发布版本为 **0.3.0-alpha.1**,主要支持 Windows x64。Alpha 版本用于验证完整业务闭环和部署方案,升级前请备份 Vault。
|
||||
当前预发布版本为 **0.3.1-alpha.1**,主要支持 Windows x64。Alpha 版本用于验证完整业务闭环和部署方案,升级前请备份 Vault。
|
||||
|
||||
## 主要能力
|
||||
|
||||
@@ -42,6 +42,12 @@ flowchart LR
|
||||
|
||||
凭据不会写入前端 `localStorage`。首次试用建议复制一份现有笔记目录,再用副本验证索引和同步行为。
|
||||
|
||||
### 工作区图片存储
|
||||
|
||||
在源码或所见即所得编辑器中粘贴、拖入或选择 PNG、JPEG、GIF、WebP 图片后,OpenNexus 会按内容哈希保存到当前 Vault 的 `attachments/<哈希前两位>/<SHA-256>.<扩展名>`。Markdown 使用相对路径引用图片,因此笔记目录整体复制、导出或同步后仍可定位原图;单张图片上限为 5 MiB,相同内容只保存一份。
|
||||
|
||||
图片二进制不写入 SQLite。数据库中的 `workspace_assets` 保存路径、SHA-256、媒体类型、大小和原始文件名,`workspace_asset_links` 保存图片与笔记的引用关系。另一台设备收到 Vault 文件后,会在首次显示图片时校验路径哈希并重建本机元数据。
|
||||
|
||||
## 开发环境
|
||||
|
||||
| 工具 | 版本 |
|
||||
|
||||
@@ -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);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path],
|
||||
if written > MAX_EXPANDED_BYTES:
|
||||
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。')
|
||||
output.write(chunk)
|
||||
if (entry.external_attr >> 16) & 0o111:
|
||||
target.chmod(0o755)
|
||||
manifest = f'{kind}.yaml'
|
||||
root = destination
|
||||
if not (root / manifest).is_file():
|
||||
|
||||
+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
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -24,12 +25,15 @@ def build(output: Path | None = None) -> dict:
|
||||
if identity == 'markdown-workbench':
|
||||
with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory:
|
||||
executable = Path(directory) / 'markdown-workbench.exe'
|
||||
subprocess.run([
|
||||
rustc_command = [
|
||||
'rustc', '--edition=2021', '--crate-name', 'markdown_workbench',
|
||||
'-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s',
|
||||
'-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp',
|
||||
str(source / 'server.rs'), '-o', str(executable),
|
||||
], check=True)
|
||||
'-C', 'strip=symbols',
|
||||
]
|
||||
if sys.platform == 'win32':
|
||||
rustc_command.extend(['-C', 'link-arg=-Wl,--no-insert-timestamp'])
|
||||
rustc_command.extend([str(source / 'server.rs'), '-o', str(executable)])
|
||||
subprocess.run(rustc_command, check=True)
|
||||
generated[executable.name] = executable.read_bytes()
|
||||
manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8')
|
||||
version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1]
|
||||
@@ -38,7 +42,7 @@ def build(output: Path | None = None) -> dict:
|
||||
for name in sorted(files):
|
||||
info = zipfile.ZipInfo(f'{identity}/{name}', date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o100644 << 16
|
||||
info.external_attr = (0o100755 if name == 'markdown-workbench.exe' else 0o100644) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
content = generated.get(name)
|
||||
if content is None:
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notes-agent-frontend",
|
||||
"private": true,
|
||||
"version": "0.3.0-alpha.1",
|
||||
"version": "0.3.1-alpha.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -3242,7 +3242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notesagent-desktop"
|
||||
version = "0.3.0-alpha.1"
|
||||
version = "0.3.1-alpha.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "notesagent-desktop"
|
||||
version = "0.3.0-alpha.1"
|
||||
version = "0.3.1-alpha.1"
|
||||
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"] }
|
||||
|
||||
@@ -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<Value, String> {
|
||||
.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<Value, String> {
|
||||
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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenNexus",
|
||||
"version": "0.3.0-alpha.1",
|
||||
"version": "0.3.1-alpha.1",
|
||||
"identifier": "cc.kronecker.notesagent",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -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(''))
|
||||
expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/fixture.md', 'note-fixture')
|
||||
})
|
||||
|
||||
@@ -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<HTMLElement | null>(null), error = ref('')
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const conflicts = ref<PropertyConflict[]>([]), choices = ref<PropertyChoices>({})
|
||||
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 })
|
||||
|
||||
<template>
|
||||
<div class="source-container">
|
||||
<div class="source-actions"><button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性…</button></div>
|
||||
<div class="source-actions">
|
||||
<button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性…</button>
|
||||
<button class="btn" :disabled="!available()" @click="chooseImages">插入图片…</button>
|
||||
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple @change="selectedImages" />
|
||||
</div>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<div ref="root" class="source-code" />
|
||||
<AppDialog v-if="conflicts.length" label="属性冲突预览" @close="conflicts = []; pending = undefined">
|
||||
@@ -100,6 +145,7 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
|
||||
<style scoped>
|
||||
.source-container { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.source-code { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.source-actions { padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.source-actions { display: flex; gap: var(--space-sm); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { executeEditorCommand } from '@/services/editorCommandService'
|
||||
import { headingFoldKey } from './headingFolding'
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
import * as workspace from '@/services/workspaceService'
|
||||
|
||||
type EditorComponent = { getEditor: () => Editor | undefined }
|
||||
|
||||
@@ -52,9 +53,26 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
mounted.splice(0).forEach((wrapper) => wrapper.unmount())
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
it('uploads a selected image and keeps a portable Markdown reference', async () => {
|
||||
const store = useEditorStore(); store.currentFilePath = '/课程/笔记.md'; store.currentNoteId = 'note-image'
|
||||
vi.spyOn(workspace, 'storeWorkspaceImage').mockResolvedValue({
|
||||
asset_id: 'asset-image', path: 'attachments/aa/hash.png', content_hash: 'hash',
|
||||
media_type: 'image/png', size: 12, original_name: '图.png', reference: '../attachments/aa/hash.png',
|
||||
})
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '' }, attachTo: document.body })
|
||||
mounted.push(wrapper); const editor = await waitForEditor(wrapper)
|
||||
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(editor.action(getMarkdown())).toContain(''))
|
||||
expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/课程/笔记.md', 'note-image')
|
||||
})
|
||||
|
||||
it('opens a rendered Markdown link on Ctrl click without changing its source', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {
|
||||
props: { initialContent: '[**文档**](https://example.com/docs)' }, attachTo: document.body,
|
||||
|
||||
@@ -50,6 +50,7 @@ import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSe
|
||||
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
import { t } from '@/i18n'
|
||||
import { loadWorkspaceImage, resolveWorkspaceAssetPath, storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
@@ -80,23 +81,92 @@ const loading = ref(true)
|
||||
const allHeadingsFolded = ref(false)
|
||||
const hasFoldableHeadings = ref(false)
|
||||
const fontSizeInput = ref(16)
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const imageError = ref('')
|
||||
let crepe: Crepe | null = null
|
||||
let disposeLanguagePicker: (() => void) | undefined
|
||||
let disposeCodeLabels: (() => void) | undefined
|
||||
let disposeLinkNavigation: (() => void) | undefined
|
||||
let disposeCommands: (() => void) | undefined
|
||||
let disposed = false
|
||||
const imageUrls = new Set<string>()
|
||||
|
||||
function insertMarkdown(source: string) {
|
||||
function insertMarkdown(source: string, position?: number) {
|
||||
crepe?.editor.action(ctx => {
|
||||
const doc = ctx.get(parserCtx)(source)
|
||||
if (!doc) throw new Error('Invalid Markdown')
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (position !== undefined) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, Math.min(position, view.state.doc.content.size))))
|
||||
view.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView())
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function imageFiles(list: FileList | null): File[] {
|
||||
return [...(list ?? [])].filter(file => file.type.startsWith('image/'))
|
||||
}
|
||||
|
||||
async function insertImages(files: File[], source: WorkspaceAssetSource, position?: number) {
|
||||
if (!crepe || !editorStore.currentFilePath || !files.length) return
|
||||
const target = crepe, targetPath = editorStore.currentFilePath
|
||||
const document = target.editor.action(ctx => ctx.get(editorViewCtx).state.doc)
|
||||
imageError.value = ''
|
||||
try {
|
||||
const assets = []
|
||||
for (const file of files) assets.push(await storeWorkspaceImage(file, source, targetPath, editorStore.currentNoteId))
|
||||
if (crepe !== target || editorStore.currentFilePath !== targetPath) return
|
||||
const current = target.editor.action(ctx => ctx.get(editorViewCtx).state.doc)
|
||||
insertMarkdown(assets.map(asset => `![${asset.original_name.replace(/[\]\\]/g, '\\$&')}](${asset.reference})`).join('\n\n'), current.eq(document) ? position : undefined)
|
||||
} catch (reason) {
|
||||
imageError.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 workspaceImageNodeView(node: { type: unknown; attrs: Record<string, unknown> }) {
|
||||
const notePath = editorStore.currentFilePath
|
||||
const dom = document.createElement('img')
|
||||
let source = '', objectUrl = '', generation = 0
|
||||
const apply = (next: typeof node) => {
|
||||
const nextSource = String(next.attrs.src ?? '')
|
||||
dom.alt = String(next.attrs.alt ?? '')
|
||||
if (next.attrs.title) dom.title = String(next.attrs.title)
|
||||
else dom.removeAttribute('title')
|
||||
if (nextSource === source) return
|
||||
source = nextSource
|
||||
const current = ++generation
|
||||
if (objectUrl) { URL.revokeObjectURL(objectUrl); imageUrls.delete(objectUrl); objectUrl = '' }
|
||||
const path = notePath && resolveWorkspaceAssetPath(notePath, nextSource)
|
||||
if (!path) { dom.src = nextSource; return }
|
||||
dom.dataset.workspaceAsset = path
|
||||
void loadWorkspaceImage(path, notePath, editorStore.currentNoteId).then(blob => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
if (disposed || current !== generation) { URL.revokeObjectURL(url); return }
|
||||
objectUrl = url; imageUrls.add(url); dom.src = url
|
||||
}).catch(() => {
|
||||
if (current === generation) dom.dataset.workspaceAssetError = 'true'
|
||||
})
|
||||
}
|
||||
apply(node)
|
||||
return {
|
||||
dom,
|
||||
update(next: typeof node) {
|
||||
if (next.type !== node.type) return false
|
||||
node = next; apply(next); return true
|
||||
},
|
||||
destroy() {
|
||||
generation++
|
||||
if (objectUrl) { URL.revokeObjectURL(objectUrl); imageUrls.delete(objectUrl) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertCallout(event: Event) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`)
|
||||
@@ -293,7 +363,12 @@ onMounted(async () => {
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: metadata.value?.body ?? props.initialContent,
|
||||
features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
|
||||
// 使用标准 Markdown 图片节点,确保 alt 文本和相对路径可被其他编辑器直接读取。
|
||||
features: {
|
||||
[Crepe.Feature.TopBar]: false,
|
||||
[Crepe.Feature.Latex]: markdownPreferences.math,
|
||||
[Crepe.Feature.ImageBlock]: false,
|
||||
},
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
@@ -385,6 +460,23 @@ onMounted(async () => {
|
||||
crepe.editor.use(inlineCodeInputPlugin)
|
||||
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
|
||||
crepe.editor.use(headingFoldingPlugin)
|
||||
crepe.editor.use($prose(() => new Plugin({
|
||||
props: {
|
||||
nodeViews: { image: workspaceImageNodeView },
|
||||
handlePaste(_view, event) {
|
||||
const files = imageFiles(event.clipboardData?.files ?? null)
|
||||
if (!files.length) return false
|
||||
event.preventDefault(); void insertImages(files, 'paste'); return true
|
||||
},
|
||||
handleDrop(view, event) {
|
||||
const files = imageFiles(event.dataTransfer?.files ?? null)
|
||||
if (!files.length) return false
|
||||
event.preventDefault()
|
||||
const position = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? view.state.selection.from
|
||||
void insertImages(files, 'drop', position); return true
|
||||
},
|
||||
},
|
||||
})))
|
||||
crepe.editor.use($prose(() => new Plugin({
|
||||
view(view) {
|
||||
const sync = (current: typeof view) => {
|
||||
@@ -438,7 +530,7 @@ watch(() => editorStore.headingRequest, request => {
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); imageUrls.forEach(URL.revokeObjectURL); imageUrls.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||
|
||||
defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
@@ -446,6 +538,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
<template>
|
||||
<DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables">
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<p v-if="imageError" class="image-error" role="alert">{{ imageError }}</p>
|
||||
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
|
||||
<div class="section-actions">
|
||||
<button type="button" :disabled="loading || !hasFoldableHeadings"
|
||||
@@ -489,6 +582,8 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
<button v-if="markdownPreferences.math" type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
|
||||
<button v-if="markdownPreferences.math" type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph">∑</span></button>
|
||||
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
|
||||
<button type="button" :title="t('插入工作区图片', 'Insert workspace image')" :aria-label="t('插入工作区图片', 'Insert workspace image')" @pointerdown.prevent="chooseImages"><span class="image-glyph">▧</span></button>
|
||||
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple @change="selectedImages" />
|
||||
<label class="toolbar-select">
|
||||
<select v-if="markdownPreferences.callouts" :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
|
||||
<option value="">{{ t('提示框', 'Callout') }}</option>
|
||||
@@ -514,6 +609,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
|
||||
<style scoped>
|
||||
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
|
||||
.image-error { margin: 0; padding: var(--space-sm) var(--space-lg); color: var(--color-error); background: var(--color-error-soft); }
|
||||
.hide-code-line-numbers :deep(.cm-lineNumbers) { display: none; }
|
||||
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
|
||||
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
@@ -532,6 +628,8 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
.list-lines { overflow: hidden; width: 14px; font-size: 15px; line-height: 1; transform: scaleX(1.2); }
|
||||
.code-glyph, .block-glyph { padding: 0; background: transparent; color: inherit; font: 700 13px/1 var(--font-editor-mono); }
|
||||
.math-glyph { font: italic 700 16px/1 Georgia, 'Times New Roman', serif; }
|
||||
.image-glyph { font-size: 18px; line-height: 1; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
.toolbar-select { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 5px 3px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
.toolbar-select select { min-width: 58px; border: 0; outline: 0; background: transparent; color: inherit; cursor: pointer; font-size: var(--font-size-sm); }
|
||||
.font-size-select select { min-width: 62px; }
|
||||
|
||||
@@ -158,8 +158,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
|
||||
return request<T>(path, { method: 'POST', body, headers })
|
||||
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }, params?: RequestOptions['params']) {
|
||||
return request<T>(path, { method: 'POST', body, headers, params })
|
||||
},
|
||||
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
|
||||
return request<T>(path, { ...options, method: 'GET' })
|
||||
|
||||
@@ -6,7 +6,7 @@ import paper from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
it('uses the desktop release version for compatibility checks', () => {
|
||||
expect(THEME_APP_VERSION).toBe('0.3.0-alpha.1')
|
||||
expect(THEME_APP_VERSION).toBe('0.3.1-alpha.1')
|
||||
})
|
||||
it.each(['999.0.0', 'bad', '0.2'])('rejects unsupported minimum app version %s at inspection and install', async version => {
|
||||
const source = paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${version}\n`)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveWorkspaceAssetPath, workspaceAssetReference } from './workspaceService'
|
||||
|
||||
describe('workspace asset paths', () => {
|
||||
it('creates portable references relative to the note', () => {
|
||||
expect(workspaceAssetReference('/课程/系统/调度.md', 'attachments/ab/hash.png')).toBe('../../attachments/ab/hash.png')
|
||||
expect(resolveWorkspaceAssetPath('/课程/系统/调度.md', '../../attachments/ab/hash.png')).toBe('attachments/ab/hash.png')
|
||||
})
|
||||
|
||||
it('does not resolve remote URLs or paths escaping the vault', () => {
|
||||
expect(resolveWorkspaceAssetPath('/a.md', 'https://example.com/image.png')).toBeNull()
|
||||
expect(resolveWorkspaceAssetPath('/a.md', '../attachments/ab/hash.png')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,17 @@ import * as noteService from './noteService'
|
||||
import { splitNoteMetadata } from '@/utils/noteMetadata'
|
||||
import { contentHash, hostInvoke, isDesktop, nativePath, nativeTree, type HostDocument, type HostEntry, type HostVault } from './platform/desktop'
|
||||
|
||||
export interface WorkspaceAsset {
|
||||
asset_id: string
|
||||
path: string
|
||||
content_hash: string
|
||||
media_type: string
|
||||
size: number
|
||||
original_name: string
|
||||
}
|
||||
|
||||
export type WorkspaceAssetSource = 'paste' | 'drop' | 'upload'
|
||||
|
||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||
export interface VaultInfo {
|
||||
vault_id: string
|
||||
@@ -147,6 +158,41 @@ export async function readFileContent(filePath: string): Promise<string> {
|
||||
return note.markdown
|
||||
}
|
||||
|
||||
/** 将 Vault 根路径转换为相对当前笔记的可移植 Markdown 引用。 */
|
||||
export function workspaceAssetReference(notePath: string, assetPath: string): string {
|
||||
const noteParts = relativePath(notePath).split('/').filter(Boolean)
|
||||
noteParts.pop()
|
||||
return `${'../'.repeat(noteParts.length)}${relativePath(assetPath)}`
|
||||
}
|
||||
|
||||
/** 将笔记内的相对附件引用还原为 Vault 根路径。 */
|
||||
export function resolveWorkspaceAssetPath(notePath: string, reference: string): string | null {
|
||||
if (/^(?:[a-z]+:|\/\/|#)/i.test(reference)) return null
|
||||
const parts = [...relativePath(notePath).split('/').slice(0, -1)]
|
||||
for (const part of reference.replace(/\\/g, '/').split('/')) {
|
||||
if (!part || part === '.') continue
|
||||
if (part === '..') { if (!parts.length) return null; parts.pop() }
|
||||
else parts.push(part)
|
||||
}
|
||||
const path = parts.join('/')
|
||||
return path.startsWith('attachments/') ? path : null
|
||||
}
|
||||
|
||||
export async function storeWorkspaceImage(
|
||||
file: Blob & { name?: string }, source: WorkspaceAssetSource,
|
||||
notePath: string, noteId: string | null,
|
||||
): Promise<WorkspaceAsset & { reference: string }> {
|
||||
const asset = await apiClient.postBinary<WorkspaceAsset>('/api/workspace/assets', file, { 'Content-Type': 'application/octet-stream' }, {
|
||||
filename: file.name || 'image', note_id: noteId || '', note_path: notePath, source,
|
||||
})
|
||||
return { ...asset, reference: workspaceAssetReference(notePath, asset.path) }
|
||||
}
|
||||
|
||||
export async function loadWorkspaceImage(path: string, notePath = '', noteId: string | null = null): Promise<Blob> {
|
||||
const response = await apiClient.get<Response>('/api/workspace/assets/content', { params: { path, note_path: notePath, note_id: noteId || '' } })
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
/** 解析已与工作空间路径关联的后端笔记标识。 */
|
||||
export async function getNoteId(filePath: string): Promise<string> {
|
||||
return requireNoteId(filePath)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOCK_FILES = (
|
||||
Path("backend/uv.lock"),
|
||||
Path("server sync/uv.lock"),
|
||||
Path("community-server/uv.lock"),
|
||||
)
|
||||
|
||||
REPLACEMENTS = {
|
||||
"https://pypi.org/simple": "https://mirrors.aliyun.com/pypi/simple",
|
||||
"https://files.pythonhosted.org/packages/": "https://mirrors.aliyun.com/pypi/packages/",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for path in LOCK_FILES:
|
||||
if not path.is_file():
|
||||
continue
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for source, mirror in REPLACEMENTS.items():
|
||||
content = content.replace(source, mirror)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
print(f"已切换锁文件下载源:{path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opennexus-sync-console",
|
||||
"private": true,
|
||||
"version": "0.3.0-alpha.1",
|
||||
"version": "0.3.1-alpha.1",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "notesagent-sync"
|
||||
version = "0.3.0a1"
|
||||
version = "0.3.1a1"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2.0,<2.1",
|
||||
|
||||
Generated
+1
-1
@@ -225,7 +225,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "notesagent-sync"
|
||||
version = "0.3.0a1"
|
||||
version = "0.3.1a1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
Reference in New Issue
Block a user