mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
feat(extension-store): add tryGetJson optional JSON lookup
This commit is contained in:
parent
0a296627a5
commit
8e757bdc7c
12 changed files with 207 additions and 64 deletions
|
|
@ -16,6 +16,11 @@ const store = window.__TAURITAVERN__.api.extension.store;
|
|||
```js
|
||||
await store.setJson({ namespace: 'my-ext', key: 'settings', value: { enabled: true } });
|
||||
const settings = await store.getJson({ namespace: 'my-ext', key: 'settings' });
|
||||
|
||||
const maybeSettings = await store.tryGetJson({ namespace: 'my-ext', key: 'settings' });
|
||||
if (!maybeSettings.found) {
|
||||
// Use in-code defaults; write only when the user changes settings.
|
||||
}
|
||||
```
|
||||
|
||||
### JSON 多表(额外 json / table)
|
||||
|
|
@ -63,6 +68,7 @@ const blob = await store.getBlob({ namespace: 'my-ext', key: 'icon.png' });
|
|||
| 方法 | 返回值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `getJson({ namespace, key, table? })` | `Promise<any>` | 读取 JSON 值 |
|
||||
| `tryGetJson({ namespace, key, table? })` | `Promise<{ found: boolean; value?: any }>` | 可选读取;key 不存在时返回 `{ found: false }`,其他错误仍抛出 |
|
||||
| `setJson({ namespace, key, value, table? })` | `Promise<void>` | 写入 JSON 值(覆盖) |
|
||||
| `updateJson({ namespace, key, value, table? })` | `Promise<void>` | 合并更新:对象会深度合并;非对象直接替换 |
|
||||
| `renameKey({ namespace, key, newKey, table? })` | `Promise<void>` | 重命名 key |
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ impl ExtensionStoreService {
|
|||
Ok(self.repository.get_json(namespace, table, key).await?)
|
||||
}
|
||||
|
||||
pub async fn try_get_json(
|
||||
&self,
|
||||
namespace: &str,
|
||||
table: Option<&str>,
|
||||
key: &str,
|
||||
) -> Result<Option<Value>, ApplicationError> {
|
||||
let table = self.resolve_table(table);
|
||||
Ok(self.repository.try_get_json(namespace, table, key).await?)
|
||||
}
|
||||
|
||||
pub async fn set_json(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ pub trait ExtensionStoreRepository: Send + Sync {
|
|||
async fn get_json(&self, namespace: &str, table: &str, key: &str)
|
||||
-> Result<Value, DomainError>;
|
||||
|
||||
async fn try_get_json(
|
||||
&self,
|
||||
namespace: &str,
|
||||
table: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<Value>, DomainError>;
|
||||
|
||||
async fn set_json(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,30 @@ async fn list_directories(dir: &Path) -> Result<Vec<String>, DomainError> {
|
|||
Ok(dirs)
|
||||
}
|
||||
|
||||
async fn read_json_entry(path: &Path) -> Result<Option<Value>, DomainError> {
|
||||
let bytes = match fs::read(path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(DomainError::InternalError(format!(
|
||||
"Failed to read extension store JSON entry {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
|
||||
DomainError::InvalidData(format!(
|
||||
"Extension store entry contains invalid JSON {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExtensionStoreRepository for FileExtensionStoreRepository {
|
||||
async fn get_json(
|
||||
|
|
@ -229,29 +253,24 @@ impl ExtensionStoreRepository for FileExtensionStoreRepository {
|
|||
key: &str,
|
||||
) -> Result<Value, DomainError> {
|
||||
let path = self.json_entry_path(namespace, table, key)?;
|
||||
let bytes = fs::read(&path).await.map_err(|error| {
|
||||
if error.kind() == io::ErrorKind::NotFound {
|
||||
return DomainError::NotFound(format!(
|
||||
"Extension store JSON entry not found: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read extension store JSON entry {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
|
||||
serde_json::from_slice::<Value>(&bytes).map_err(|error| {
|
||||
DomainError::InvalidData(format!(
|
||||
"Extension store entry contains invalid JSON {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
read_json_entry(&path).await?.ok_or_else(|| {
|
||||
DomainError::NotFound(format!(
|
||||
"Extension store JSON entry not found: {}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn try_get_json(
|
||||
&self,
|
||||
namespace: &str,
|
||||
table: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<Value>, DomainError> {
|
||||
let path = self.json_entry_path(namespace, table, key)?;
|
||||
read_json_entry(&path).await
|
||||
}
|
||||
|
||||
async fn set_json(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
|
@ -308,23 +327,7 @@ impl ExtensionStoreRepository for FileExtensionStoreRepository {
|
|||
})?;
|
||||
}
|
||||
|
||||
let mut current = match fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice::<Value>(&bytes).map_err(|error| {
|
||||
DomainError::InvalidData(format!(
|
||||
"Extension store entry contains invalid JSON {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Value::Null,
|
||||
Err(error) => {
|
||||
return Err(DomainError::InternalError(format!(
|
||||
"Failed to read extension store JSON entry {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut current = read_json_entry(&path).await?.unwrap_or(Value::Null);
|
||||
|
||||
merge_json_value(&mut current, value);
|
||||
|
||||
|
|
@ -606,6 +609,41 @@ mod tests {
|
|||
assert_eq!(keys, vec![String::from("index")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_get_json_distinguishes_missing_from_null_and_keeps_invalid_json_fatal() {
|
||||
let dir = create_temp_dir();
|
||||
let repo = FileExtensionStoreRepository::new(dir.clone());
|
||||
|
||||
let missing = repo
|
||||
.try_get_json("my-ext", "main", "settings")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing, None);
|
||||
|
||||
repo.set_json("my-ext", "main", "settings", json!(null))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let existing_null = repo
|
||||
.try_get_json("my-ext", "main", "settings")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(existing_null, Some(json!(null)));
|
||||
|
||||
let invalid_path = dir
|
||||
.join("my-ext")
|
||||
.join("kv")
|
||||
.join("main")
|
||||
.join("invalid.json");
|
||||
std::fs::write(&invalid_path, "{").expect("write invalid json");
|
||||
|
||||
let error = repo
|
||||
.try_get_json("my-ext", "main", "invalid")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("invalid JSON"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_and_delete_json_key() {
|
||||
let dir = create_temp_dir();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ pub struct ExtensionStoreBlobPayload {
|
|||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtensionStoreJsonLookupPayload {
|
||||
pub found: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_extension_store_json(
|
||||
namespace: String,
|
||||
|
|
@ -40,6 +47,35 @@ pub async fn get_extension_store_json(
|
|||
)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn try_get_extension_store_json(
|
||||
namespace: String,
|
||||
key: String,
|
||||
table: Option<String>,
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<ExtensionStoreJsonLookupPayload, CommandError> {
|
||||
log_command(format!(
|
||||
"try_get_extension_store_json {}:{}/{}",
|
||||
namespace,
|
||||
table.as_deref().unwrap_or("main"),
|
||||
key
|
||||
));
|
||||
|
||||
let value = app_state
|
||||
.extension_store_service
|
||||
.try_get_json(&namespace, table.as_deref(), &key)
|
||||
.await
|
||||
.map_err(map_command_error(format!(
|
||||
"Failed to try-get extension store json {}:{}",
|
||||
namespace, key
|
||||
)))?;
|
||||
|
||||
Ok(ExtensionStoreJsonLookupPayload {
|
||||
found: value.is_some(),
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_extension_store_json(
|
||||
namespace: String,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
|
|||
super::group_chat_api_commands::search_group_chat_messages,
|
||||
// Extension store commands (global extension persistence)
|
||||
super::extension_store_commands::get_extension_store_json,
|
||||
super::extension_store_commands::try_get_extension_store_json,
|
||||
super::extension_store_commands::set_extension_store_json,
|
||||
super::extension_store_commands::update_extension_store_json,
|
||||
super::extension_store_commands::rename_extension_store_key,
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ function requireExtensionStore() {
|
|||
return store;
|
||||
}
|
||||
|
||||
function isMissingStoreValue(error) {
|
||||
const message = String(error?.message || error || '').toLowerCase();
|
||||
return message.includes('not found')
|
||||
|| message.includes('no such file')
|
||||
|| message.includes('enoent')
|
||||
|| message.includes('os error 2');
|
||||
}
|
||||
|
||||
function mergeSettings(value) {
|
||||
return {
|
||||
...DEFAULT_AGENT_SYSTEM_SETTINGS,
|
||||
|
|
@ -40,22 +32,24 @@ function emitSettingsChanged(settings) {
|
|||
|
||||
export async function loadAgentSystemSettings() {
|
||||
const store = requireExtensionStore();
|
||||
try {
|
||||
return mergeSettings(await store.getJson({
|
||||
namespace: AGENT_SYSTEM_MODULE_NAME,
|
||||
key: AGENT_SYSTEM_SETTINGS_KEY,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isMissingStoreValue(error)) {
|
||||
throw error;
|
||||
}
|
||||
await store.setJson({
|
||||
namespace: AGENT_SYSTEM_MODULE_NAME,
|
||||
key: AGENT_SYSTEM_SETTINGS_KEY,
|
||||
value: DEFAULT_AGENT_SYSTEM_SETTINGS,
|
||||
});
|
||||
if (typeof store.tryGetJson !== 'function') {
|
||||
throw new Error('TauriTavern extension store tryGetJson API is unavailable');
|
||||
}
|
||||
|
||||
const result = await store.tryGetJson({
|
||||
namespace: AGENT_SYSTEM_MODULE_NAME,
|
||||
key: AGENT_SYSTEM_SETTINGS_KEY,
|
||||
});
|
||||
|
||||
if (typeof result?.found !== 'boolean') {
|
||||
throw new Error('TauriTavern extension store tryGetJson returned an invalid response');
|
||||
}
|
||||
|
||||
if (!result.found) {
|
||||
return { ...DEFAULT_AGENT_SYSTEM_SETTINGS };
|
||||
}
|
||||
|
||||
return mergeSettings(result.value);
|
||||
}
|
||||
|
||||
export async function saveAgentSystemSettings(settings) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ function createExtensionStoreApi({ safeInvoke }) {
|
|||
return safeInvoke('get_extension_store_json', { namespace, key, table });
|
||||
}
|
||||
|
||||
async function tryGetJson(options) {
|
||||
const namespace = requireNonEmptyString(options?.namespace, 'namespace');
|
||||
const key = requireNonEmptyString(options?.key, 'key');
|
||||
const table = normalizeOptionalName(options?.table);
|
||||
return safeInvoke('try_get_extension_store_json', { namespace, key, table });
|
||||
}
|
||||
|
||||
async function setJson(options) {
|
||||
const namespace = requireNonEmptyString(options?.namespace, 'namespace');
|
||||
const key = requireNonEmptyString(options?.key, 'key');
|
||||
|
|
@ -138,6 +145,7 @@ function createExtensionStoreApi({ safeInvoke }) {
|
|||
|
||||
return {
|
||||
getJson,
|
||||
tryGetJson,
|
||||
setJson,
|
||||
updateJson,
|
||||
updateJSON: updateJson,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export {};
|
|||
* | 'delete_character_chat_store_json'
|
||||
* | 'delete_group_chat_store_json'
|
||||
* | 'get_extension_store_json'
|
||||
* | 'try_get_extension_store_json'
|
||||
* | 'set_extension_store_json'
|
||||
* | 'update_extension_store_json'
|
||||
* | 'rename_extension_store_key'
|
||||
|
|
|
|||
1
src/types.d.ts
vendored
1
src/types.d.ts
vendored
|
|
@ -425,6 +425,7 @@ type TauriTavernWorldInfoApi = {
|
|||
|
||||
type TauriTavernExtensionStoreApi = {
|
||||
getJson: (options: { namespace: string; key: string; table?: string }) => Promise<any>;
|
||||
tryGetJson: (options: { namespace: string; key: string; table?: string }) => Promise<{ found: boolean; value?: any }>;
|
||||
setJson: (options: { namespace: string; key: string; value: any; table?: string }) => Promise<void>;
|
||||
updateJson: (options: { namespace: string; key: string; value: any; table?: string }) => Promise<void>;
|
||||
updateJSON: (options: { namespace: string; key: string; value: any; table?: string }) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ test('Agent System settings use the extension store and publish changes', async
|
|||
installWindow({
|
||||
extension: {
|
||||
store: {
|
||||
async getJson() {
|
||||
async tryGetJson() {
|
||||
if (stored === null) {
|
||||
throw new Error('not found');
|
||||
return { found: false };
|
||||
}
|
||||
return stored;
|
||||
return { found: true, value: stored };
|
||||
},
|
||||
async setJson(request) {
|
||||
writes.push(request);
|
||||
|
|
@ -60,7 +60,7 @@ test('Agent System settings use the extension store and publish changes', async
|
|||
selectedProfileId: 'default-writer',
|
||||
activeTab: 'profiles',
|
||||
});
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes.length, 0);
|
||||
|
||||
let emitted = null;
|
||||
const unsubscribe = settings.subscribeAgentSystemSettings((next) => {
|
||||
|
|
@ -89,8 +89,8 @@ test('Agent generation router uses the global toggle for normal regenerate and s
|
|||
installWindow({
|
||||
extension: {
|
||||
store: {
|
||||
async getJson() {
|
||||
return stored;
|
||||
async tryGetJson() {
|
||||
return { found: true, value: stored };
|
||||
},
|
||||
async setJson(request) {
|
||||
stored = request.value;
|
||||
|
|
|
|||
41
tests/extension-store-api-contract.test.mjs
Normal file
41
tests/extension-store-api-contract.test.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function importFresh(relativePath) {
|
||||
const modulePath = path.join(REPO_ROOT, relativePath);
|
||||
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||
return import(url);
|
||||
}
|
||||
|
||||
test('extension store tryGetJson uses optional lookup command', async () => {
|
||||
const calls = [];
|
||||
globalThis.window = { __TAURITAVERN__: { api: {} } };
|
||||
|
||||
const { installExtensionStoreApi } = await importFresh('src/tauri/main/api/extension-store.js');
|
||||
installExtensionStoreApi({
|
||||
async safeInvoke(command, args) {
|
||||
calls.push({ command, args });
|
||||
return { found: false };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await window.__TAURITAVERN__.api.extension.store.tryGetJson({
|
||||
namespace: 'my-ext',
|
||||
table: 'state',
|
||||
key: 'settings',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { found: false });
|
||||
assert.deepEqual(calls, [{
|
||||
command: 'try_get_extension_store_json',
|
||||
args: {
|
||||
namespace: 'my-ext',
|
||||
table: 'state',
|
||||
key: 'settings',
|
||||
},
|
||||
}]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue