feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力

This commit is contained in:
2026-09-08 12:23:20 +08:00
parent f4aeeef49b
commit 4c79e940d2
59 changed files with 4242 additions and 102 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ const pageTitle = computed(() => {
logs: t('运行日志', 'Operation Logs'),
media: t('音视频转写', 'Media Transcription'),
}
return titles[name] || 'NotesAgent'
return titles[name] || 'OpenNexus'
})
const currentFileName = computed(() => {
@@ -63,7 +63,7 @@ function toggleFromTitlebar(event: MouseEvent) {
</span>
</div>
<div class="titlebar-center" data-tauri-drag-region>
<span class="app-name" data-tauri-drag-region>NotesAgent</span>
<span class="app-name" data-tauri-drag-region>OpenNexus</span>
</div>
<div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')">
+2 -2
View File
@@ -258,11 +258,11 @@ onMounted(load)
<label>{{ t('服务器名称', 'Server name') }}<input v-model="form.name" maxlength="80" :placeholder="t('例如:文件系统工具', 'For example: Filesystem tools')"></label>
<div class="template-row"><span>{{ t('服务器配置', 'Server configuration') }}</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio {{ t('模板', 'template') }}</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE {{ t('兼容', '(legacy)') }}</button></div>
<template v-if="form.transport === 'stdio'"><label>{{ t('可执行命令', 'Executable command') }}<input v-model="form.command" :placeholder="t('uvx、npx 或可信可执行文件路径', 'uvx, npx, or a trusted executable path')"></label><label>{{ t('参数(每行一项)', 'Arguments (one per line)') }}<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>{{ t('普通环境变量(JSON', 'Environment variables (JSON)') }}<textarea v-model="environmentText" rows="5"></textarea></label><label>{{ t('敏感环境变量名(每行一项)', 'Secret environment names (one per line)') }}<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 HeaderJSON', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 HeaderJSON', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"OpenNexus"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>{{ t('声明权限(逗号分隔,可选)', 'Declared permissions (comma-separated, optional)') }}<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>{{ t('启动超时(秒)', 'Startup timeout (seconds)') }}<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
</template>
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。', 'Supports NotesAgent, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 OpenNexus 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。', 'Supports OpenNexus, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset>
</form>
+23 -4
View File
@@ -2,17 +2,36 @@
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { apiClient } from '@/services/apiClient'
import { isDesktop } from '@/services/platform/desktop'
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'
const route = useRoute()
const maxUploadMiB = isDesktop() ? 64 : 128
const submission = createMediaSubmission()
const updateExisting = ref(false)
const jobs = ref<MediaJob[]>([])
const selected = ref<MediaJob | null>(null)
const audioSource = ref('')
watch(() => selected.value?.attachment_id, async (id, _old, onCleanup) => {
let stale = false
let objectUrl: string | undefined
onCleanup(() => { stale = true; if (objectUrl) URL.revokeObjectURL(objectUrl) })
audioSource.value = ''
if (!id) return
if (!isDesktop()) { audioSource.value = mediaService.audio(id); return }
try {
const response = await apiClient.get<Response>(`/api/media/attachments/${encodeURIComponent(id)}`)
const blob = await response.blob()
if (stale) return
objectUrl = URL.createObjectURL(blob)
audioSource.value = objectUrl
} catch (e) { if (!stale) error.value = (e as Error).message }
})
const file = ref<File | null>(null)
const reference = ref<File | null>(null)
const matchResult = ref('')
@@ -62,7 +81,7 @@ async function action(work: () => Promise<void>) {
async function submit() {
if (!file.value) return
await action(async () => {
if (file.value!.size > 128 * 1024 * 1024) throw new Error(t('文件不能超过 128 MiB。', 'Files cannot exceed 128 MiB.'))
if (file.value!.size > maxUploadMiB * 1024 * 1024) throw new Error(t(`文件不能超过 ${maxUploadMiB} MiB。`, `Files cannot exceed ${maxUploadMiB} MiB.`))
if (file.value!.size > 25 * 1024 * 1024 && !localOnly.value) throw new Error(t('超过 25 MiB 的录音请先启用仅本地处理。', 'Enable local-only processing for audio above 25 MiB.'))
let terms = {}
if (terminology.value.trim()) {
@@ -119,7 +138,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<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('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
<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>
<form class="panel upload" @submit.prevent="submit">
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
@@ -141,7 +160,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<article v-if="selected" class="panel transcript">
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">{{ t('修订', 'Revision') }} {{ selected.revision }}</span></header>
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" :aria-label="t('转写进度', 'Transcription progress')" />
<audio ref="player" controls :src="mediaService.audio(selected.attachment_id)" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<audio ref="player" controls :src="audioSource || undefined" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<label>{{ t('播放速度', 'Playback speed') }}<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label>
<p v-if="selected.error_message" class="error-banner">{{ selected.error_message }} · {{ selected.error_code }}</p>
<p v-if="selected.fallback_reason" class="subtle">{{ t('已回退', 'Fallback: ') }}{{ selected.fallback_reason }}</p>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { hostInvoke } from '@/services/platform/desktop'
import { t } from '@/i18n'
const locked = ref(true)
const busy = ref(false)
const password = ref('')
const confirmation = ref('')
const message = ref('')
async function refresh() {
const state = await hostInvoke<{ locked: boolean }>('credentials_status')
locked.value = state.locked
}
async function importLegacy() {
busy.value = true; message.value = ''
try {
const count = await hostInvoke<number | null>('credentials_import')
if (count !== null) message.value = t(`已迁移并验证 ${count} 条凭据;旧文件仍保留。`, `Imported and verified ${count} credentials. Legacy files are retained.`)
} catch (error) { message.value = error instanceof Error ? error.message : 'MIGRATION_FAILED' }
finally { busy.value = false }
}
async function act(action: 'unlock' | 'lock' | 'change_password') {
if (busy.value) return
message.value = ''
if (action === 'change_password' && password.value !== confirmation.value) {
message.value = t('两次口令不一致。', 'The passwords do not match.'); return
}
busy.value = true
const value = password.value
password.value = ''; confirmation.value = ''
try {
await hostInvoke(`credentials_${action}`, action === 'lock' ? undefined : { password: value })
await refresh()
message.value = action === 'change_password' ? t('口令已更新。', 'Password updated.') : ''
} catch (error) { message.value = error instanceof Error ? error.message : 'CREDENTIAL_STORE_FAILED' }
finally { busy.value = false }
}
onMounted(() => refresh().catch(error => { message.value = String(error) }))
</script>
<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>
<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" />
</label>
<label v-if="!locked">{{ t('确认新口令', 'Confirm new password') }}
<input v-model="confirmation" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" />
</label>
<div class="inline-actions">
<button class="button-primary" type="submit" :disabled="busy">{{ busy ? t('处理中…', 'Working…') : locked ? t('解锁', 'Unlock') : t('更改口令', 'Change password') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="act('lock')">{{ t('立即锁定', 'Lock now') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="importLegacy">{{ t('迁移旧凭据', 'Import legacy credentials') }}</button>
</div>
</form>
<p v-if="message" role="status">{{ message }}</p>
</section>
</template>
<style scoped>
.credential-vault form { display: grid; gap: 12px; max-width: 480px; }
.credential-vault label { display: grid; gap: 6px; }
.credential-vault input { color: var(--text-primary); background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; padding: 8px; }
</style>
@@ -12,6 +12,8 @@ import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
import LocalModelSettings from './LocalModelSettings.vue'
import UsageCard from './UsageCard.vue'
import CredentialVaultSettings from './CredentialVaultSettings.vue'
import { isDesktop } from '@/services/platform/desktop'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
@@ -85,6 +87,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<section v-if="activeSection === 'general'" class="panel settings-section"><div class="setting-row"><span><strong>{{ t('全局人设', 'Global persona') }}</strong><small>{{ t('统一设置所有 AI 对话和智能体的系统人设与对话示例', 'System persona and examples for all AI chats and agents') }}</small></span><button class="button-secondary" @click="showPersona = true">{{ t('编辑人设与头像', 'Edit persona and avatars') }}</button></div></section>
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" />
<CredentialVaultSettings v-if="activeSection === 'general' && isDesktop()" />
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>{{ t('通用', 'General') }}</h2><label class="setting-row"><span><strong>{{ t('恢复上次 Vault', 'Restore last Vault') }}</strong><small>{{ t('启动后自动打开最近使用的知识库', 'Open the most recently used knowledge base at startup') }}</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>{{ t('自动保存间隔', 'Autosave interval') }}</strong><small>{{ t('编辑停止后等待多久写入文件', 'How long to wait after editing before saving') }}</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 {{ t('秒', 'sec') }}</option><option :value="1500">1.5 {{ t('秒', 'sec') }}</option><option :value="3000">3 {{ t('秒', 'sec') }}</option></select></div><div class="setting-row"><span><strong>{{ t('界面语言', 'Interface language') }}</strong><small>{{ t('切换后立即应用到界面', 'Applied to the interface immediately') }}</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>{{ t('版本', 'Version') }}</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label><MarkdownPreferenceSettings /><HeadingStyleSettings /></div>
+1 -1
View File
@@ -63,7 +63,7 @@ async function openFolderPicker() {
<div class="entry-container">
<div class="brand-section">
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
<h1 class="app-title">NotesAgent</h1>
<h1 class="app-title">OpenNexus</h1>
<p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
</div>
+1 -1
View File
@@ -94,7 +94,7 @@ router.beforeEach((to) => {
})
export function updateDocumentTitle(to = router.currentRoute.value) {
const baseTitle = 'NotesAgent'
const baseTitle = 'OpenNexus'
const titles: Record<string, string> = {
logs: t('运行日志', 'Operation logs'),
media: t('音视频转写', 'Media Transcription'),
+15 -5
View File
@@ -2,7 +2,7 @@ import type { ApiError, ErrorResponse } from '@/contracts'
import { hostInvoke, isDesktop } from './platform/desktop'
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? (isDesktop() ? 'http://127.0.0.1:8000' : '')
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
interface DesktopCoreResponse {
status: number
@@ -82,12 +82,22 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try {
if (isDesktop()) {
const parsed = new URL(url)
const parsed = new URL(url, 'http://localhost')
let bodyBase64: string | undefined
if (rest.body instanceof Blob) {
if (rest.body.size > 64 * 1024 * 1024) throw new ApiErrorClass('CORE_REQUEST_TOO_LARGE', '上传文件超过 64 MiB')
const bytes = new Uint8Array(await rest.body.arrayBuffer())
const parts: string[] = []
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
bodyBase64 = btoa(parts.join(''))
}
const response = await hostInvoke<DesktopCoreResponse>('core_request', {
method: rest.method ?? 'GET',
path: `${parsed.pathname}${parsed.search}`,
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
authorization: token ? `Bearer ${token}` : undefined,
bodyBase64,
contentType: new Headers(reqHeaders).get('Content-Type'),
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
})
if (response.status >= 200 && response.status < 300) {
if (response.status === 204) return undefined as T
@@ -136,8 +146,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}
export const apiClient = {
postBinary<T>(path: string, body: Blob) {
return request<T>(path, { method: 'POST', body, headers: { 'Content-Type': 'application/zip' } })
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
return request<T>(path, { method: 'POST', body, headers })
},
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' })
+5
View File
@@ -1,5 +1,6 @@
import { apiClient, resolveApiUrl } from './apiClient'
import { t } from '@/i18n'
import { isDesktop } from './platform/desktop'
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
export interface MediaJob {
@@ -24,6 +25,10 @@ export const mediaService = {
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)}`),
async upload(file: File, idempotencyKey?: string) {
if (isDesktop()) return apiClient.postBinary<{ attachment_id: string }>(
`/api/media/attachments?filename=${encodeURIComponent(file.name)}`, file,
{'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})},
)
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
})
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
const { invoke, channels } = vi.hoisted(() => ({ invoke: vi.fn(), channels: [] as { onmessage: (message: unknown) => void }[] }))
vi.mock('./desktop', () => ({ hostInvoke: invoke }))
vi.mock('@tauri-apps/api/core', () => ({ Channel: class { onmessage = (_message: unknown) => {}; constructor() { channels.push(this) } } }))
import { coreStream } from './coreStream'
beforeEach(() => { channels.length = 0; invoke.mockReset(); invoke.mockResolvedValue(undefined) })
it('preserves UTF-8 byte fragments and cursor without exposing authorization', async () => {
const pending = coreStream('/api/events', { method: 'GET', headers: { 'Last-Event-ID': '42', Authorization: 'must-not-forward' } })
channels[0]!.onmessage({ kind: 'headers', status: 200 })
const response = await pending
channels[0]!.onmessage({ kind: 'chunk', data: '5A==' })
channels[0]!.onmessage({ kind: 'chunk', data: 'uK0=' })
channels[0]!.onmessage({ kind: 'done' })
expect(await response.text()).toBe('中')
expect(invoke.mock.calls[0]![1]).toMatchObject({ lastEventId: '42' })
expect(JSON.stringify(invoke.mock.calls)).not.toContain('must-not-forward')
})
it('cancels a native request even when abort arrives before start acknowledgement', async () => {
let acknowledge!: () => void
invoke.mockImplementationOnce(() => new Promise<void>(resolve => { acknowledge = resolve }))
const abort = new AbortController()
const pending = coreStream('/api/events', { signal: abort.signal })
abort.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
acknowledge()
await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith('core_stream_cancel', expect.anything()))
})
it('propagates native failure after headers to the response reader', async () => {
const pending = coreStream('/api/events', {})
channels[0]!.onmessage({ kind: 'headers', status: 200 })
const response = await pending
channels[0]!.onmessage({ kind: 'error', code: 'CORE_RESPONSE_ERROR' })
await expect(response.text()).rejects.toThrow('CORE_RESPONSE_ERROR')
})
@@ -0,0 +1,52 @@
import { Channel } from '@tauri-apps/api/core'
import { hostInvoke } from './desktop'
type Message = { kind: 'headers'; status: number } | { kind: 'chunk'; data: string }
| { kind: 'done' } | { kind: 'error'; code: string }
/** Native session credentials stay in Rust; this channel carries response bytes only. */
export function coreStream(path: string, init: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
let ended = false
let started = false
let controller: ReadableStreamDefaultController<Uint8Array>
const cancelHost = () => hostInvoke('core_stream_cancel', { requestId }).catch(() => {})
const cleanup = () => init.signal?.removeEventListener('abort', abort)
const fail = (error: Error) => {
if (ended) return
ended = true
cleanup()
controller.error(error)
reject(error)
if (started) void cancelHost()
}
const abort = () => fail(new DOMException('Request aborted', 'AbortError'))
const stream = new ReadableStream<Uint8Array>({
start(value) { controller = value },
cancel() { ended = true; cleanup(); if (started) void cancelHost() },
})
const channel = new Channel<Message>()
channel.onmessage = message => {
if (ended) return
if (message.kind === 'headers') resolve(new Response(stream, { status: message.status, headers: { 'Content-Type': 'text/event-stream' } }))
if (message.kind === 'chunk') {
const bytes = Uint8Array.from(atob(message.data), c => c.charCodeAt(0))
controller.enqueue(bytes)
// Bound queued data if a consumer stops reading without cancelling.
if ((controller.desiredSize ?? 0) < -4096) fail(new Error('CORE_STREAM_BACKPRESSURE'))
}
if (message.kind === 'error') fail(new Error(message.code))
if (message.kind === 'done') { ended = true; cleanup(); controller.close() }
}
init.signal?.addEventListener('abort', abort, { once: true })
if (init.signal?.aborted) { abort(); return }
let body: unknown
try { body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined }
catch { fail(new Error('CORE_BODY_INVALID')); return }
void hostInvoke('core_stream', {
requestId, path, method: init.method ?? 'GET', body,
lastEventId: new Headers(init.headers).get('Last-Event-ID') ?? undefined, channel,
}).then(() => { started = true; if (ended) void cancelHost() }).catch(fail)
})
}
+5 -2
View File
@@ -1,4 +1,6 @@
import { resolveApiUrl } from './apiClient'
import { isDesktop } from './platform/desktop'
import { coreStream } from './platform/coreStream'
export type SseEventHandler = (
event: string,
@@ -47,12 +49,13 @@ export class SseClient {
headers['Last-Event-ID'] = lastEventId
}
const resp = await fetch(resolveApiUrl(url), {
const init: RequestInit = {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: this.controller.signal,
})
}
const resp = isDesktop() ? await coreStream(url, init) : await fetch(resolveApiUrl(url), init)
if (!resp.ok || !resp.body) {
throw new Error(`SSE connection failed: ${resp.status}`)
+2 -2
View File
@@ -21,7 +21,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
author: 'OpenNexus 团队',
description: t.description,
is_dark: t.is_dark,
builtin: true,
@@ -30,7 +30,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
author: 'OpenNexus 团队',
description: t.description,
min_app_version: '0.1.0',
is_dark: t.is_dark,