release: prepare OpenNexus 0.5.2-alpha1

This commit is contained in:
OpenNexus Release Bot
2026-09-18 20:41:21 +08:00
parent 32bc2ddccb
commit 5c3cb8003c
51 changed files with 590 additions and 9327 deletions
+5 -11
View File
@@ -1,7 +1,5 @@
# NotesAgent Frontend
> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](../docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
NotesAgent Frontend 是基于 Vue 3、TypeScript、Vite、Pinia、Vue Router、Milkdown 和 CodeMirror 6 的 Web 联调前端。当前页面调用 FastAPI 真实接口,不使用业务 Mock 作为运行时回退;测试文件中的 mock 只用于隔离单元和组件测试。
## 初始化与运行
@@ -44,9 +42,9 @@ pnpm dev
编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。
写作模式支持按标题折叠章节及全部展开/折叠;“设置 → 编辑器 → 标题样式”可按 H1–H6 设置字号、粗细与标题字体。设置本地保存,不改写 Markdown;详见 [标题折叠与样式开发说明](../docs/development/标题折叠与样式开发说明.md)
写作模式支持按标题折叠章节及全部展开/折叠;“设置 → 编辑器 → 标题样式”可按 H1–H6 设置字号、粗细与标题字体。设置本地保存,不改写 Markdown。
工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界,尚未接入 Tauri 原生快捷键与元数据转换处理器;见 [警告框与桌面编辑命令开发说明](../docs/development/警告框与桌面编辑命令开发说明.md)
工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界。
语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
@@ -66,7 +64,7 @@ pnpm dev
设置页支持带 logo 的提供商预设、模型发现、聊天/Embedding/转写/声纹能力绑定,以及按 capability、model 和 stream 条件匹配的自定义请求 JSON。请求预览不联网;“发送测试推理请求”使用当前草稿和已保存凭据执行真实短请求。
本地模型页显示固定 revision、许可、下载状态和实际磁盘占用。CPU 是默认运行方式;Windows 可从页面安装独立 CUDA 12.8 组件,安装过程不修改显卡驱动。当前模型选型详见[多模态管线与模型运行](../docs/development/多模态管线与模型运行开发说明.md)。
本地模型页显示固定 revision、许可、下载状态和实际磁盘占用。CPU 是默认运行方式;Windows 可从页面安装独立 CUDA 12.8 组件,安装过程不修改显卡驱动。
## 测试与构建
@@ -84,7 +82,7 @@ pnpm build
- 新接口先更新 `src/contracts``src/services`,页面和 Store 不直接散落 `fetch` 协议细节。
- 异步页面需要处理加载、空数据、后端错误、重复提交和迟到响应。
- 功能行为或契约变化时,同一提交同步更新测试和相关文档。
- 页面需求见[前端页面需求说明](../docs/contracts/前端页面需求说明-开发版.md)后端行为以运行时 `/openapi.json` 为准。
- 后端行为以运行时 `/openapi.json` 为准。
## 保存状态与图表联调(2026-09-06)
@@ -92,10 +90,6 @@ pnpm build
Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补偿;行内中键启用滚轮控制,移动鼠标退出。标签段落样式与正文隔离,避免 foreignObject 内文字裁切。
开发和验证方法见 [后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)、[Mermaid 预览与缩放](../docs/development/Mermaid预览与缩放开发说明.md)。
## 构建体积检查
执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。
Markdown 语法预设、主题适配和外部文件刷新规则见 [开发说明](../docs/development/Markdown语法预设与外部文件刷新.md)。
执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notes-agent-frontend",
"private": true,
"version": "0.5.1-alpha",
"version": "0.5.2-alpha1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3242,7 +3242,7 @@ dependencies = [
[[package]]
name = "notesagent-desktop"
version = "0.5.1-alpha"
version = "0.5.2-alpha1"
dependencies = [
"argon2",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "notesagent-desktop"
version = "0.5.1-alpha"
version = "0.5.2-alpha1"
edition = "2021"
rust-version = "1.89"
+1
View File
@@ -50,6 +50,7 @@ fn main() {
"core_request",
"core_request_prepare",
"core_request_cancel",
"export_save",
"core_stream",
"core_stream_cancel",
"editor_capabilities",
@@ -18,6 +18,7 @@
"allow-core-request",
"allow-core-request-prepare",
"allow-core-request-cancel",
"allow-export-save",
"allow-core-stream",
"allow-core-stream-cancel",
"allow-workspace-choose",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-export-save"
description = "Enables the export_save command without any pre-configured scope."
commands.allow = ["export_save"]
[[permission]]
identifier = "deny-export-save"
description = "Denies the export_save command without any pre-configured scope."
commands.deny = ["export_save"]
+5
View File
@@ -340,6 +340,11 @@ impl CoreSupervisor {
"HOME",
"USERPROFILE",
"LOCALAPPDATA",
// PDF 浏览器渲染需要从系统安装目录定位 Edge/Chrome;这里只传
// 目录根,不放宽 PATH,也不携带任何凭据或用户配置。
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"ProgramW6432",
] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
+87
View File
@@ -461,6 +461,92 @@ async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result<Cor
.await
}
fn valid_export_job_id(value: &str) -> bool {
value.len() == 19
&& value.starts_with("export_")
&& value[7..].bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn export_file_name(value: &str) -> Result<(&str, &'static str), String> {
if value.len() > 120 || Path::new(value).file_name().and_then(|name| name.to_str()) != Some(value)
{
return Err("EXPORT_FILE_NAME_INVALID".into());
}
let extension = Path::new(value)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.ok_or("EXPORT_FILE_NAME_INVALID")?;
let label = match extension.as_str() {
"pdf" => "PDF",
"docx" => "Word document",
"html" => "HTML",
_ => return Err("EXPORT_FILE_NAME_INVALID".into()),
};
Ok((value, label))
}
/// 由原生保存对话框选择目标,再从已认证的本机 Core 直接写入产物。
/// 文件内容不绕回 WebView,避免大文件重复 Base64 编解码。
#[tauri::command]
async fn export_save(
job_id: String,
file_name: String,
host: State<'_, Host>,
) -> Result<Option<String>, String> {
if !valid_export_job_id(&job_id) {
return Err("EXPORT_JOB_ID_INVALID".into());
}
let (file_name, label) = export_file_name(&file_name)?;
let extension = Path::new(file_name)
.extension()
.and_then(|value| value.to_str())
.ok_or("EXPORT_FILE_NAME_INVALID")?;
let Some(path) = rfd::FileDialog::new()
.set_title("保存导出文件")
.set_file_name(file_name)
.add_filter(label, &[extension])
.save_file()
else {
return Ok(None);
};
let core = host.core.clone();
let core_path = format!("/api/exports/{job_id}/file");
let session = tauri::async_runtime::spawn_blocking(move || {
core.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("CORE_UNAVAILABLE")?
.request_session(&core_path)
})
.await
.map_err(|_| "CORE_UNAVAILABLE")??;
let response = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.map_err(|_| "CORE_CLIENT_ERROR")?
.get(&session.url)
.header(reqwest::header::AUTHORIZATION, session.authorization.as_str())
.header("X-Core-Generation", &session.generation)
.send()
.await
.map_err(|_| "CORE_UNAVAILABLE")?;
if !response.status().is_success() {
return Err("EXPORT_DOWNLOAD_FAILED".into());
}
let bytes = read_core_response(response).await?;
let saved = path.to_string_lossy().into_owned();
tauri::async_runtime::spawn_blocking(move || {
std::fs::write(path, bytes).map_err(|_| "EXPORT_SAVE_FAILED")
})
.await
.map_err(|_| "EXPORT_SAVE_FAILED")??;
Ok(Some(saved))
}
#[tauri::command]
fn core_stream_cancel(host: State<'_, Host>, request_id: String) -> Result<(), String> {
if let Some(task) = host
@@ -1131,6 +1217,7 @@ fn main() {
core_request,
core_request_prepare,
core_request_cancel,
export_save,
core_stream,
core_stream_cancel,
editor_capabilities,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenNexus",
"version": "0.5.1-alpha",
"version": "0.5.2-alpha1",
"identifier": "cc.kronecker.notesagent",
"build": {
"beforeDevCommand": "pnpm dev",
+1 -1
View File
@@ -110,7 +110,7 @@ async function openFolderPicker() {
</div>
<div class="footer-info">
<span>v0.5.1-alpha</span>
<span>v0.5.2-alpha1</span>
<button class="theme-toggle" @click="themeStore.toggleTheme()">
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
{{ themeStore.isDark ? t('浅色', 'Light') : t('深色', 'Dark') }}
@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import {webcrypto} from 'node:crypto'
import {describe,it,expect,vi,afterEach} from 'vitest'
const native=vi.hoisted(()=>({isDesktop:vi.fn(()=>false),hostInvoke:vi.fn()}))
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
vi.mock('./platform/desktop',()=>native)
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
vi.mock('./pdfSnapshotService',()=>({preparePdfSnapshot:vi.fn().mockResolvedValue('<html>theme snapshot</html>')}))
import {preparePdfSnapshot} from './pdfSnapshotService'
@@ -26,6 +28,14 @@ describe('export snapshot contract',()=>{
const reviewOptions={theme_id:'light',include_title:true,page_size:'A4'}
const queued={job_id:'review-job',status:'queued',warnings:[],file:null,error:null}
afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals();vi.clearAllMocks()})
it('uses the native save dialog for desktop export downloads',async()=>{
native.isDesktop.mockReturnValue(true)
native.hostInvoke.mockResolvedValue('D:/Exports/note.pdf')
await exportService.download({id:'export_0123456789ab',status:'completed',warnings:[],error:null,fileName:'note.pdf'})
expect(native.hostInvoke).toHaveBeenCalledWith('export_save',{jobId:'export_0123456789ab',fileName:'note.pdf'})
expect(apiClient.get).not.toHaveBeenCalled()
native.isDesktop.mockReturnValue(false)
})
it('cancels a created server job after an in-flight submission is aborted',async()=>{
let finish!:(value:unknown)=>void
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
+5
View File
@@ -1,4 +1,5 @@
import { apiClient } from './apiClient'
import { hostInvoke, isDesktop } from './platform/desktop'
import { Marked } from 'marked'
import { renderMermaid } from './mermaidService'
@@ -102,6 +103,10 @@ export const exportService = {
async list() { const response = await apiClient.get<{ items: JobWire[] }>('/api/exports'); return response.items.map(mapJob) },
cancel(id: string) { return apiClient.post(`/api/exports/${encodeURIComponent(id)}/cancel`) },
async download(job: ExportJob) {
if (isDesktop()) {
await hostInvoke<string | null>('export_save', { jobId: job.id, fileName: job.fileName ?? 'export' })
return
}
const response = await apiClient.get<Response>(`/api/exports/${encodeURIComponent(job.id)}/file`)
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a'); link.href = url; link.download = job.fileName ?? 'export'
+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.5.1-alpha')
expect(THEME_APP_VERSION).toBe('0.5.2-alpha1')
})
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`)
+1 -1
View File
@@ -47,7 +47,7 @@ backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url
`agent-task.html?kind=tasks&theme=light` 测试任务组件,`kind=trace` 测试 Trace;支持 light、dark、paper-moments。继续使用 `run-stress.py --scroll`,任务规模可设 `--sizes 100 1000`Trace 可设 `--sizes 200 2000 10000`。每个规模在新页面中生成独立数据,所有 fetch 被拦截,未知请求直接失败,不落到真实后端。
任务先记录实际分页加载数量,再注入全量夹具测渲染上限,结果包含 `fullListIsInjected`。Trace 测量时间线、树形搜索及切换;滚轮区间与过滤区间分别计时。`scrollContainers``maxScrollTop` 用来确认目标实际滚动。完整结果及限制见 [Agent 与任务压测报告](../../../docs/development/Agent与任务压测报告.md)。
任务先记录实际分页加载数量,再注入全量夹具测渲染上限,结果包含 `fullListIsInjected`。Trace 测量时间线、树形搜索及切换;滚轮区间与过滤区间分别计时。`scrollContainers``maxScrollTop` 用来确认目标实际滚动。
修复后任务会读取所有 API 页,渲染每页 100 条;Trace 每页 200 条,筛选仍覆盖完整数据。`renderedTasks``totalFilteredCount` 区分 DOM 数量与实际记录总数,不能把分页后的 DOM 数量误报为数据丢失。