release: OpenNexus 0.5.0

This commit is contained in:
2026-09-17 20:59:32 +08:00
parent e86809b238
commit 40ceb50788
34 changed files with 509 additions and 60 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。
当前发布版本为 **0.4.0-alpha.1**,主要支持 Windows x64。Alpha 版本仍处于快速迭代阶段,升级前请备份 Vault。
当前发布版本为 **0.5.0**,主要支持 Windows x64。升级前请备份 Vault。
## 主要能力
@@ -34,7 +34,7 @@ flowchart LR
## 使用发布包
本版提供 Windows x64 EXE 安装包和独立的 Server Sync 包,下载入口见 [v0.4.0-alpha.1 发布页](https://gitea.kronecker.cc/Kronecker/NotesAgentic/releases/tag/v0.4.0-alpha.1)。发布页同时附带 `SHA256.json`,用于核对文件完整性。
本版提供 Windows x64 EXE 安装包和独立的 Server Sync 包,下载入口见 [v0.5.0 发布页](https://gitea.kronecker.cc/Kronecker/NotesAgentic/releases/tag/v0.5.0)。发布页同时附带 `SHA256.json`,用于核对文件完整性。
安装包不包含任何 Vault 或用户数据,也不预装已下载的社区主题、本地模型权重、CUDA 与 PyTorch 运行时。相关功能仍完整保留;需要时可在客户端内按需安装主题、选择模型或配置 CUDA 环境。程序自带的基础界面样式属于客户端资源,不视为社区主题。同一 Windows 用户下升级安装会继续使用 `%APPDATA%\cc.kronecker.notesagent` 中的既有配置和索引,以及用户此前选择的外部 Vault。
+1 -1
View File
@@ -33,7 +33,7 @@ def get_settings() -> Settings:
data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data")))
return Settings(
name=os.getenv("APP_NAME", "OpenNexus AI Core"),
version=os.getenv("APP_VERSION", "0.1.0"),
version=os.getenv("APP_VERSION", "0.5.0"),
environment=os.getenv("APP_ENVIRONMENT", "development"),
host=os.getenv("APP_HOST", "127.0.0.1"),
port=int(os.getenv("APP_PORT", "8000")),
+6
View File
@@ -1250,6 +1250,12 @@ class TranscriptNoteRequest(Contract):
include_speakers: bool = True
class TranscriptArtifactsRequest(TranscriptNoteRequest):
provider_id: str = Field(min_length=1, max_length=128)
model: str = Field(min_length=1, max_length=256)
knowledge_title: str | None = Field(default=None, min_length=1, max_length=200)
class IndexStatus(Contract):
running_jobs: int = 0
active_searches: int = 0
+5 -2
View File
@@ -5,11 +5,14 @@ import os
import shutil
import subprocess
from app.config import BACKEND_DIR
from app.config import BACKEND_DIR, get_settings
from app.errors import ApiError
from app.local_models.process import ThreadedProcess
ROOT = BACKEND_DIR / '.venv-models-cuda'
# 模型运行环境会在安装与升级时写入大量文件,必须位于应用数据目录,
# 不能写入受完整性清单保护的 Core 发布目录。
DEFAULT_ROOT = get_settings().data_dir / 'model-runtime'
ROOT = DEFAULT_ROOT
state = {'status': 'unchecked', 'stage': '', 'cuda_available': None}
task = None
+7 -1
View File
@@ -11,7 +11,7 @@ from uuid import uuid4
from fastapi import APIRouter, Header, Query, Request
from fastapi.responses import FileResponse, StreamingResponse
from app.contracts import TranscriptEditRequest, TranscriptNoteRequest, TranscriptionJob
from app.contracts import TranscriptArtifactsRequest, TranscriptEditRequest, TranscriptNoteRequest, TranscriptionJob
from app.database.db import connect, transaction
from app.errors import ApiError
from app.services import transcription_service as jobs
@@ -160,6 +160,12 @@ async def create_note(job_id: str, request: TranscriptNoteRequest):
return await create_transcript_note(job_id, request)
@router.post("/transcriptions/{job_id}/artifacts", status_code=201)
async def create_artifacts(job_id: str, request: TranscriptArtifactsRequest):
from app.services.media_notes import create_transcript_artifacts
return await create_transcript_artifacts(job_id, request)
@router.get("/attachments/{attachment_id}/cleanup-impact")
async def cleanup_impact(attachment_id: str):
attachment_path(attachment_id)
+1 -1
View File
@@ -41,7 +41,7 @@ async def execute(call, request):
run = await container.agent.create_run(AgentRunCreateRequest(
input=task, provider_id=request.provider_id, model=request.model,
skill_id=skill_id,
allowed_tools=ALLOWED_TOOLS, max_steps=10, token_budget=16000,
allowed_tools=ALLOWED_TOOLS, max_steps=10, token_budget=None,
allow_network=False, metadata={'source': 'chat', 'conversation_id': request.conversation_id},
))
elif call.name == 'agent.status':
+133
View File
@@ -4,8 +4,11 @@ import hashlib
from contextlib import closing
from app.config import get_settings
from app.contracts import Message, MessageRole, ModelRequest, TranscriptNoteRequest
from app.database.db import connect, transaction
from app.errors import ApiError
from app.providers.base import ProviderError
from app.providers.registry import ProviderNotFoundError
from app.services import note_service
from app.services.transcription_service import require_job
@@ -66,6 +69,136 @@ async def create_transcript_note(job_id, options):
return note
def _transcript_text(job) -> str:
if job.segments:
rows = []
for segment in job.segments:
speaker = job.speaker_names.get(segment.speaker, segment.speaker) if segment.speaker else ""
stamp = f"{int(segment.start_time // 60):02}:{int(segment.start_time % 60):02}"
rows.append(f"[{stamp}] {speaker}{segment.text}" if speaker else f"[{stamp}] {segment.text}")
return "\n".join(rows)
return job.text or ""
def _chunks(text: str, limit: int = 12000) -> list[str]:
"""按段落切分长转录,避免在中间截断句子。"""
paragraphs = [part.strip() for part in text.splitlines() if part.strip()]
if not paragraphs:
return []
chunks: list[str] = []
current: list[str] = []
size = 0
for paragraph in paragraphs:
if current and size + len(paragraph) + 1 > limit:
chunks.append("\n".join(current))
current, size = [], 0
if len(paragraph) > limit:
if current:
chunks.append("\n".join(current))
current, size = [], 0
chunks.extend(paragraph[index:index + limit] for index in range(0, len(paragraph), limit))
continue
current.append(paragraph)
size += len(paragraph) + 1
if current:
chunks.append("\n".join(current))
return chunks
async def _complete(provider_id: str, model: str, system: str, content: str) -> str:
from app.container import container
try:
provider = container.providers.get(provider_id).adapter
except ProviderNotFoundError as exc:
raise ApiError(404, "PROVIDER_NOT_FOUND", "所选模型提供商不存在或未启用。",
{"provider_id": provider_id}) from exc
try:
turn = await provider.complete(ModelRequest(
provider_id=provider_id,
model=model,
system=system,
messages=[Message(role=MessageRole.user, content=content)],
temperature=0.2,
))
except ProviderError as exc:
raise ApiError(502, exc.code, exc.message, {"provider_id": provider_id}) from exc
if not turn.text or not turn.text.strip():
raise ApiError(502, "KNOWLEDGE_NOTE_EMPTY", "模型没有返回知识点笔记。")
return turn.text.strip().removeprefix("```markdown").removeprefix("```").removesuffix("```").strip()
async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> str:
transcript = _transcript_text(job)
if not transcript.strip():
raise ApiError(409, "TRANSCRIPT_EMPTY", "转录内容为空,无法提取知识点。")
system = (
"你是一名严谨的课程笔记整理助手。只能依据提供的转录内容整理,不补写未出现的事实。"
"输出中文 Markdown 正文,使用清晰的二级、三级标题;包含课程主题、核心概念、关键论证或步骤、"
"重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。不要使用代码围栏,也不要写处理说明。"
)
parts = _chunks(transcript)
summaries: list[str] = []
for index, part in enumerate(parts, 1):
summaries.append(await _complete(
provider_id, model, system,
f"这是课程转录的第 {index}/{len(parts)} 部分。请提取可供最终整合的知识点:\n\n{part}",
))
if len(summaries) == 1:
body = summaries[0]
else:
body = await _complete(
provider_id, model, system,
"请将以下分段知识点合并成一篇完整课程笔记,消除重复并保持逻辑顺序:\n\n"
+ "\n\n".join(f"### 分段 {index}\n{summary}" for index, summary in enumerate(summaries, 1)),
)
return "\n".join([
f"<!-- knowledge-note:{job.job_id}:{job.revision}:{provider_id}:{model} -->",
f"# {title}", "", f"[查看完整转录稿](/#/media?job={job.job_id})", "", body,
])
async def create_transcript_artifacts(job_id, options):
"""为完成的转录生成可回听的全文和模型整理的知识点笔记。"""
transcript_options = TranscriptNoteRequest(
title=options.title,
folder=options.folder,
update_existing=options.update_existing,
include_timestamps=options.include_timestamps,
include_speakers=options.include_speakers,
)
transcript_note = await create_transcript_note(job_id, transcript_options)
job = require_job(job_id)
knowledge_title = options.knowledge_title or f"{options.title} · 知识点"
identity = (str(get_settings().db_path), job_id, "knowledge")
lock = _locks.setdefault(identity, asyncio.Lock())
async with lock:
signature = "knowledge:" + hashlib.sha256(options.model_copy(update={
"update_existing": False,
"knowledge_title": knowledge_title,
}).model_dump_json(exclude={"update_existing"}).encode()).hexdigest()
with closing(connect()) as conn:
row = conn.execute(
"SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?",
(job_id, job.revision, signature),
).fetchone()
if row:
knowledge_note = await note_service.get_note(row[0])
if knowledge_note is not None:
return {"transcript": transcript_note, "knowledge_note": knowledge_note}
markdown = await _knowledge_markdown(job, options.provider_id, options.model, knowledge_title)
note_title = f"{knowledge_title} · {job_id[-8:]}-r{job.revision}-{signature[-6:]}"
knowledge_note = await note_service.create_note(
title=note_title,
markdown=markdown,
folder=options.folder,
tags=["课程笔记", "知识点"],
)
with closing(connect()) as conn, transaction(conn):
conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)",
(job_id, job.revision, signature, knowledge_note.note_id))
return {"transcript": transcript_note, "knowledge_note": knowledge_note}
async def _create_note(title, markdown, options, marker):
try:
note = await note_service.create_note(title=title, markdown=markdown, folder=options.folder, tags=["转写"])
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "notes-agent-backend"
version = "0.1.0"
version = "0.5.0"
description = "Notes Agent 的 FastAPI 基础壳子"
readme = "README.md"
requires-python = ">=3.11"
+12
View File
@@ -96,6 +96,18 @@ def test_terminology_export_and_privacy_cleanup():
first = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
again = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
assert first['note_id'] == again['note_id']
artifacts = client.post(f'/api/media/transcriptions/{job_id}/artifacts', json={
'title': '课程', 'knowledge_title': '课程知识点',
'provider_id': 'mock', 'model': 'mock-1',
})
assert artifacts.status_code == 201
assert artifacts.json()['transcript']['note_id'] == first['note_id']
assert artifacts.json()['knowledge_note']['note_id'] != first['note_id']
repeated = client.post(f'/api/media/transcriptions/{job_id}/artifacts', json={
'title': '课程', 'knowledge_title': '课程知识点',
'provider_id': 'mock', 'model': 'mock-1',
})
assert repeated.json()['knowledge_note']['note_id'] == artifacts.json()['knowledge_note']['note_id']
response = client.delete('/api/media/attachments/lecture.txt')
assert first['note_id'] in response.json()['retained_note_ids']
cleaned = client.get(f'/api/media/transcriptions/{job_id}').json()
-2
View File
@@ -13,8 +13,6 @@ def isolate(monkeypatch, tmp_path):
monkeypatch.setattr(components, 'ROOT', tmp_path / 'cuda')
monkeypatch.setattr(components, 'state', {'status': 'unchecked', 'stage': '', 'cuda_available': None})
monkeypatch.setattr(components, 'task', None)
def test_status_checks_without_installing_and_detects_existing_cuda(monkeypatch):
python = components.ROOT / 'Scripts/python.exe'
python.parent.mkdir(parents=True)
+1 -1
View File
@@ -1108,7 +1108,7 @@ wheels = [
[[package]]
name = "notes-agent-backend"
version = "0.1.0"
version = "0.5.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notes-agent-frontend",
"private": true,
"version": "0.4.0-alpha.1",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3242,7 +3242,7 @@ dependencies = [
[[package]]
name = "notesagent-desktop"
version = "0.4.0-alpha.1"
version = "0.5.0"
dependencies = [
"argon2",
"base64 0.22.1",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "notesagent-desktop"
version = "0.4.0-alpha.1"
version = "0.5.0"
edition = "2021"
rust-version = "1.89"
@@ -48,7 +48,7 @@ cap-fs-ext = "4.0.2"
jsonschema = { version = "0.55", default-features = false }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_IO", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Cryptography", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_IO", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
[build-dependencies]
tauri-build = { version = "2", optional = true , features = [] }
@@ -0,0 +1,44 @@
use notesagent_host::credentials::CredentialBroker;
use std::path::PathBuf;
fn main() -> Result<(), String> {
let mut arguments = std::env::args_os().skip(1);
let vault = PathBuf::from(arguments.next().ok_or("TARGET_REQUIRED")?);
let legacy = PathBuf::from(arguments.next().ok_or("LEGACY_REQUIRED")?);
if arguments.next().is_some() {
return Err("ARGUMENTS_INVALID".into());
}
let parent = vault.parent().ok_or("TARGET_INVALID")?;
let backup = parent.join("stronghold.pre-0.5.0.onxcred");
let auto_key = parent.join("auto-unlock.dpapi");
if auto_key.exists() {
return Err("AUTO_UNLOCK_ALREADY_CONFIGURED".into());
}
if backup.exists() {
return Err("BACKUP_ALREADY_EXISTS".into());
}
if vault.exists() {
std::fs::rename(&vault, &backup).map_err(|_| "BACKUP_FAILED")?;
}
let migrated = (|| {
let mut broker = CredentialBroker::new(vault.clone());
if !broker.ensure_system_unlock()? {
return Err("AUTO_UNLOCK_INITIALIZATION_FAILED".into());
}
broker.import_fernet(&legacy, None)
})();
match migrated {
Ok(count) => {
println!("Migrated {count} credential(s) to Windows automatic unlock.");
Ok(())
}
Err(error) => {
let _ = std::fs::remove_file(&vault);
let _ = std::fs::remove_file(&auto_key);
if backup.exists() {
let _ = std::fs::rename(&backup, &vault);
}
Err(error)
}
}
}
@@ -0,0 +1,109 @@
//! Windows DPAPI-backed storage for the random Stronghold unlock secret.
use std::fs;
use std::io::Write;
use std::path::Path;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
use zeroize::Zeroizing;
type Result<T> = std::result::Result<T, String>;
const MAGIC: &[u8] = b"ONXDPAPI1";
const ENTROPY: &[u8] = b"OpenNexus credential auto-unlock v1";
fn transform(data: &[u8], protect: bool) -> Result<Zeroizing<Vec<u8>>> {
let input = CRYPT_INTEGER_BLOB {
cbData: u32::try_from(data.len()).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?,
pbData: data.as_ptr() as *mut u8,
};
let entropy = CRYPT_INTEGER_BLOB {
cbData: ENTROPY.len() as u32,
pbData: ENTROPY.as_ptr() as *mut u8,
};
let mut output = CRYPT_INTEGER_BLOB::default();
let ok = unsafe {
if protect {
CryptProtectData(
&input,
std::ptr::null(),
&entropy,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
} else {
CryptUnprotectData(
&input,
std::ptr::null_mut(),
&entropy,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
}
};
if ok == 0 || output.pbData.is_null() || output.cbData == 0 {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
let result = unsafe {
Zeroizing::new(std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec())
};
if !protect {
unsafe { std::ptr::write_bytes(output.pbData, 0, output.cbData as usize) };
}
unsafe { LocalFree(output.pbData as *mut core::ffi::c_void) };
Ok(result)
}
pub fn load(path: &Path) -> Result<Option<Zeroizing<Vec<u8>>>> {
if !path.exists() {
return Ok(None);
}
let metadata = fs::symlink_metadata(path).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 64 * 1024 {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
let bytes = fs::read(path).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
if !bytes.starts_with(MAGIC) || bytes.len() == MAGIC.len() {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
transform(&bytes[MAGIC.len()..], false).map(Some)
}
pub fn save(path: &Path, secret: &[u8]) -> Result<()> {
let protected = transform(secret, true)?;
let parent = path.parent().ok_or("CREDENTIAL_AUTO_UNLOCK_FAILED")?;
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
let mut target =
tempfile::NamedTempFile::new_in(parent).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
target
.write_all(MAGIC)
.and_then(|_| target.write_all(&protected))
.and_then(|_| target.as_file().sync_all())
.map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
target
.persist(path)
.map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dpapi_round_trip_never_persists_plaintext() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("auto-unlock.dpapi");
let secret = b"test-system-secret-123456789";
save(&path, secret).unwrap();
assert!(!fs::read(&path)
.unwrap()
.windows(secret.len())
.any(|part| part == secret));
assert_eq!(load(&path).unwrap().unwrap().as_slice(), secret);
}
}
+74
View File
@@ -354,6 +354,52 @@ pub struct CredentialBroker {
}
impl CredentialBroker {
#[cfg(windows)]
fn auto_unlock_path(&self) -> Result<PathBuf> {
Ok(self
.path
.parent()
.ok_or("CREDENTIAL_PATH_INVALID")?
.join("auto-unlock.dpapi"))
}
/// Unlocks with a random secret protected by Windows DPAPI. A new vault is initialized
/// automatically; an existing password vault is never overwritten implicitly.
#[cfg(windows)]
pub fn ensure_system_unlock(&mut self) -> Result<bool> {
let key_path = self.auto_unlock_path()?;
if let Some(secret) = crate::credential_autounlock::load(&key_path)? {
self.unlock(secret)?;
return Ok(true);
}
if self.path.exists() {
return Ok(false);
}
let mut secret = Zeroizing::new(vec![0u8; 32]);
rand::rngs::OsRng
.try_fill_bytes(&mut secret)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
crate::credential_autounlock::save(&key_path, &secret)?;
self.unlock(secret)?;
Ok(true)
}
#[cfg(windows)]
pub fn enable_system_unlock(&self, password: &[u8]) -> Result<()> {
self.session()?;
crate::credential_autounlock::save(&self.auto_unlock_path()?, password)
}
#[cfg(windows)]
pub fn has_system_unlock(&self) -> bool {
self.auto_unlock_path().is_ok_and(|path| path.is_file())
}
#[cfg(not(windows))]
pub fn has_system_unlock(&self) -> bool {
false
}
/// 源来自本机文件选择器,而不是原始 WebView 路径。导入是幂等的;冲突的 ID 会停止整个事务。
pub fn import_fernet(
&mut self,
@@ -1041,6 +1087,34 @@ mod tests {
fn password() -> Zeroizing<Vec<u8>> {
Zeroizing::new(b"test-only-password-123".to_vec())
}
#[cfg(windows)]
#[test]
fn system_unlock_survives_broker_restart() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("credentials/stronghold.v1");
let id = CredentialId {
scope: Scope::Provider,
id: "provider-restart".into(),
};
{
let mut broker = CredentialBroker::new(path.clone());
assert!(broker.ensure_system_unlock().unwrap());
broker
.put(&id, Zeroizing::new(b"restart-secret".to_vec()))
.unwrap();
}
let mut restarted = CredentialBroker::new(path);
assert!(restarted.ensure_system_unlock().unwrap());
assert_eq!(
restarted
.resolve(&Scope::Provider, &id)
.unwrap()
.unwrap()
.as_slice(),
b"restart-secret"
);
}
fn b04_fixture() -> (Vec<u8>, String, BTreeMap<String, String>) {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
+2
View File
@@ -2,6 +2,8 @@
pub mod core;
pub mod core_update;
#[cfg(windows)]
mod credential_autounlock;
pub mod credentials;
mod payloads;
mod preference_records;
+25 -16
View File
@@ -561,7 +561,10 @@ fn credentials_status(host: State<'_, Host>) -> Result<serde_json::Value, String
.try_lock()
.map_err(|_| "CREDENTIALS_BUSY")?;
let broker = broker.as_ref().ok_or("HOST_NOT_READY")?;
Ok(serde_json::json!({"locked":broker.is_locked()}))
Ok(serde_json::json!({
"locked": broker.is_locked(),
"automatic": if cfg!(windows) { broker.has_system_unlock() } else { false }
}))
}
#[tauri::command]
@@ -578,12 +581,13 @@ async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(
let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.unlock(password)
let mut guard = broker.lock().map_err(|_| "HOST_BUSY")?;
let broker = guard.as_mut().ok_or("HOST_NOT_READY")?;
let retained = Zeroizing::new(password.to_vec());
broker.unlock(password)?;
#[cfg(windows)]
broker.enable_system_unlock(&retained)?;
Ok(())
})
.await
.map_err(|_| "HOST_BUSY")?
@@ -705,12 +709,13 @@ async fn credentials_change_password(
let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.change_password(password)
let mut guard = broker.lock().map_err(|_| "HOST_BUSY")?;
let broker = guard.as_mut().ok_or("HOST_NOT_READY")?;
let retained = Zeroizing::new(password.to_vec());
broker.change_password(password)?;
#[cfg(windows)]
broker.enable_system_unlock(&retained)?;
Ok(())
})
.await
.map_err(|_| "HOST_BUSY")?
@@ -948,11 +953,15 @@ fn main() {
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(extension_store);
let credential_state = app.state::<Host>().credentials.clone();
let mut broker =
CredentialBroker::new(app.path().app_data_dir()?.join("credentials/stronghold.v1"));
#[cfg(windows)]
broker
.ensure_system_unlock()
.map_err(std::io::Error::other)?;
*credential_state
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
));
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(broker);
let signal = credential_state
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))?
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenNexus",
"version": "0.4.0-alpha.1",
"version": "0.5.0",
"identifier": "cc.kronecker.notesagent",
"build": {
"beforeDevCommand": "pnpm dev",
+13 -3
View File
@@ -21,7 +21,7 @@ const { openCitation } = useCitationNavigation()
const pageError = ref('')
const form = reactive({
input: '', provider_id: '', model: '', skill_id: '', max_steps: 10,
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000,
tool_timeout_seconds: 30, run_timeout_seconds: 300, limit_token_budget: false, token_budget: 8000,
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
})
@@ -61,7 +61,7 @@ async function createRun() {
input: form.input, provider_id: form.provider_id, model: form.model,
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
max_steps: form.max_steps, tool_timeout_seconds: form.tool_timeout_seconds,
run_timeout_seconds: form.run_timeout_seconds, token_budget: form.token_budget,
run_timeout_seconds: form.run_timeout_seconds, token_budget: form.limit_token_budget ? form.token_budget : null,
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
})
await router.replace({ name: 'agent', params: { runId: run.run_id } })
@@ -101,7 +101,7 @@ async function handleOpenCitation(data: Record<string, unknown>) {
<div class="field"><label>{{ t('最大步骤', 'Maximum steps') }}</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
<div class="field"><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('运行超时(秒)', 'Run timeout (seconds)') }}</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('令牌预算', 'Token budget') }}</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
<div class="field budget-field"><label><input v-model="form.limit_token_budget" type="checkbox" />{{ t('限制令牌消耗', 'Limit token usage') }}</label><input v-if="form.limit_token_budget" v-model.number="form.token_budget" class="input" type="number" min="1" :aria-label="t('令牌上限', 'Token limit')" /><small v-else class="subtle">{{ t('默认不限制仍可随时取消运行', 'Unlimited by default; the run can still be cancelled at any time.') }}</small></div>
<div class="field"><label>{{ t('最大并发工具', 'Maximum concurrent tools') }}</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
</div>
<div class="field"><label>{{ t('允许使用的工具', 'Allowed tools') }}</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
@@ -150,6 +150,16 @@ async function handleOpenCitation(data: Record<string, unknown>) {
<style scoped>
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
.run-form { display: grid; gap: var(--space-xl); }
.budget-field {
min-height: 78px;
align-content: center;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
}
.budget-field > label { color: var(--color-text-primary); }
.budget-field > .input { background: var(--color-surface-primary); }
.tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.network { display: flex; gap: var(--space-sm); }
.trace-layout { display: grid; gap: var(--space-lg); }
+40 -8
View File
@@ -9,8 +9,10 @@ import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
import { useProviderStore } from '@/stores/provider'
const route = useRoute()
const providerStore = useProviderStore()
const maxUploadMiB = isDesktop() ? 64 : 128
const submission = createMediaSubmission()
const updateExisting = ref(false)
@@ -44,6 +46,10 @@ const error = ref('')
const notice = ref('')
const dirty = ref(false)
const title = ref(t('课堂转写', 'Class transcript'))
const knowledgeTitle = ref(t('课堂知识点笔记', 'Class knowledge notes'))
const providerId = ref('')
const model = ref('')
const models = computed(() => providerStore.modelsByProvider[providerId.value] ?? [])
const player = ref<HTMLAudioElement | null>(null)
const position = ref(0)
const speed = ref(1)
@@ -121,23 +127,40 @@ async function compareSpeaker() {
}
})
}
async function createArtifacts() {
if (!selected.value || !providerId.value || !model.value.trim()) return
await action(async () => {
const result = await mediaService.artifacts(selected.value!.job_id, {
title: title.value,
knowledge_title: knowledgeTitle.value,
provider_id: providerId.value,
model: model.value,
update_existing: updateExisting.value,
})
notice.value = t(
`已生成完整转录稿“${result.transcript.title}”和知识点笔记“${result.knowledge_note.title}”。`,
`Created transcript “${result.transcript.title}” and knowledge notes “${result.knowledge_note.title}”.`,
)
})
}
function loaded() { if (player.value) player.value.playbackRate = speed.value; const seconds = Number(route.query.time || 0); if (Number.isFinite(seconds) && seconds >= 0) seek(seconds) }
onMounted(async () => {
await refresh()
await Promise.all([refresh(), providerStore.loadProviders()])
providerId.value = providerStore.defaultProviderId
if (typeof route.query.job === 'string') {
try { selected.value = await mediaService.get(route.query.job) } catch (e) { error.value = (e as Error).message }
}
})
watch(providerId, async (value) => {
model.value = providerStore.providers.find(item => item.provider_id === value)?.default_model ?? ''
if (!value) return
try { await providerStore.loadModels(value) } catch { /* 允许手动填写模型 ID。 */ }
})
onUnmounted(() => { stopped = true; clearTimeout(timer) })
</script>
<template>
<section class="media-page">
<details class="ui-disclosure">
<summary>{{ t('当前转写能力与验收范围', 'Transcription capabilities and validation') }}</summary>
<p>{{ t('本地转写提供片段级时间戳与说话人聚类,不提供逐字强制对齐或重叠语音分离。聚类编号不代表已确认的真实人数。', 'Local transcription provides segment timestamps and speaker clusters, without forced word alignment or overlapping speech separation. Cluster IDs are not verified speaker counts.') }}</p>
<p>{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}</p>
</details>
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t(`上传音频或视频音轨,转写、校对后保存到知识库。最多 ${maxUploadMiB} MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。`, `Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to ${maxUploadMiB} MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.`) }}</p></div></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
@@ -181,7 +204,16 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">{{ t('修订历史', 'Revision history') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision" class="ui-disclosure"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('更新上次导出的笔记(已手动修改则拒绝)', 'Update the previously exported note (refuse if manually edited)') }}</label><input v-model="title" class="input" :aria-label="t('笔记标题', 'Note title')" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title, updateExisting); notice = `${t('已保存笔记:', 'Saved note: ')}${note.title}` })">{{ t('保存为笔记', 'Save as note') }}</button></div>
<section class="artifact-panel">
<div><h3>{{ t('生成课程材料', 'Create course materials') }}</h3><p class="subtle">{{ t('一次生成两份内容:带时间戳的完整转录稿,以及由所选模型提取的知识点笔记。', 'Create two outputs: a timestamped full transcript and knowledge notes extracted by the selected model.') }}</p></div>
<div class="artifact-grid">
<label>{{ t('转录稿标题', 'Transcript title') }}<input v-model="title" class="input" /></label>
<label>{{ t('知识点笔记标题', 'Knowledge-note title') }}<input v-model="knowledgeTitle" class="input" /></label>
<label>{{ t('模型提供商', 'Model provider') }}<select v-model="providerId" class="select"><option value="">{{ t('请选择', 'Select') }}</option><option v-for="item in providerStore.enabledProviders" :key="item.provider_id" :value="item.provider_id">{{ item.name }}</option></select></label>
<label>{{ t('知识提取模型', 'Knowledge extraction model') }}<input v-model="model" class="input" list="media-models" :placeholder="t('填写模型 ID', 'Enter model ID')" /><datalist id="media-models"><option v-for="item in models" :key="item.model_id" :value="item.model_id">{{ item.name }}</option></datalist></label>
</div>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('安全更新上次导出的转录稿', 'Safely update the last exported transcript') }}</label><button class="button-primary" :disabled="busy || dirty || !title.trim() || !knowledgeTitle.trim() || !providerId || !model.trim()" @click="createArtifacts">{{ busy ? t('生成中', 'Creating') : t('生成转录稿与知识点笔记', 'Create transcript and knowledge notes') }}</button></div>
</section>
</template>
</article>
<div v-else class="panel subtle">{{ t('选择任务查看转写结果。', 'Select a job to view its transcript.') }}</div>
@@ -191,5 +223,5 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<style scoped>
.media-page > :is(.feature-header, .panel, .media-columns, .error-banner) { width: 100%; max-width: 1180px; margin-inline: auto; }
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}}
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}.artifact-panel{display:grid;gap:14px;padding:18px;border:1px solid var(--color-border-default);border-radius:var(--radius-lg);background:var(--color-surface-secondary)}.artifact-panel h3,.artifact-panel p{margin:0}.artifact-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.artifact-grid label{align-items:stretch;flex-direction:column;color:var(--color-text-secondary)}.artifact-grid :is(.input,.select){background:var(--color-surface-primary);color:var(--color-text-primary)}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}@media(max-width:620px){.artifact-grid{grid-template-columns:1fr}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}}
</style>
@@ -4,6 +4,7 @@ import { hostInvoke } from '@/services/platform/desktop'
import { t } from '@/i18n'
const locked = ref(true)
const automatic = ref(false)
const busy = ref(false)
const password = ref('')
const confirmation = ref('')
@@ -15,8 +16,9 @@ function failureMessage(error: unknown, fallback: string) {
: code
}
async function refresh() {
const state = await hostInvoke<{ locked: boolean }>('credentials_status')
const state = await hostInvoke<{ locked: boolean; automatic?: boolean }>('credentials_status')
locked.value = state.locked
automatic.value = state.automatic === true
}
async function importLegacy() {
busy.value = true; message.value = ''
@@ -81,8 +83,8 @@ onUnmounted(() => clearInterval(statusTimer))
<template>
<section class="panel settings-section credential-vault" aria-labelledby="credential-vault-title">
<h2 id="credential-vault-title">{{ t('设备凭据保险库', 'Device credential vault') }}</h2>
<p>{{ locked ? t('已锁定:使用模型密钥前请解锁。首次解锁将创建本机保险库。', 'Locked: unlock before using provider credentials. The first unlock creates this devices vault.') : t('已解锁:密钥仅由本机受控调用使用。', 'Unlocked: credentials are available to authorized local calls.') }}</p>
<p class="subtle">{{ t('口令至少12个字符。遗失口令后需恢复备份或重新配置密钥;笔记仍可使用。', 'Use at least 12 characters. A lost password requires a backup or re-entering credentials; notes remain available.') }}</p>
<p>{{ locked ? t('已锁定:使用模型密钥前请解锁。', 'Locked: unlock before using provider credentials.') : automatic ? t('已自动解锁:凭据由当前 Windows 用户的系统加密保护。', 'Automatically unlocked: credentials are protected for the current Windows user.') : t('已解锁:密钥仅由本机受控调用使用。', 'Unlocked: credentials are available to authorized local calls.') }}</p>
<p class="subtle">{{ automatic ? t('应用重启后会自动解锁;Windows 锁屏仍会立即撤销当前会话。', 'The vault unlocks automatically after an app restart; locking Windows still revokes the current session immediately.') : t('口令至少12个字符。成功解锁后将为当前 Windows 用户启用自动解锁。', 'Use at least 12 characters. A successful unlock enables automatic unlock for the current Windows user.') }}</p>
<form @submit.prevent="act(locked ? 'unlock' : 'change_password')">
<label>{{ locked ? t('解锁口令', 'Vault password') : t('新口令', 'New password') }}
<input v-model="password" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" />
+1 -1
View File
@@ -110,7 +110,7 @@ async function openFolderPicker() {
</div>
<div class="footer-info">
<span>v0.4.0-alpha.1</span>
<span>v0.5.0</span>
<button class="theme-toggle" @click="themeStore.toggleTheme()">
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
{{ themeStore.isDark ? t('浅色', 'Light') : t('深色', 'Dark') }}
+1 -1
View File
@@ -39,7 +39,7 @@ export interface CreateAgentRunRequest {
max_steps?: number
tool_timeout_seconds?: number
run_timeout_seconds?: number
token_budget?: number
token_budget?: number | null
allow_network?: boolean
max_concurrent_tools?: number
}
+2
View File
@@ -21,6 +21,8 @@ export const mediaService = {
}),
revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`),
note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }),
artifacts: (id: string, body: {title: string; knowledge_title?: string; provider_id: string; model: string; update_existing?: boolean}) =>
apiClient.post<{transcript: {note_id: string; title: string}; knowledge_note: {note_id: string; title: string}}>(`/api/media/transcriptions/${encodeURIComponent(id)}/artifacts`, body),
audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`),
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
+1 -1
View File
@@ -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.4.0-alpha.1')
expect(THEME_APP_VERSION).toBe('0.5.0')
})
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`)
+7
View File
@@ -61,6 +61,13 @@ def main():
"--name", "opennexus-core", "--distpath", str(output / "dist"),
"--workpath", str(output / "work"), "--specpath", str(output),
"--collect-submodules", "app", "--collect-all", "sqlite_vec",
# 本地模型使用独立 Python 进程执行;冻结后的 Core 必须保留可直接运行的 Worker 源文件。
"--add-data", str(ROOT / "backend" / "app" / "local_models" / "worker.py") + ":app/local_models",
"--add-data", str(ROOT / "backend" / "app" / "local_models" / "protocol.py") + ":app/local_models",
# 设置页可以在可写的数据目录中安装模型运行环境,安装器及其锁文件需随 Core 发布。
"--add-data", str(ROOT / "backend" / "scripts" / "install-model-runtime.ps1") + ":scripts",
"--add-data", str(ROOT / "backend" / "scripts" / "model-requirements.lock") + ":scripts",
"--add-data", str(ROOT / "backend" / "scripts" / "model-requirements.txt") + ":scripts",
"--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "text-tools") + ":extensions/plugins/text-tools",
"--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "chat-policy") + ":extensions/plugins/chat-policy",
"--add-data", str(ROOT / "backend" / "extensions" / "skills" / "knowledge-assistant") + ":extensions/skills/knowledge-assistant",
+1 -1
View File
@@ -7,7 +7,7 @@ COPY console ./
RUN pnpm build
FROM python:3.12-slim
ARG OPENNEXUS_SYNC_VERSION=0.4.0-alpha.1
ARG OPENNEXUS_SYNC_VERSION=0.5.0
LABEL org.opencontainers.image.title="OpenNexus Server Sync" \
org.opencontainers.image.version="${OPENNEXUS_SYNC_VERSION}" \
org.opencontainers.image.source="https://gitea.kronecker.cc/Kronecker/NotesAgentic"
+3 -3
View File
@@ -1,6 +1,6 @@
# OpenNexus Server Sync
当前发布版本为 **0.4.0-alpha.1**。协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3 兼容对象存储。独立发布包包含服务源码、锁文件、Vue 3 + TypeScript 管理控制台静态文件、Dockerfile 与 Compose 模板,不包含任何 Vault、账户数据库、对象存储数据或部署密钥。
当前发布版本为 **0.5.0**。协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3 兼容对象存储。独立发布包包含服务源码、锁文件、Vue 3 + TypeScript 管理控制台静态文件、Dockerfile 与 Compose 模板,不包含任何 Vault、账户数据库、对象存储数据或部署密钥。
服务根路径 `/``/console/` 提供同源的 Vue 3 + TypeScript Sync Console,可查看服务健康与依赖就绪状态,并使用普通 Sync 账户管理自己的 Vault 和设备。页面只调用公开的 Sync v1 API;密码在请求发出前从输入框清除,访问和刷新令牌只保留在页面内存,刷新或关闭页面即丢弃。控制台源码位于 `console/`,生产静态文件由 Docker 多阶段构建生成。
@@ -22,7 +22,7 @@ uv run pytest
## 自托管准备
从发布页下载 `OpenNexus-Server-Sync-0.4.0-alpha.1.zip` 并核对 `SHA256.json` 后,将压缩包解压到独立目录。升级现有实例时先备份数据库、对象存储和 `.env`,再使用新版镜像替换 Sync 服务;不要用发行包覆盖持久化卷。
从发布页下载 `OpenNexus-Server-Sync-0.5.0.zip` 并核对 `SHA256.json` 后,将压缩包解压到独立目录。升级现有实例时先备份数据库、对象存储和 `.env`,再使用新版镜像替换 Sync 服务;不要用发行包覆盖持久化卷。
仓库提供以下 Docker 文件:
@@ -57,7 +57,7 @@ docker compose run --rm sync /service/.venv/bin/python -m sync_server bootstrap-
`initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket`.env` 中的 root 与同步凭据必须不同。
0.4.0-alpha.1 使用真实 PostgreSQL/MinIO 环境验证初始化、重复启动、固定凭据、健康检查和已有数据升级。测试专用 HTTP 地址、故障检查、完整验收记录与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复。测试阶段可以直接开放 HTTP 端口;生产上线仍需配置 TLS、访问控制、监控与异机备份。
0.5.0 使用真实 PostgreSQL/MinIO 环境验证初始化、重复启动、固定凭据、健康检查和已有数据升级。测试专用 HTTP 地址、故障检查、完整验收记录与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复。测试阶段可以直接开放 HTTP 端口;生产上线仍需配置 TLS、访问控制、监控与异机备份。
## 备份与空实例恢复
+4 -4
View File
@@ -23,8 +23,8 @@ services:
build:
context: .
args:
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.5.0}
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.5.0}
command: ["/service/.venv/bin/python", "-m", "sync_server", "initialize"]
environment:
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
@@ -43,8 +43,8 @@ services:
build:
context: .
args:
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.5.0}
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.5.0}
environment:
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
SYNC_S3_ENDPOINT: http://objects:9000
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "opennexus-sync-console",
"private": true,
"version": "0.4.0-alpha.1",
"version": "0.5.0",
"packageManager": "pnpm@10.28.0",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "notesagent-sync"
version = "0.4.0a1"
version = "0.5.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2.0,<2.1",
+1 -1
View File
@@ -225,7 +225,7 @@ wheels = [
[[package]]
name = "notesagent-sync"
version = "0.4.0a1"
version = "0.5.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },