diff --git a/README.md b/README.md index c3d5785..ad89e6a 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/backend/app/config.py b/backend/app/config.py index 274dfa8..4e0db79 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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")), diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 50dc54a..faeee12 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -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 diff --git a/backend/app/local_models/components.py b/backend/app/local_models/components.py index 249c233..80a50ca 100644 --- a/backend/app/local_models/components.py +++ b/backend/app/local_models/components.py @@ -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 diff --git a/backend/app/media_routes.py b/backend/app/media_routes.py index df05665..46d9505 100644 --- a/backend/app/media_routes.py +++ b/backend/app/media_routes.py @@ -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) diff --git a/backend/app/services/chat_agents.py b/backend/app/services/chat_agents.py index 6acbfcc..0802ad5 100644 --- a/backend/app/services/chat_agents.py +++ b/backend/app/services/chat_agents.py @@ -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': diff --git a/backend/app/services/media_notes.py b/backend/app/services/media_notes.py index c008cf8..8de157e 100644 --- a/backend/app/services/media_notes.py +++ b/backend/app/services/media_notes.py @@ -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"", + 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=["转写"]) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e78042d..6f3db36 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/tests/test_media_jobs.py b/backend/tests/test_media_jobs.py index 2708c6b..e37c818 100644 --- a/backend/tests/test_media_jobs.py +++ b/backend/tests/test_media_jobs.py @@ -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() diff --git a/backend/tests/test_runtime_components.py b/backend/tests/test_runtime_components.py index 50408cb..2c75039 100644 --- a/backend/tests/test_runtime_components.py +++ b/backend/tests/test_runtime_components.py @@ -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) diff --git a/backend/uv.lock b/backend/uv.lock index 7c50f9f..3af54fd 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1108,7 +1108,7 @@ wheels = [ [[package]] name = "notes-agent-backend" -version = "0.1.0" +version = "0.5.0" source = { virtual = "." } dependencies = [ { name = "cryptography" }, diff --git a/frontend/package.json b/frontend/package.json index 52ac5da..a418cc0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index a31807c..fba2f97 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -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", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 4d1c9d3..4b8036d 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -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 = [] } diff --git a/frontend/src-tauri/examples/migrate_auto_unlock.rs b/frontend/src-tauri/examples/migrate_auto_unlock.rs new file mode 100644 index 0000000..e62d96c --- /dev/null +++ b/frontend/src-tauri/examples/migrate_auto_unlock.rs @@ -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) + } + } +} diff --git a/frontend/src-tauri/src/credential_autounlock.rs b/frontend/src-tauri/src/credential_autounlock.rs new file mode 100644 index 0000000..1964d8a --- /dev/null +++ b/frontend/src-tauri/src/credential_autounlock.rs @@ -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 = std::result::Result; +const MAGIC: &[u8] = b"ONXDPAPI1"; +const ENTROPY: &[u8] = b"OpenNexus credential auto-unlock v1"; + +fn transform(data: &[u8], protect: bool) -> Result>> { + 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>>> { + 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); + } +} diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index 907181a..4e5d7d7 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -354,6 +354,52 @@ pub struct CredentialBroker { } impl CredentialBroker { + #[cfg(windows)] + fn auto_unlock_path(&self) -> Result { + 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 { + 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> { 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, String, BTreeMap) { let fixture: serde_json::Value = serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap(); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 246d74a..eebac92 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ pub mod core; pub mod core_update; +#[cfg(windows)] +mod credential_autounlock; pub mod credentials; mod payloads; mod preference_records; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index cc10078..af56e76 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -561,7 +561,10 @@ fn credentials_status(host: State<'_, Host>) -> Result, 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::().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"))? diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 1ad22c0..aa18e3a 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenNexus", - "version": "0.4.0-alpha.1", + "version": "0.5.0", "identifier": "cc.kronecker.notesagent", "build": { "beforeDevCommand": "pnpm dev", diff --git a/frontend/src/features/agent/AgentView.vue b/frontend/src/features/agent/AgentView.vue index 6561187..969ed11 100644 --- a/frontend/src/features/agent/AgentView.vue +++ b/frontend/src/features/agent/AgentView.vue @@ -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) {
-
+
{{ t('默认不限制;仍可随时取消运行。', 'Unlimited by default; the run can still be cancelled at any time.') }}
@@ -150,6 +150,16 @@ async function handleOpenCitation(data: Record) { diff --git a/frontend/src/features/settings/CredentialVaultSettings.vue b/frontend/src/features/settings/CredentialVaultSettings.vue index 3c63018..4b96f14 100644 --- a/frontend/src/features/settings/CredentialVaultSettings.vue +++ b/frontend/src/features/settings/CredentialVaultSettings.vue @@ -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))