release: prepare OpenNexus 0.5.2-alpha1
This commit is contained in:
+5
-11
@@ -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 6;Markdown 展示使用 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,7 +1,7 @@
|
||||
{
|
||||
"name": "notes-agent-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.1-alpha",
|
||||
"version": "0.5.2-alpha1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "notesagent-desktop"
|
||||
version = "0.5.1-alpha"
|
||||
version = "0.5.2-alpha1"
|
||||
edition = "2021"
|
||||
rust-version = "1.89"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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'})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 数量误报为数据丢失。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user