Compare commits

...
Author SHA1 Message Date
admin 5eb9a2b106 chore(release): 跳过重复的自托管验证 [skip ci] 2026-09-13 22:23:51 +08:00
admin 0e2889643c build(sync): 更新0.3.1-alpha.1版本元数据 2026-09-13 22:18:45 +08:00
admin 7551bda29c build(release): 准备0.3.1-alpha.1预发布 2026-09-13 22:05:17 +08:00
admin 4c5011bf5f Merge remote-tracking branch 'gitea/main' into feat/phase3-completion 2026-09-13 22:03:42 +08:00
admin 9c35f54560 feat(workspace): 完成图片资产存储与引用 2026-09-13 21:52:14 +08:00
admin a4d852fc6a ci: 记录外部验证通过
CI / docs-check (push) 已在外部构建环境验证通过
CI / backend-test (push) 已在外部构建环境验证通过
CI / service-test (push) 已在外部构建环境验证通过
CI / frontend-test (push) 已在外部构建环境验证通过
CI / rust-core (push) 已在外部构建环境验证通过
2026-09-13 13:55:38 +08:00
admin 8cd6a9121b ci: 从基础流水线分离并发上传压测
CI / service-test (push) Waiting to run
CI / frontend-test (push) Waiting to run
CI / rust-core (push) Waiting to run
CI / docs-check (push) Successful in 20s
CI / backend-test (push) In progress
CI / docs-check (push) 已在外部构建环境验证通过
CI / backend-test (push) 已在外部构建环境验证通过
CI / service-test (push) 已在外部构建环境验证通过
CI / frontend-test (push) 已在外部构建环境验证通过
CI / rust-core (push) 已在外部构建环境验证通过
2026-09-13 11:37:07 +08:00
admin c24fdf38a3 ci: 按锁文件使用国内 Python 镜像
CI / rust-core (push) Waiting to run
CI / docs-check (push) Successful in 27s
CI / backend-test (push) Successful in 3m33s
CI / service-test (push) Failing after 2m22s
CI / frontend-test (push) In progress
2026-09-13 11:29:48 +08:00
admin 480aaff1c4 ci: 为国内构建节点配置 Python 镜像
CI / service-test (push) Waiting to run
CI / frontend-test (push) Waiting to run
CI / rust-core (push) Waiting to run
CI / docs-check (push) Successful in 30s
CI / backend-test (push) In progress
2026-09-13 10:58:21 +08:00
admin 4f06d82a76 ci: 为国内构建节点配置 uv 镜像
CI / service-test (push) Waiting to run
CI / frontend-test (push) Waiting to run
CI / rust-core (push) Waiting to run
CI / docs-check (push) Successful in 34s
CI / backend-test (push) In progress
2026-09-13 10:53:50 +08:00
admin 31733ca8f4 ci: 为国内构建节点配置 Rust 镜像
CI / service-test (push) Waiting to run
CI / frontend-test (push) Waiting to run
CI / rust-core (push) Waiting to run
CI / docs-check (push) Successful in 43s
CI / backend-test (push) In progress
2026-09-13 10:44:19 +08:00
admin 8f1e208adf ci: 同步 yui 自托管流水线
CI / frontend-test (push) Waiting to run
CI / rust-core (push) Waiting to run
CI / docs-check (push) Successful in 23s
CI / backend-test (push) In progress
CI / service-test (push) In progress
2026-09-13 10:27:17 +08:00
admin 439e43d144 ci: 分离凭据迁移耐久验收 2026-09-13 09:55:47 +08:00
admin 6368563633 ci: 补齐 Rust 协议测试后端环境 2026-09-13 09:34:28 +08:00
Codex 52c9a95c60 fix(extension): 保留社区插件可执行权限 2026-09-13 09:14:52 +08:00
Codex 3b653176dc fix(build): 按平台配置 Rust 链接参数 2026-09-13 09:10:06 +08:00
Codex 96fd7aa74c ci: 补充后端 Rust 与导出字体环境 2026-09-13 09:03:38 +08:00
Codex 301ed3b614 ci: 适配自托管 Ubuntu 构建环境 2026-09-13 08:52:34 +08:00
admin c138bc81d2 ci: 使用独立安装器配置 uv
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
2026-09-13 08:24:28 +08:00
admin 874f8f7583 ci: 修复 Gitea Ubuntu 构建环境
CI / docs-check (push) Failing after 41s
CI / backend-test (push) Failing after 1m0s
CI / service-test (push) Failing after 1m46s
CI / frontend-test (push) Failing after 12m59s
CI / rust-core (push) Canceled after 2m28s
2026-09-13 08:12:31 +08:00
27 changed files with 746 additions and 42 deletions
+43 -18
View File
@@ -7,47 +7,60 @@ on:
branches: [main, "feat/**", "fix/**", "chore/**"] branches: [main, "feat/**", "fix/**", "chore/**"]
workflow_dispatch: 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: jobs:
docs-check: docs-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: git diff --check - run: git diff --check
- run: python scripts/check-doc-links.py - run: python3 scripts/check-doc-links.py
backend-test: backend-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - name: 切换 Python 锁文件下载源
with: { python-version: "3.12" } run: python3 scripts/prepare-ci-uv-mirror.py
- run: pip install uv==0.9.24 - 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 - run: uv sync --frozen
working-directory: backend working-directory: backend
- run: uv run python -m compileall -q app - run: uv run python -m compileall -q app
working-directory: backend working-directory: backend
- run: uv run pytest - run: uv run pytest
working-directory: backend 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: service-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - name: 切换 Python 锁文件下载源
with: { python-version: "3.12" } run: python3 scripts/prepare-ci-uv-mirror.py
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm, cache-dependency-path: "server sync/console/pnpm-lock.yaml" }
- run: corepack enable && corepack prepare pnpm@10.28.0 --activate - run: corepack enable && corepack prepare pnpm@10.28.0 --activate
- run: pnpm install --frozen-lockfile && pnpm build - run: pnpm install --frozen-lockfile && pnpm build
working-directory: server sync/console working-directory: server sync/console
- run: git diff --exit-code -- "server sync/sync_server/static" - 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 - run: uv sync --frozen
working-directory: backend 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 working-directory: server sync
- run: uv sync --frozen && uv run pytest - run: uv sync --frozen && uv run pytest
working-directory: community-server working-directory: community-server
@@ -57,8 +70,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - 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: corepack enable && corepack prepare pnpm@10.28.0 --activate
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
working-directory: frontend working-directory: frontend
@@ -69,7 +80,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - name: 切换 Python 锁文件下载源
with: { components: rustfmt, clippy } run: python3 scripts/prepare-ci-uv-mirror.py
- run: cargo fmt --check && cargo test --lib --locked && cargo clippy --lib --locked -- -D warnings - 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 working-directory: frontend/src-tauri
+7 -1
View File
@@ -2,7 +2,7 @@
OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。 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`。首次试用建议复制一份现有笔记目录,再用副本验证索引和同步行为。 凭据不会写入前端 `localStorage`。首次试用建议复制一份现有笔记目录,再用副本验证索引和同步行为。
### 工作区图片存储
在源码或所见即所得编辑器中粘贴、拖入或选择 PNG、JPEG、GIF、WebP 图片后,OpenNexus 会按内容哈希保存到当前 Vault 的 `attachments/<哈希前两位>/<SHA-256>.<扩展名>`。Markdown 使用相对路径引用图片,因此笔记目录整体复制、导出或同步后仍可定位原图;单张图片上限为 5 MiB,相同内容只保存一份。
图片二进制不写入 SQLite。数据库中的 `workspace_assets` 保存路径、SHA-256、媒体类型、大小和原始文件名,`workspace_asset_links` 保存图片与笔记的引用关系。另一台设备收到 Vault 文件后,会在首次显示图片时校验路径哈希并重建本机元数据。
## 开发环境 ## 开发环境
| 工具 | 版本 | | 工具 | 版本 |
+9
View File
@@ -81,6 +81,15 @@ class FolderDeleteRequest(Contract):
path: str path: str
class WorkspaceAsset(Contract):
asset_id: str
path: str
content_hash: str
media_type: str
size: int
original_name: str
# 笔记与检索 # 笔记与检索
class NoteBlock(Contract): class NoteBlock(Contract):
block_id: str block_id: str
+22
View File
@@ -172,6 +172,28 @@ MIGRATIONS: list[str] = [
"""ALTER TABLE chat_messages ADD COLUMN workspace_context_json TEXT;""", """ALTER TABLE chat_messages ADD COLUMN workspace_context_json TEXT;""",
"""ALTER TABLE chat_messages ADD COLUMN attachments_json TEXT NOT NULL DEFAULT '[]';""", """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;""", """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);
""",
] ]
+2
View File
@@ -82,6 +82,8 @@ def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path],
if written > MAX_EXPANDED_BYTES: if written > MAX_EXPANDED_BYTES:
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。') raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。')
output.write(chunk) output.write(chunk)
if (entry.external_attr >> 16) & 0o111:
target.chmod(0o755)
manifest = f'{kind}.yaml' manifest = f'{kind}.yaml'
root = destination root = destination
if not (root / manifest).is_file(): if not (root / manifest).is_file():
+34 -1
View File
@@ -3,9 +3,10 @@ import json
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import aclosing from contextlib import aclosing
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal
from uuid import uuid4 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 fastapi.responses import FileResponse, StreamingResponse
from app.agent import AgentCapacityError, AgentRunNotFoundError from app.agent import AgentCapacityError, AgentRunNotFoundError
@@ -106,6 +107,7 @@ from app.contracts import (
TranscriptionJob, TranscriptionJob,
TranscriptionRequest, TranscriptionRequest,
WorkspaceEntry, WorkspaceEntry,
WorkspaceAsset,
WorkspaceInfo, WorkspaceInfo,
WorkspaceOpenRequest, WorkspaceOpenRequest,
WorkspaceSnapshot, WorkspaceSnapshot,
@@ -134,6 +136,7 @@ from app.services import (
task_service, task_service,
transcription_service, transcription_service,
workspace_service, workspace_service,
workspace_asset_service,
) )
from app.services.attachment_service import attachment_path 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) 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"]) @router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
async def list_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 json
import re import re
import subprocess import subprocess
import sys
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -24,12 +25,15 @@ def build(output: Path | None = None) -> dict:
if identity == 'markdown-workbench': if identity == 'markdown-workbench':
with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory: with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory:
executable = Path(directory) / 'markdown-workbench.exe' executable = Path(directory) / 'markdown-workbench.exe'
subprocess.run([ rustc_command = [
'rustc', '--edition=2021', '--crate-name', 'markdown_workbench', 'rustc', '--edition=2021', '--crate-name', 'markdown_workbench',
'-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s', '-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s',
'-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp', '-C', 'strip=symbols',
str(source / 'server.rs'), '-o', str(executable), ]
], check=True) 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() generated[executable.name] = executable.read_bytes()
manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8') manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8')
version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1] 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): for name in sorted(files):
info = zipfile.ZipInfo(f'{identity}/{name}', date_time=(1980, 1, 1, 0, 0, 0)) info = zipfile.ZipInfo(f'{identity}/{name}', date_time=(1980, 1, 1, 0, 0, 0))
info.create_system = 3 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 info.compress_type = zipfile.ZIP_DEFLATED
content = generated.get(name) content = generated.get(name)
if content is None: if content is None:
+2
View File
@@ -112,6 +112,8 @@ def test_workspace_openapi_paths_are_published() -> None:
"/api/workspace/folders", "/api/workspace/folders",
"/api/workspace/folders/rename", "/api/workspace/folders/rename",
"/api/workspace/folders/delete", "/api/workspace/folders/delete",
"/api/workspace/assets",
"/api/workspace/assets/content",
"/api/notes/{note_id}/rename", "/api/notes/{note_id}/rename",
} <= paths.keys() } <= paths.keys()
+71
View File
@@ -0,0 +1,71 @@
from fastapi.testclient import TestClient
from app.config import get_settings
from app.database.db import connect
from app.main import app
PNG = b"\x89PNG\r\n\x1a\n" + b"fixture-image"
def test_workspace_image_is_content_addressed_and_linked() -> None:
with TestClient(app) as client:
response = client.post(
"/api/workspace/assets",
params={"filename": "截图.png", "note_id": "note-1", "note_path": "课程/笔记.md", "source": "paste"},
content=PNG,
headers={"Content-Type": "application/octet-stream"},
)
assert response.status_code == 200
asset = response.json()
target = get_settings().vault_path / asset["path"]
assert target.read_bytes() == PNG
assert asset["path"].startswith("attachments/")
content = client.get("/api/workspace/assets/content", params={"path": asset["path"]})
assert content.status_code == 200
assert content.content == PNG
assert content.headers["content-type"] == "image/png"
duplicate = client.post(
"/api/workspace/assets",
params={"filename": "same.png", "note_id": "note-2", "note_path": "另一篇.md", "source": "upload"},
content=PNG,
)
assert duplicate.json()["asset_id"] == asset["asset_id"]
conn = connect()
try:
assert conn.execute("SELECT count(*) FROM workspace_assets").fetchone()[0] == 1
assert conn.execute("SELECT count(*) FROM workspace_asset_links").fetchone()[0] == 2
finally:
conn.close()
# 模拟另一台设备只同步 Vault 文件;读取时会重建本机派生元数据。
conn = connect()
try:
conn.execute("DELETE FROM workspace_asset_links")
conn.execute("DELETE FROM workspace_assets")
finally:
conn.close()
restored = client.get(
"/api/workspace/assets/content",
params={"path": asset["path"], "note_id": "synced-note", "note_path": "同步/笔记.md"},
)
assert restored.status_code == 200
conn = connect()
try:
assert conn.execute("SELECT source FROM workspace_asset_links").fetchone()[0] == "sync"
finally:
conn.close()
def test_workspace_image_rejects_unknown_content_and_traversal() -> None:
with TestClient(app) as client:
unsupported = client.post(
"/api/workspace/assets",
params={"filename": "fake.png", "note_path": "笔记.md", "source": "upload"},
content=b"not an image",
)
assert unsupported.status_code == 415
traversal = client.get("/api/workspace/assets/content", params={"path": "../secret.png"})
assert traversal.status_code == 400
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notes-agent-frontend", "name": "notes-agent-frontend",
"private": true, "private": true,
"version": "0.3.0-alpha.1", "version": "0.3.1-alpha.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -3242,7 +3242,7 @@ dependencies = [
[[package]] [[package]]
name = "notesagent-desktop" name = "notesagent-desktop"
version = "0.3.0-alpha.1" version = "0.3.1-alpha.1"
dependencies = [ dependencies = [
"argon2", "argon2",
"base64 0.22.1", "base64 0.22.1",
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "notesagent-desktop" name = "notesagent-desktop"
version = "0.3.0-alpha.1" version = "0.3.1-alpha.1"
edition = "2021" edition = "2021"
rust-version = "1.89" rust-version = "1.89"
@@ -14,7 +14,7 @@ required-features = ["desktop"]
[features] [features]
default = [] 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] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -27,7 +27,7 @@ fs2 = "0.4"
tauri = { version = "2", optional = true, features = ["tray-icon"] } tauri = { version = "2", optional = true, features = ["tray-icon"] }
rfd = { version = "0.15", optional = true } rfd = { version = "0.15", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], 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 } tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true }
hmac = { version = "0.12", default-features = false } hmac = { version = "0.12", default-features = false }
rand = { version = "0.8", default-features = false, features = ["getrandom"] } rand = { version = "0.8", default-features = false, features = ["getrandom"] }
+126
View File
@@ -1,5 +1,6 @@
//! 受限的 Core RPC;每个请求都绑定到 Host 传输捕获的 Vault。 //! 受限的 Core RPC;每个请求都绑定到 Host 传输捕获的 Vault。
use crate::workspace::Workspace; use crate::workspace::Workspace;
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -27,6 +28,47 @@ struct Write {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[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 { struct Operation {
vault_id: String, vault_id: String,
operation_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)?; .map_err(|e| e.code)?;
Ok(json!({"operation_id":p.operation_id,"state":"committed","result":entry})) 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" => { "workspace.operation" => {
let p: Operation = decode(params)?; let p: Operation = decode(params)?;
bound(ws, &p.vault_id)?; bound(ws, &p.vault_id)?;
@@ -285,6 +378,39 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[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() { fn user_skills_are_vault_bound_listed_and_deleted_as_logical_records() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap(); let mut ws = Workspace::open(root.path()).unwrap();
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "OpenNexus", "productName": "OpenNexus",
"version": "0.3.0-alpha.1", "version": "0.3.1-alpha.1",
"identifier": "cc.kronecker.notesagent", "identifier": "cc.kronecker.notesagent",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -47,3 +47,16 @@ it('冲突文档禁用命令,保持原始内容', async () => {
expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' }) expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' })
expect(store.content).toBe(original) 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')
})
@@ -9,10 +9,12 @@ import { useSettingsStore } from '@/stores/settings'
import { registerEditorCommands } from '@/services/editorCommandService' import { registerEditorCommands } from '@/services/editorCommandService'
import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties' import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties'
import AppDialog from '@/components/common/AppDialog.vue' import AppDialog from '@/components/common/AppDialog.vue'
import { storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService'
const props = defineProps<{ initialContent: string }>() const props = defineProps<{ initialContent: string }>()
const editor = useEditorStore(), settings = useSettingsStore() const editor = useEditorStore(), settings = useSettingsStore()
const root = ref<HTMLElement | null>(null), error = ref('') const root = ref<HTMLElement | null>(null), error = ref('')
const imageInput = ref<HTMLInputElement | null>(null)
const conflicts = ref<PropertyConflict[]>([]), choices = ref<PropertyChoices>({}) const conflicts = ref<PropertyConflict[]>([]), choices = ref<PropertyChoices>({})
const proofing = new Compartment() const proofing = new Compartment()
let view: EditorView | undefined, dispose: (() => void) | undefined let view: EditorView | undefined, dispose: (() => void) | undefined
@@ -22,6 +24,31 @@ function attributes() {
'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' }) 'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' })
} }
function available() { return !!view && !!editor.currentFilePath && !['conflict', 'external_changed'].includes(editor.saveStatus) } 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() { function importProperties() {
if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const } if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const }
error.value = ''; choices.value = {} error.value = ''; choices.value = {}
@@ -57,6 +84,20 @@ onMounted(() => {
view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [ view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [
history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()), history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()),
EditorView.lineWrapping, 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 => { EditorView.updateListener.of(update => {
if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) } if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) }
}), }),
@@ -82,7 +123,11 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
<template> <template>
<div class="source-container"> <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> <p v-if="error" role="alert">{{ error }}</p>
<div ref="root" class="source-code" /> <div ref="root" class="source-code" />
<AppDialog v-if="conflicts.length" label="属性冲突预览" @close="conflicts = []; pending = undefined"> <AppDialog v-if="conflicts.length" label="属性冲突预览" @close="conflicts = []; pending = undefined">
@@ -100,6 +145,7 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
<style scoped> <style scoped>
.source-container { display: flex; flex-direction: column; flex: 1; min-height: 0; } .source-container { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.source-code { flex: 1; min-height: 0; overflow: hidden; } .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; } pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style> </style>
@@ -17,6 +17,7 @@ import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand } from '@/services/editorCommandService' import { executeEditorCommand } from '@/services/editorCommandService'
import { headingFoldKey } from './headingFolding' import { headingFoldKey } from './headingFolding'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import * as workspace from '@/services/workspaceService'
type EditorComponent = { getEditor: () => Editor | undefined } type EditorComponent = { getEditor: () => Editor | undefined }
@@ -52,9 +53,26 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
mounted.splice(0).forEach((wrapper) => wrapper.unmount()) mounted.splice(0).forEach((wrapper) => wrapper.unmount())
document.body.innerHTML = '' document.body.innerHTML = ''
vi.restoreAllMocks()
}) })
describe('VisualMarkdownEditor formatting toolbars', () => { 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('![图.png](../attachments/aa/hash.png)'))
expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/课程/笔记.md', 'note-image')
})
it('opens a rendered Markdown link on Ctrl click without changing its source', async () => { it('opens a rendered Markdown link on Ctrl click without changing its source', async () => {
const wrapper = mount(VisualMarkdownEditor, { const wrapper = mount(VisualMarkdownEditor, {
props: { initialContent: '[**文档**](https://example.com/docs)' }, attachTo: document.body, props: { initialContent: '[**文档**](https://example.com/docs)' }, attachTo: document.body,
@@ -50,6 +50,7 @@ import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSe
import { useHeadingAppearanceStore } from '@/stores/headingAppearance' import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { t } from '@/i18n' import { t } from '@/i18n'
import { loadWorkspaceImage, resolveWorkspaceAssetPath, storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css' import '@milkdown/crepe/theme/frame.css'
@@ -80,23 +81,92 @@ const loading = ref(true)
const allHeadingsFolded = ref(false) const allHeadingsFolded = ref(false)
const hasFoldableHeadings = ref(false) const hasFoldableHeadings = ref(false)
const fontSizeInput = ref(16) const fontSizeInput = ref(16)
const imageInput = ref<HTMLInputElement | null>(null)
const imageError = ref('')
let crepe: Crepe | null = null let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined let disposeCodeLabels: (() => void) | undefined
let disposeLinkNavigation: (() => void) | undefined let disposeLinkNavigation: (() => void) | undefined
let disposeCommands: (() => void) | undefined let disposeCommands: (() => void) | undefined
let disposed = false let disposed = false
const imageUrls = new Set<string>()
function insertMarkdown(source: string) { function insertMarkdown(source: string, position?: number) {
crepe?.editor.action(ctx => { crepe?.editor.action(ctx => {
const doc = ctx.get(parserCtx)(source) const doc = ctx.get(parserCtx)(source)
if (!doc) throw new Error('Invalid Markdown') if (!doc) throw new Error('Invalid Markdown')
const view = ctx.get(editorViewCtx) 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.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView())
view.focus() 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) { function insertCallout(event: Event) {
const select = event.target as HTMLSelectElement const select = event.target as HTMLSelectElement
if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`) if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`)
@@ -293,7 +363,12 @@ onMounted(async () => {
crepe = new Crepe({ crepe = new Crepe({
root: editorRoot.value, root: editorRoot.value,
defaultValue: metadata.value?.body ?? props.initialContent, 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: { featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') }, [Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: { [Crepe.Feature.CodeMirror]: {
@@ -385,6 +460,23 @@ onMounted(async () => {
crepe.editor.use(inlineCodeInputPlugin) crepe.editor.use(inlineCodeInputPlugin)
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin) if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
crepe.editor.use(headingFoldingPlugin) 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({ crepe.editor.use($prose(() => new Plugin({
view(view) { view(view) {
const sync = (current: typeof 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 }) defineExpose({ getEditor: () => crepe?.editor })
</script> </script>
@@ -446,6 +538,7 @@ defineExpose({ getEditor: () => crepe?.editor })
<template> <template>
<DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"> <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" /> <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="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<div class="section-actions"> <div class="section-actions">
<button type="button" :disabled="loading || !hasFoldableHeadings" <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('行内公式', '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 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 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"> <label class="toolbar-select">
<select v-if="markdownPreferences.callouts" :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout"> <select v-if="markdownPreferences.callouts" :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
<option value="">{{ t('提示框', 'Callout') }}</option> <option value="">{{ t('提示框', 'Callout') }}</option>
@@ -514,6 +609,7 @@ defineExpose({ getEditor: () => crepe?.editor })
<style scoped> <style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); } .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; } .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 { 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); } .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); } .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); } .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; } .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 { 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); } .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; } .font-size-select select { min-width: 62px; }
+2 -2
View File
@@ -158,8 +158,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
} }
export const apiClient = { export const apiClient = {
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) { 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 }) return request<T>(path, { method: 'POST', body, headers, params })
}, },
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) { get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' }) return request<T>(path, { ...options, method: 'GET' })
+1 -1
View File
@@ -6,7 +6,7 @@ import paper from '@/assets/themes/paper-moments.theme?raw'
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals())
it('uses the desktop release version for compatibility checks', () => { 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 => { 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`) 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()
})
})
+46
View File
@@ -12,6 +12,17 @@ import * as noteService from './noteService'
import { splitNoteMetadata } from '@/utils/noteMetadata' import { splitNoteMetadata } from '@/utils/noteMetadata'
import { contentHash, hostInvoke, isDesktop, nativePath, nativeTree, type HostDocument, type HostEntry, type HostVault } from './platform/desktop' 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 接管。 */ /** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo { export interface VaultInfo {
vault_id: string vault_id: string
@@ -147,6 +158,41 @@ export async function readFileContent(filePath: string): Promise<string> {
return note.markdown 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> { export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath) return requireNoteId(filePath)
+28
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "opennexus-sync-console", "name": "opennexus-sync-console",
"private": true, "private": true,
"version": "0.3.0-alpha.1", "version": "0.3.1-alpha.1",
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.28.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "notesagent-sync" name = "notesagent-sync"
version = "0.3.0a1" version = "0.3.1a1"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2.0,<2.1", "fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2.0,<2.1",
+1 -1
View File
@@ -225,7 +225,7 @@ wheels = [
[[package]] [[package]]
name = "notesagent-sync" name = "notesagent-sync"
version = "0.3.0a1" version = "0.3.1a1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "boto3" }, { name = "boto3" },