release: OpenNexus 0.5.0
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +2,8 @@
|
||||
|
||||
pub mod core;
|
||||
pub mod core_update;
|
||||
#[cfg(windows)]
|
||||
mod credential_autounlock;
|
||||
pub mod credentials;
|
||||
mod payloads;
|
||||
mod preference_records;
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 device’s 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" />
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}`),
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user