mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 07:35:14 +00:00
feat (iOS policy): Implement iOS policy tiering strategy
This commit is contained in:
parent
433e82375f
commit
99b1939976
48 changed files with 2474 additions and 504 deletions
|
|
@ -59,3 +59,7 @@
|
|||
11. `docs/CurrentState/NativeApiFormats.md`
|
||||
- Custom 原生 API 格式兼容现状(OpenAI Responses / Claude Messages / Gemini Interactions)
|
||||
- 包含端到端链路、支持/不支持边界与持续开发约束(尤其回滚 ST 与 thought-signatures)
|
||||
|
||||
12. `docs/CurrentState/iOSPolicy.md`
|
||||
- iOS/iPadOS-only 分发 Policy(profile + capabilities snapshot)当前实现快照
|
||||
- 包含 settings 契约、baseline 矩阵、后端裁决点与前端 UI 投影的维护约束
|
||||
|
|
|
|||
254
docs/CurrentState/iOSPolicy.md
Normal file
254
docs/CurrentState/iOSPolicy.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# iOS 分发 Policy(当前实现状态)
|
||||
|
||||
本文档记录 **已经落地** 的 iOS/iPadOS-only 分发 Policy 现状:通过 `profile + capabilities snapshot` 将公开 TestFlight 包的默认裁剪收敛到一个集中、可审计、可被导入覆盖的运行时策略系统。
|
||||
|
||||
核心结论:
|
||||
|
||||
- **profile 可被导入覆盖**:用户导入/覆盖 `tauritavern-settings.json` 后,必须能改变 profile 与 capability 边界(允许“导入后重启生效”)。
|
||||
- **iOS-only 生效**:非 iOS/iPadOS 平台忽略 `ios_policy`(不解析、不校验、不参与裁剪),避免 iOS policy 配置错误拖垮桌面端。
|
||||
- **fail-fast**:iOS/iPadOS 上 policy schema/keys/allowlist 一旦非法,启动期直接失败;能力被禁用时,相关命令/服务明确拒绝,不做 silent noop。
|
||||
- **无编译期 HardPolicy**:构建时只注入“出厂默认 profile”,用户始终可通过导入 settings 覆盖/解锁。
|
||||
|
||||
---
|
||||
|
||||
## 1. Settings 契约(Public Contract)
|
||||
|
||||
### 1.1 存放位置(source of truth)
|
||||
|
||||
- 数据目录:`<data_root>/default-user/`
|
||||
- 文件:`<data_root>/default-user/tauritavern-settings.json`
|
||||
- Policy 字段:`ios_policy`(存储为 raw JSON:`serde_json::Value`)
|
||||
|
||||
实现位置:
|
||||
|
||||
- 读取/写入:`src-tauri/src/infrastructure/repositories/file_settings_repository.rs`
|
||||
- Settings 模型:`src-tauri/src/domain/models/settings.rs`
|
||||
|
||||
> 选择 raw JSON 的原因:允许桌面端加载来自 iOS 的 settings(即使未来 iOS policy schema 演进),同时让 iOS 端在运行时严格校验并 fail-fast。
|
||||
|
||||
### 1.2 v1 最小 JSON 结构(严格校验)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ios_policy": {
|
||||
"version": 1,
|
||||
"profile": "ios_external_beta",
|
||||
"overrides": {
|
||||
"capabilities": {
|
||||
"updates": { "manual_check": true },
|
||||
"llm": { "endpoint_overrides": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
约束(iOS/iPadOS 上生效):
|
||||
|
||||
- `version` 必填,且必须等于 `IOS_POLICY_VERSION`(当前为 1)
|
||||
- `profile` 必填,允许:`full` / `ios_internal_full` / `ios_external_beta`
|
||||
- `overrides.capabilities` 只允许覆盖 **已定义** 的 capability key
|
||||
- policy JSON 采用 `deny_unknown_fields`:出现未知字段(含拼写错误)直接失败
|
||||
- allowlist(如下两类)支持:
|
||||
- `"all"`
|
||||
- `["item-a", "item-b"]`
|
||||
|
||||
实现位置:
|
||||
|
||||
- schema + baseline + 解析器:`src-tauri/src/domain/ios_policy.rs`
|
||||
|
||||
### 1.3 iOS-only 生效语义(范围隔离)
|
||||
|
||||
- iOS/iPadOS:解析并应用 `ios_policy`,严格校验(fail-fast)
|
||||
- 非 iOS/iPadOS:`ios_policy` **被忽略**,不会因为其非法导致启动失败
|
||||
|
||||
实现位置:
|
||||
|
||||
- `IosPolicyScope::for_current_platform()`:`src-tauri/src/domain/ios_policy.rs`
|
||||
- 解析入口:`resolve_ios_policy_activation_report()`:`src-tauri/src/domain/ios_policy.rs`
|
||||
|
||||
---
|
||||
|
||||
## 2. 启动链路(Activation Report 下发)
|
||||
|
||||
### 2.1 解析与缓存(一次性快照)
|
||||
|
||||
当前实现为“启动期一次性解析并缓存”,变更需要重启生效:
|
||||
|
||||
1. `tauritavern-settings.json` 不存在时写入 `TauriTavernSettings::default()`(iOS 会注入默认 profile,见 2.2)
|
||||
2. iOS 上解析 `ios_policy` → `IosPolicyActivationReport`
|
||||
3. 将 `IosPolicyActivationReport` 缓存到 `AppState.ios_policy`
|
||||
4. `/api/bootstrap` 返回 `ios_policy` 快照,前端启动期读取并投影 UI
|
||||
|
||||
实现位置:
|
||||
|
||||
- AppState 构建:`src-tauri/src/app/bootstrap.rs`
|
||||
- Bootstrap DTO:`src-tauri/src/presentation/commands/bootstrap_commands.rs`
|
||||
- Host 路由:`src/tauri/main/routes/bootstrap-routes.js`
|
||||
- 前端启动:`src/script.js`
|
||||
- 前端读取帮助函数:`src/scripts/tauritavern/ios-policy.js`
|
||||
|
||||
### 2.2 “出厂默认 profile”(仅默认值,不是硬限制)
|
||||
|
||||
iOS 构建可通过 `TAURITAVERN_IOS_POLICY_PROFILE` 注入默认 profile,用于首启生成默认 `tauritavern-settings.json`:
|
||||
|
||||
- iOS target:允许 `full` / `ios_internal_full` / `ios_external_beta`
|
||||
- 非 iOS target:强制注入为空字符串(默认不 seed `ios_policy`)
|
||||
|
||||
实现位置:
|
||||
|
||||
- build-time 注入:`src-tauri/build.rs`
|
||||
- default seed:`src-tauri/src/domain/models/settings.rs`(`default_ios_policy_seed()`)
|
||||
|
||||
> 这只是“首次生成默认 settings”的默认值:用户导入 settings 后可覆盖 profile/overrides,且不会被系统偷偷改回去。
|
||||
|
||||
### 2.3 Activation Report(用于调试/回归)
|
||||
|
||||
`IosPolicyActivationReport` 字段:
|
||||
|
||||
- `scope`: `ios` / `ignored`
|
||||
- `profile`: 最终采用的 profile
|
||||
- `capabilities`: 最终快照(唯一真相)
|
||||
- `overridden_capabilities`: overrides 生效的路径列表(用于定位“哪些能力被手动改过”)
|
||||
|
||||
实现位置:`src-tauri/src/domain/ios_policy.rs`
|
||||
|
||||
---
|
||||
|
||||
## 3. Profiles 与 v1 Baseline Capabilities
|
||||
|
||||
基线定义(profile → baseline capabilities)在:`src-tauri/src/domain/ios_policy.rs`。
|
||||
|
||||
### 3.1 Profiles
|
||||
|
||||
- `full`:无限制
|
||||
- `ios_internal_full`:接近 full,但默认关闭启动后自动检查更新
|
||||
- `ios_external_beta`:公开 TestFlight 外测的 review-safe 默认边界(可被导入覆盖解锁)
|
||||
|
||||
### 3.2 Baseline(概览)
|
||||
|
||||
| Capability | full | ios_internal_full | ios_external_beta |
|
||||
|---|---:|---:|---:|
|
||||
| `extensions.third_party_management` | ✅ | ✅ | ❌ |
|
||||
| `extensions.third_party_execution` | ✅ | ✅ | ❌ |
|
||||
| `extensions.system_allowlist` | `"all"` | `"all"` | `["data-migration","regex","quick-reply","tauritavern-version","token-counter"]` |
|
||||
| `content.external_import` | ✅ | ✅ | ❌ |
|
||||
| `updates.startup_check` | ✅ | ❌ | ❌ |
|
||||
| `updates.manual_check` | ✅ | ✅ | ❌ |
|
||||
| `prompts.nsfw_prompt` | ✅ | ✅ | ❌ |
|
||||
| `prompts.jailbreak_prompt` | ✅ | ✅ | ❌ |
|
||||
| `llm.chat_completion_sources.allowlist` | `"all"` | `"all"` | `["openai","claude","makersuite"]` |
|
||||
| `llm.chat_completion_features.web_search` | ✅ | ✅ | ❌ |
|
||||
| `llm.chat_completion_features.request_images` | ✅ | ✅ | ❌ |
|
||||
| `llm.endpoint_overrides` | ✅ | ✅ | ❌ |
|
||||
| `llm.text_completions.enabled` | ✅ | ✅ | ❌ |
|
||||
| `network.request_proxy` | ✅ | ✅ | ❌ |
|
||||
| `scripting.prompt_injections` | ✅ | ✅ | ❌ |
|
||||
| `scripting.tool_registration` | ✅ | ✅ | ❌ |
|
||||
| `ai.image_generation` | ✅ | ✅ | ❌ |
|
||||
| `sync.lan` | ✅ | ✅ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 裁决点覆盖(当前已落地)
|
||||
|
||||
本节只描述 **已经落地** 的裁决点;未覆盖的能力会明确标注为“仅 UI 投影”。
|
||||
|
||||
### 4.1 后端裁决(不可绕过)
|
||||
|
||||
通用拒绝 helper:
|
||||
|
||||
- `ensure_ios_policy_allows(...)`:`src-tauri/src/presentation/commands/helpers.rs`
|
||||
|
||||
能力 → 落点:
|
||||
|
||||
- `extensions.third_party_management`
|
||||
- Rust commands:`src-tauri/src/presentation/commands/extension_commands.rs`
|
||||
- `install_extension` / `update_extension` / `delete_extension` / `move_extension` / `get_extension_version`
|
||||
- `extensions.third_party_execution` + `extensions.system_allowlist`
|
||||
- Rust commands:`src-tauri/src/presentation/commands/extension_commands.rs:get_extensions`
|
||||
- 行为:发现阶段过滤(system 仅 allowlist;local/global third-party 受 `third_party_execution` 控制)
|
||||
- `content.external_import`
|
||||
- Rust commands:`src-tauri/src/presentation/commands/content_commands.rs:download_external_import_url`
|
||||
- `updates.manual_check`
|
||||
- Rust commands:`src-tauri/src/presentation/commands/update_commands.rs:check_for_update`
|
||||
- `llm.chat_completion_sources.allowlist`
|
||||
- Service:`src-tauri/src/application/services/chat_completion_service/mod.rs`
|
||||
- `llm.endpoint_overrides`
|
||||
- Service:`src-tauri/src/application/services/chat_completion_service/mod.rs`
|
||||
- 行为:禁用时禁止 `custom` source;并拒绝任何非空 override 字段(reverse_proxy/custom_url/custom_include_headers/proxy_password)
|
||||
- `llm.chat_completion_features.web_search` / `llm.chat_completion_features.request_images`
|
||||
- Service:`src-tauri/src/application/services/chat_completion_service/mod.rs`
|
||||
- 行为:payload 中出现 enable_web_search/request_images 或 request_image_* 即拒绝
|
||||
- `network.request_proxy`
|
||||
- 启动期:`src-tauri/src/lib.rs` 若 settings 中 request_proxy.enabled=true 但 capability 禁用 → 直接启动失败
|
||||
- 运行期:`src-tauri/src/presentation/commands/settings_commands.rs:update_tauritavern_settings` 禁止启用 proxy
|
||||
- `ai.image_generation`
|
||||
- Rust commands:`src-tauri/src/presentation/commands/stable_diffusion_commands.rs:sd_handle`
|
||||
- `sync.lan`
|
||||
- Rust commands:`src-tauri/src/presentation/commands/lan_sync_commands.rs:*`(所有 LAN sync 命令统一门禁)
|
||||
|
||||
### 4.2 启动关键路径裁剪(避免外测默认自爆)
|
||||
|
||||
- `updates.startup_check`
|
||||
- 前端系统扩展 `tauritavern-version` 在 `APP_READY` 时检查 capability 并跳过启动更新检查
|
||||
- 实现:`src/scripts/extensions/tauritavern-version/index.js`
|
||||
- `llm.text_completions.enabled`
|
||||
- 若用户 settings 里 `main_api === "textgenerationwebui"`,iOS policy 禁用时会在加载 settings 后强制切回 chat completion(并 toast 提示)
|
||||
- 实现:`src/script.js`
|
||||
|
||||
### 4.3 前端 UI 投影(入口隐藏/收口)
|
||||
|
||||
统一入口(启动期):
|
||||
|
||||
- 将 bootstrap 的 `ios_policy` 写入 `window.__TAURITAVERN__.iosPolicy`,随后立刻执行 UI 投影
|
||||
- 实现:`src/script.js`
|
||||
|
||||
主要投影点:
|
||||
|
||||
- 核心 UI 投影:`src/scripts/tauritavern/ios-policy-ui.js`
|
||||
- `extensions.third_party_management=false`:隐藏扩展管理入口(Install/Manage/notify updates)
|
||||
- `content.external_import=false`:隐藏 external import 按钮 + onboarding 文案块
|
||||
- `prompts.nsfw_prompt=false`:隐藏 NSFW quick edit textarea 区块(仅 UI 投影)
|
||||
- `prompts.jailbreak_prompt=false`:隐藏 Jailbreak quick edit textarea + “prefer character jailbreak”开关(仅 UI 投影)
|
||||
- `llm.chat_completion_sources.allowlist`:过滤 `#chat_completion_source` 选项并修正当前值
|
||||
- `llm.endpoint_overrides=false`:隐藏 reverse proxy / custom endpoint 相关 UI
|
||||
- `llm.chat_completion_features.web_search=false`:隐藏 web search 开关
|
||||
- `llm.chat_completion_features.request_images=false`:隐藏 request images block
|
||||
- `llm.text_completions.enabled=false`:移除 Text Completion option 并隐藏 panel
|
||||
- `ai.image_generation=false`:隐藏 `#bg_chat_hint`(避免 SD 相关提示露出)
|
||||
- Settings 面板投影:`src/scripts/tauri/setting/setting-panel/settings-popup.js`
|
||||
- `network.request_proxy=false`:隐藏 Request Proxy details(并在不支持 data root 选择时隐藏 system panel)
|
||||
- `sync.lan=false`:隐藏 Sync panel(避免触发相机/局域网相关入口)
|
||||
- 外部导入点击门禁:`src/script.js`
|
||||
- `content.external_import=false` 时直接 toast 并 return(避免“隐藏失效/脚本触发”)
|
||||
- LLM source selector 兜底:`src/scripts/openai.js`
|
||||
- `llm.chat_completion_sources.allowlist` + `llm.endpoint_overrides`:当当前选择不允许时自动回落到允许项(并 toast)
|
||||
- Slash Commands 注册收口:
|
||||
- `scripting.prompt_injections=false`:不注册 `/inject` `/listinjects` `/flushinject(s)` 等命令
|
||||
- `src/scripts/slash-commands.js`
|
||||
- `scripting.tool_registration=false`:不注册 `/tools-register` 等注册类命令
|
||||
- `src/scripts/tool-calling.js`
|
||||
- `tauritavern-version` 扩展(更新能力 UI):
|
||||
- `updates.manual_check=false`:移除 “Check for Updates” 按钮,并隐藏兼容性信息/Discord 链接
|
||||
- `updates.startup_check=false`:跳过启动检查更新
|
||||
- `src/scripts/extensions/tauritavern-version/index.js`
|
||||
|
||||
> 当前仍存在少量按 `profile` 的 UI 差异(如外测隐藏 Discord 链接/兼容信息、data-migration 文案替换);新增功能应优先以 `capabilities.*` 为唯一判断条件,避免 profile 分支扩散。
|
||||
|
||||
---
|
||||
|
||||
## 5. 已知边界(刻意未做/仅 UI)
|
||||
|
||||
- `prompts.nsfw_prompt` / `prompts.jailbreak_prompt`
|
||||
- 当前为 **UI 投影级别**(隐藏 quick-edit 入口),尚未在“生成请求 payload”层做后端拒绝/校验。
|
||||
- 这意味着:若未来出现绕过 UI 的 prompt 注入链路,仍需在生成链路追加更硬的裁决点(应以 capabilities 为唯一真相)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 维护约束(防腐化要点)
|
||||
|
||||
1. 新增/调整能力时,先改 `src-tauri/src/domain/ios_policy.rs`(capability key + baseline + overrides),再落裁决点与 UI 投影;禁止直接在 UI 到处加 `if (profile === ...)`。
|
||||
2. iOS 上拒绝 silent fallback:策略非法/能力禁用必须“明确失败或明确拒绝”,避免后续难排查。
|
||||
3. 非 iOS 平台必须继续 **忽略** `ios_policy`,防止跨平台 settings 导入把桌面端拖死。
|
||||
|
||||
|
|
@ -12,13 +12,14 @@
|
|||
6. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑
|
||||
7. [Android 端开发说明](./AndroidDevelopment.md) - Android WebView/Insets 注入、资源访问与路径解析方案
|
||||
8. [iOS 端开发说明](./iOSDevelopment.md) - WKWebView 行为差异、safe-area/viewport-fit 与底部死区修复
|
||||
9. [iOS 通用导出桥实现计划](./IosGenericExportBridgePlan.md) - iOS 上将浏览器式导出统一接入原生 Share Sheet 的实施方案
|
||||
10. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束
|
||||
11. [扩展 API 文档](./API/README.md) - `window.__TAURITAVERN__.api.*` 的参考与适配指南(面向扩展作者)
|
||||
12. [TauriTavern-Creator-Extension 实现计划](./TauriTavernCreatorExtensionPlan.md) - 面向样板扩展的整体架构、分层、阶段路线与 ROI 分析
|
||||
13. [宿主扩展 API 规范化计划](./TauriTavernHostExtensionApiPlan.md) - TauriTavern 本体应新增/整理的正式 ABI 设计与落地顺序
|
||||
14. [Workshop 回调认证重构计划](./WorkshopAuthRefactorPlan.md) - 桌面端 100% 兼容 window.open 弹窗回调,移动端优先覆盖 token/轮询类
|
||||
15. [Dev Logging 性能重构计划书](./DevLoggingPerformanceRefactorPlan.md) - 日志捕获/存储/展示链路的有界化与性能治理路线图
|
||||
9. [iOS 分发 Policy(当前状态)](./CurrentState/iOSPolicy.md) - iOS/iPadOS-only:profile 可被导入覆盖的 Policy/Capabilities 契约与裁决点快照
|
||||
10. [iOS 通用导出桥实现计划](./IosGenericExportBridgePlan.md) - iOS 上将浏览器式导出统一接入原生 Share Sheet 的实施方案
|
||||
11. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束
|
||||
12. [扩展 API 文档](./API/README.md) - `window.__TAURITAVERN__.api.*` 的参考与适配指南(面向扩展作者)
|
||||
13. [TauriTavern-Creator-Extension 实现计划](./TauriTavernCreatorExtensionPlan.md) - 面向样板扩展的整体架构、分层、阶段路线与 ROI 分析
|
||||
14. [宿主扩展 API 规范化计划](./TauriTavernHostExtensionApiPlan.md) - TauriTavern 本体应新增/整理的正式 ABI 设计与落地顺序
|
||||
15. [Workshop 回调认证重构计划](./WorkshopAuthRefactorPlan.md) - 桌面端 100% 兼容 window.open 弹窗回调,移动端优先覆盖 token/轮询类
|
||||
16. [Dev Logging 性能重构计划书](./DevLoggingPerformanceRefactorPlan.md) - 日志捕获/存储/展示链路的有界化与性能治理路线图
|
||||
|
||||
## 项目概述
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
本文档记录当前 iOS 端开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并确保移动端样式契约(`--tt-inset-*`)在 iOS 上可预测、可维护。
|
||||
|
||||
补充:iOS/iPadOS 的 **分发 Policy(profile + capabilities snapshot)** 属于“合规裁剪/能力分级”问题域,其当前实现快照与维护约束已收敛到 `docs/CurrentState/iOSPolicy.md`,本文件仍聚焦 WKWebView 行为差异与 iOS-only 桥接。
|
||||
|
||||
## 1. WKWebView safe-area 自动 inset 导致底部死区
|
||||
|
||||
### 1.1 现象
|
||||
|
|
@ -170,3 +172,9 @@ iOS 上“文件选择 / 文件导出”必须交给系统级能力完成:
|
|||
### 4.4 支持边界
|
||||
|
||||
- 该能力依赖 iOS 16 的 app-embedded WKWebView Fullscreen API,因此项目 iOS 支持线提升到 `iOS 16+`。
|
||||
|
||||
## 5. iOS 分发 Policy(当前状态)
|
||||
|
||||
iOS 外测/内测分发裁剪与能力分级已落地为 iOS-only 的 `ios_policy` 运行时系统(可被导入 `tauritavern-settings.json` 覆盖 profile/能力边界,且 iOS 上 fail-fast、桌面端忽略)。
|
||||
|
||||
- 当前实现快照:`docs/CurrentState/iOSPolicy.md`
|
||||
|
|
|
|||
|
|
@ -19,6 +19,45 @@ mod upsync;
|
|||
|
||||
const TAOBAO_REGISTRY: &str = "https://registry.npmmirror.com";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum IosPolicyProfileSelection {
|
||||
Full,
|
||||
IosInternalFull,
|
||||
IosExternalBeta,
|
||||
}
|
||||
|
||||
impl IosPolicyProfileSelection {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Full => "full",
|
||||
Self::IosInternalFull => "ios_internal_full",
|
||||
Self::IosExternalBeta => "ios_external_beta",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_ios_policy_profile(action: &str) -> Result<Option<IosPolicyProfileSelection>> {
|
||||
let selections = &[
|
||||
"✅ full(默认:无限制)",
|
||||
"🧪 ios_internal_full(内部:全功能,默认禁用启动更新检查)",
|
||||
"🚦 ios_external_beta(外测:review-safe 默认裁剪,可被导入覆盖解锁)",
|
||||
"🔙 返回 (Back)",
|
||||
];
|
||||
|
||||
let selection = Select::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt(format!("{}:选择 iOS Policy Profile", action))
|
||||
.default(0)
|
||||
.items(&selections[..])
|
||||
.interact()?;
|
||||
|
||||
match selection {
|
||||
0 => Ok(Some(IosPolicyProfileSelection::Full)),
|
||||
1 => Ok(Some(IosPolicyProfileSelection::IosInternalFull)),
|
||||
2 => Ok(Some(IosPolicyProfileSelection::IosExternalBeta)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// 日志辅助函数
|
||||
fn log_info(msg: &str) {
|
||||
let time = Local::now().format("%H:%M:%S").to_string();
|
||||
|
|
@ -735,12 +774,35 @@ fn run_android_dev() -> Result<()> {
|
|||
}
|
||||
|
||||
fn run_ios_dev() -> Result<()> {
|
||||
log_info("正在启动 iOS 开发模式...");
|
||||
let Some(profile) = prompt_ios_policy_profile("iOS Dev")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
log_info(&format!(
|
||||
"正在启动 iOS 开发模式... (policy: {})",
|
||||
profile.as_str()
|
||||
));
|
||||
|
||||
let status = run_sequential_attempts(&[
|
||||
("pnpm", vec!["run", "ios:dev"]),
|
||||
("corepack", vec!["pnpm", "run", "ios:dev"]),
|
||||
("npm", vec!["run", "ios:dev"]),
|
||||
(
|
||||
"pnpm",
|
||||
vec!["run", "ios:dev:policy", "--", "--profile", profile.as_str()],
|
||||
),
|
||||
(
|
||||
"corepack",
|
||||
vec![
|
||||
"pnpm",
|
||||
"run",
|
||||
"ios:dev:policy",
|
||||
"--",
|
||||
"--profile",
|
||||
profile.as_str(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"npm",
|
||||
vec!["run", "ios:dev:policy", "--", "--profile", profile.as_str()],
|
||||
),
|
||||
])?;
|
||||
|
||||
if !status.success() {
|
||||
|
|
@ -870,12 +932,47 @@ fn run_android_build_split_abi() -> Result<()> {
|
|||
}
|
||||
|
||||
fn run_ios_build() -> Result<()> {
|
||||
log_info("正在构建 iOS 生产版本...");
|
||||
let Some(profile) = prompt_ios_policy_profile("iOS Build")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
log_info(&format!(
|
||||
"正在构建 iOS 生产版本... (policy: {})",
|
||||
profile.as_str()
|
||||
));
|
||||
|
||||
let status = run_sequential_attempts(&[
|
||||
("pnpm", vec!["run", "ios:build"]),
|
||||
("corepack", vec!["pnpm", "run", "ios:build"]),
|
||||
("npm", vec!["run", "ios:build"]),
|
||||
(
|
||||
"pnpm",
|
||||
vec![
|
||||
"run",
|
||||
"ios:build:policy",
|
||||
"--",
|
||||
"--profile",
|
||||
profile.as_str(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"corepack",
|
||||
vec![
|
||||
"pnpm",
|
||||
"run",
|
||||
"ios:build:policy",
|
||||
"--",
|
||||
"--profile",
|
||||
profile.as_str(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"npm",
|
||||
vec![
|
||||
"run",
|
||||
"ios:build:policy",
|
||||
"--",
|
||||
"--profile",
|
||||
profile.as_str(),
|
||||
],
|
||||
),
|
||||
])?;
|
||||
|
||||
if status.success() {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
"android:build": "tauri android build",
|
||||
"ios:dev": "tauri ios dev",
|
||||
"ios:build": "tauri ios build",
|
||||
"ios:dev:policy": "node scripts/ios-policy.mjs dev",
|
||||
"ios:build:policy": "node scripts/ios-policy.mjs build",
|
||||
"fastools:build": "cargo build --release --manifest-path fastools/Cargo.toml",
|
||||
"fastools:run": "cargo run --manifest-path fastools/Cargo.toml"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\export-sillytavern-migration.
|
|||
用 `rg` 快速审计若干运行时依赖锚点,属于开发期排查辅助脚本。
|
||||
- `tauri-ios-xcode-script.sh`
|
||||
包装 `tauri ios xcode-script`,补齐 Xcode GUI 构建环境中的 PATH / Node / pnpm,并在构建后处理 iOS 图标。
|
||||
- `ios-policy.mjs`
|
||||
iOS Dev/Build 包装脚本:为构建过程注入 `TAURITAVERN_IOS_POLICY_PROFILE`,并在 `ios_internal_full` / `ios_external_beta` 构建时自动使用 `--export-method app-store-connect`。
|
||||
- `ios-opaque-app-icons.swift`
|
||||
将 iOS App Icon PNG 展平为不透明背景,供 `tauri-ios-xcode-script.sh` 调用。
|
||||
- `ci/setup-macos-signing.sh`
|
||||
|
|
|
|||
152
scripts/ios-policy.mjs
Normal file
152
scripts/ios-policy.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ALLOWED_PROFILES = new Set(['full', 'ios_internal_full', 'ios_external_beta']);
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/ios-policy.mjs <dev|build> [options] [-- <tauri args>]
|
||||
|
||||
Options:
|
||||
--profile <profile> iOS policy profile (full | ios_internal_full | ios_external_beta)
|
||||
--help Show this help message
|
||||
|
||||
Behavior:
|
||||
- Sets TAURITAVERN_IOS_POLICY_PROFILE for the build/dev process.
|
||||
- For profile ios_internal_full / ios_external_beta, iOS builds add:
|
||||
--export-method app-store-connect
|
||||
(full does not specify export-method).
|
||||
`);
|
||||
}
|
||||
|
||||
function resolveRepoRoot() {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(scriptDir, '..');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
mode: null,
|
||||
profile: null,
|
||||
extraTauriArgs: [],
|
||||
};
|
||||
|
||||
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
options.mode = argv[0];
|
||||
|
||||
let passThrough = false;
|
||||
for (let index = 1; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
|
||||
if (passThrough) {
|
||||
options.extraTauriArgs.push(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === '--') {
|
||||
const next = argv[index + 1];
|
||||
if (next === '--profile' || next === '--help' || next === '-h') {
|
||||
continue;
|
||||
}
|
||||
|
||||
passThrough = true;
|
||||
options.extraTauriArgs.push('--');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === '--help' || value === '-h') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (value === '--profile') {
|
||||
const profile = argv[index + 1];
|
||||
if (!profile) {
|
||||
throw new Error('Missing value for --profile');
|
||||
}
|
||||
options.profile = profile;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
options.extraTauriArgs.push(value);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function shouldAddExportMethod(profile) {
|
||||
return profile === 'ios_internal_full' || profile === 'ios_external_beta';
|
||||
}
|
||||
|
||||
function hasExportMethodFlag(args) {
|
||||
return args.some((value) => value === '--export-method' || value.startsWith('--export-method='));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const repoRoot = resolveRepoRoot();
|
||||
process.chdir(repoRoot);
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.mode !== 'dev' && options.mode !== 'build') {
|
||||
throw new Error(`Unsupported mode: ${options.mode}. Expected 'dev' or 'build'.`);
|
||||
}
|
||||
|
||||
const profile = (options.profile || '').trim();
|
||||
if (!ALLOWED_PROFILES.has(profile)) {
|
||||
throw new Error(
|
||||
`Invalid --profile value: ${profile || '(missing)'}. Expected one of: full, ios_internal_full, ios_external_beta.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('iOS dev/build requires macOS.');
|
||||
}
|
||||
|
||||
const tauriCli = path.join(repoRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js');
|
||||
if (!existsSync(tauriCli)) {
|
||||
throw new Error(`Missing Tauri CLI at ${tauriCli}. Run pnpm install first.`);
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
TAURITAVERN_IOS_POLICY_PROFILE: profile,
|
||||
};
|
||||
|
||||
const tauriArgs = ['ios', options.mode, ...options.extraTauriArgs];
|
||||
|
||||
if (
|
||||
options.mode === 'build'
|
||||
&& shouldAddExportMethod(profile)
|
||||
&& !hasExportMethodFlag(tauriArgs)
|
||||
) {
|
||||
tauriArgs.splice(2, 0, '--export-method', 'app-store-connect');
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [tauriCli, ...tauriArgs], {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -13,8 +13,10 @@ fn main() {
|
|||
println!("cargo:rerun-if-changed=../.git/refs");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
|
||||
println!("cargo:rerun-if-env-changed=TAURITAVERN_IOS_POLICY_PROFILE");
|
||||
|
||||
emit_git_build_metadata();
|
||||
emit_ios_policy_build_profile();
|
||||
|
||||
if let Err(error) = generate_resource_artifacts() {
|
||||
panic!("Failed to generate resource artifacts: {}", error);
|
||||
|
|
@ -47,6 +49,28 @@ fn emit_git_build_metadata() {
|
|||
);
|
||||
}
|
||||
|
||||
fn emit_ios_policy_build_profile() {
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
let is_ios_target = target.contains("-apple-ios");
|
||||
|
||||
let raw_profile = std::env::var("TAURITAVERN_IOS_POLICY_PROFILE").unwrap_or_default();
|
||||
let normalized = raw_profile.trim();
|
||||
let profile = if is_ios_target { normalized } else { "" };
|
||||
|
||||
if is_ios_target && !profile.is_empty() {
|
||||
match profile {
|
||||
"full" | "ios_internal_full" | "ios_external_beta" => {}
|
||||
value => {
|
||||
panic!(
|
||||
"TAURITAVERN_IOS_POLICY_PROFILE has unsupported value {value:?}. Expected one of: full, ios_internal_full, ios_external_beta."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("cargo:rustc-env=TAURITAVERN_IOS_POLICY_PROFILE={}", profile);
|
||||
}
|
||||
|
||||
fn run_git_command(args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git").args(args).output().ok()?;
|
||||
if !output.status.success() {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use crate::application::services::user_directory_service::UserDirectoryService;
|
|||
use crate::application::services::user_service::UserService;
|
||||
use crate::application::services::world_info_service::WorldInfoService;
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::infrastructure::logging::logger;
|
||||
use crate::infrastructure::paths::RuntimePaths;
|
||||
|
||||
mod bootstrap;
|
||||
|
|
@ -55,6 +56,7 @@ pub struct AppState {
|
|||
pub lan_sync_service: Arc<LanSyncService>,
|
||||
pub tt_sync_service: Arc<TtSyncService>,
|
||||
pub update_service: Arc<UpdateService>,
|
||||
pub ios_policy: crate::domain::ios_policy::IosPolicyActivationReport,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -101,6 +103,7 @@ impl AppState {
|
|||
lan_sync_service: services.lan_sync_service,
|
||||
tt_sync_service: services.tt_sync_service,
|
||||
update_service: services.update_service,
|
||||
ios_policy: services.ios_policy,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +147,10 @@ pub fn spawn_initialization(app_handle: AppHandle, runtime_paths: RuntimePaths)
|
|||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!("Failed to initialize application state: {}", error);
|
||||
logger::error(&format!(
|
||||
"Failed to initialize application state: {}",
|
||||
error
|
||||
));
|
||||
|
||||
match app_handle.emit("app-error", error.to_string()) {
|
||||
Ok(_) => {}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ pub(super) struct AppServices {
|
|||
pub lan_sync_service: Arc<LanSyncService>,
|
||||
pub tt_sync_service: Arc<TtSyncService>,
|
||||
pub update_service: Arc<UpdateService>,
|
||||
pub ios_policy: crate::domain::ios_policy::IosPolicyActivationReport,
|
||||
}
|
||||
|
||||
struct AppRepositories {
|
||||
|
|
@ -145,6 +146,10 @@ pub(super) async fn build_services(
|
|||
.settings_repository
|
||||
.load_tauritavern_settings()
|
||||
.await?;
|
||||
let ios_policy = crate::domain::ios_policy::resolve_ios_policy_activation_report(
|
||||
crate::domain::ios_policy::IosPolicyScope::for_current_platform(),
|
||||
tauritavern_settings.ios_policy.as_ref(),
|
||||
)?;
|
||||
|
||||
let content_service = Arc::new(ContentService::new(repositories.content_repository.clone()));
|
||||
let extension_service = Arc::new(ExtensionService::new(
|
||||
|
|
@ -168,6 +173,7 @@ pub(super) async fn build_services(
|
|||
repositories.secret_repository.clone(),
|
||||
repositories.settings_repository.clone(),
|
||||
repositories.prompt_cache_repository.clone(),
|
||||
ios_policy.clone(),
|
||||
));
|
||||
let tokenization_service =
|
||||
Arc::new(TokenizationService::new(repositories.tokenizer_repository));
|
||||
|
|
@ -239,6 +245,7 @@ pub(super) async fn build_services(
|
|||
lan_sync_service,
|
||||
tt_sync_service,
|
||||
update_service,
|
||||
ios_policy,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::application::dto::character_dto::CharacterDto;
|
|||
use crate::application::dto::group_dto::GroupDto;
|
||||
use crate::application::dto::secret_dto::SecretStateDto;
|
||||
use crate::application::dto::settings_dto::SillyTavernSettingsResponseDto;
|
||||
use crate::domain::ios_policy::IosPolicyActivationReport;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BootstrapSnapshotDto {
|
||||
|
|
@ -12,4 +13,5 @@ pub struct BootstrapSnapshotDto {
|
|||
pub groups: Vec<GroupDto>,
|
||||
pub avatars: Vec<String>,
|
||||
pub secret_state: SecretStateDto,
|
||||
pub ios_policy: IosPolicyActivationReport,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,8 +153,7 @@ async fn resolve_api_config(
|
|||
)
|
||||
})?;
|
||||
|
||||
read_required_secret(secret_repository, secret_key, source_display_name(source))
|
||||
.await?
|
||||
read_required_secret(secret_repository, secret_key, source.display_name()).await?
|
||||
};
|
||||
|
||||
Ok(ChatCompletionApiConfig {
|
||||
|
|
@ -303,25 +302,6 @@ fn source_secret_key(source: ChatCompletionSource) -> Option<&'static str> {
|
|||
}
|
||||
}
|
||||
|
||||
fn source_display_name(source: ChatCompletionSource) -> &'static str {
|
||||
match source {
|
||||
ChatCompletionSource::OpenAi => "OpenAI",
|
||||
ChatCompletionSource::OpenRouter => "OpenRouter",
|
||||
ChatCompletionSource::Claude => "Claude",
|
||||
ChatCompletionSource::Makersuite => "Google Gemini",
|
||||
ChatCompletionSource::VertexAi => "Google Vertex AI",
|
||||
ChatCompletionSource::DeepSeek => "DeepSeek",
|
||||
ChatCompletionSource::Cohere => "Cohere",
|
||||
ChatCompletionSource::Groq => "Groq",
|
||||
ChatCompletionSource::Moonshot => "Moonshot AI",
|
||||
ChatCompletionSource::NanoGpt => "NanoGPT",
|
||||
ChatCompletionSource::Chutes => "Chutes",
|
||||
ChatCompletionSource::SiliconFlow => "SiliconFlow",
|
||||
ChatCompletionSource::Zai => "Z.AI (GLM)",
|
||||
ChatCompletionSource::Custom => "Custom OpenAI",
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_reverse_proxy(source: ChatCompletionSource) -> bool {
|
||||
matches!(
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::sync::{RwLock, watch};
|
||||
|
||||
use crate::application::dto::chat_completion_dto::{
|
||||
ChatCompletionGenerateRequestDto, ChatCompletionStatusRequestDto,
|
||||
};
|
||||
use crate::application::errors::ApplicationError;
|
||||
use crate::domain::ios_policy::{IosPolicyActivationReport, IosPolicyScope};
|
||||
use crate::domain::models::settings::{PromptCacheTtl, TauriTavernSettings};
|
||||
use crate::domain::repositories::chat_completion_repository::{
|
||||
ChatCompletionCancelReceiver, ChatCompletionRepository, ChatCompletionSource,
|
||||
|
|
@ -23,13 +24,14 @@ mod payload;
|
|||
mod prompt_caching;
|
||||
mod vertexai_auth;
|
||||
|
||||
const OPENAI_SOURCE: &str = "openai";
|
||||
const OPENAI_SOURCE: &str = ChatCompletionSource::OpenAi.key();
|
||||
|
||||
pub struct ChatCompletionService {
|
||||
chat_completion_repository: Arc<dyn ChatCompletionRepository>,
|
||||
secret_repository: Arc<dyn SecretRepository>,
|
||||
settings_repository: Arc<dyn SettingsRepository>,
|
||||
prompt_cache_repository: Arc<dyn PromptCacheRepository>,
|
||||
ios_policy: IosPolicyActivationReport,
|
||||
active_streams: CancellationRegistry,
|
||||
active_generations: CancellationRegistry,
|
||||
}
|
||||
|
|
@ -40,17 +42,229 @@ impl ChatCompletionService {
|
|||
secret_repository: Arc<dyn SecretRepository>,
|
||||
settings_repository: Arc<dyn SettingsRepository>,
|
||||
prompt_cache_repository: Arc<dyn PromptCacheRepository>,
|
||||
ios_policy: IosPolicyActivationReport,
|
||||
) -> Self {
|
||||
Self {
|
||||
chat_completion_repository,
|
||||
secret_repository,
|
||||
settings_repository,
|
||||
prompt_cache_repository,
|
||||
ios_policy,
|
||||
active_streams: CancellationRegistry::default(),
|
||||
active_generations: CancellationRegistry::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ios_policy_is_active(&self) -> bool {
|
||||
self.ios_policy.scope == IosPolicyScope::Ios
|
||||
}
|
||||
|
||||
fn ensure_chat_completion_source_allowed(
|
||||
&self,
|
||||
source: ChatCompletionSource,
|
||||
) -> Result<(), ApplicationError> {
|
||||
if !self.ios_policy_is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.llm
|
||||
.chat_completion_sources
|
||||
.allows_source(source)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ApplicationError::PermissionDenied(format!(
|
||||
"iOS policy disabled chat completion source: {}",
|
||||
source.key()
|
||||
)))
|
||||
}
|
||||
|
||||
fn ensure_endpoint_overrides_allowed_for_status(
|
||||
&self,
|
||||
source: ChatCompletionSource,
|
||||
dto: &ChatCompletionStatusRequestDto,
|
||||
) -> Result<(), ApplicationError> {
|
||||
if !self.ios_policy_is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.ios_policy.capabilities.llm.endpoint_overrides {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if source == ChatCompletionSource::Custom {
|
||||
return Err(ApplicationError::PermissionDenied(
|
||||
"iOS policy disabled capability: llm.endpoint_overrides".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut overridden = Vec::new();
|
||||
if !dto.reverse_proxy.trim().is_empty() {
|
||||
overridden.push("reverse_proxy");
|
||||
}
|
||||
if !dto.proxy_password.trim().is_empty() {
|
||||
overridden.push("proxy_password");
|
||||
}
|
||||
if !dto.custom_url.trim().is_empty() {
|
||||
overridden.push("custom_url");
|
||||
}
|
||||
if !dto.custom_include_headers.trim().is_empty() {
|
||||
overridden.push("custom_include_headers");
|
||||
}
|
||||
|
||||
if overridden.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ApplicationError::PermissionDenied(format!(
|
||||
"iOS policy disabled capability: llm.endpoint_overrides (used: {})",
|
||||
overridden.join(", ")
|
||||
)))
|
||||
}
|
||||
|
||||
fn ensure_endpoint_overrides_allowed_for_payload(
|
||||
&self,
|
||||
source: ChatCompletionSource,
|
||||
payload: &Map<String, Value>,
|
||||
) -> Result<(), ApplicationError> {
|
||||
if !self.ios_policy_is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.ios_policy.capabilities.llm.endpoint_overrides {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if source == ChatCompletionSource::Custom {
|
||||
return Err(ApplicationError::PermissionDenied(
|
||||
"iOS policy disabled capability: llm.endpoint_overrides".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut overridden = Vec::new();
|
||||
for key in [
|
||||
"reverse_proxy",
|
||||
"proxy_password",
|
||||
"custom_url",
|
||||
"custom_include_headers",
|
||||
] {
|
||||
let Some(value) = payload.get(key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let value = value.as_str().ok_or_else(|| {
|
||||
ApplicationError::ValidationError(format!(
|
||||
"Chat completion request field must be a string: {}",
|
||||
key
|
||||
))
|
||||
})?;
|
||||
|
||||
if !value.trim().is_empty() {
|
||||
overridden.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if overridden.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ApplicationError::PermissionDenied(format!(
|
||||
"iOS policy disabled capability: llm.endpoint_overrides (used: {})",
|
||||
overridden.join(", ")
|
||||
)))
|
||||
}
|
||||
|
||||
fn ensure_chat_completion_features_allowed(
|
||||
&self,
|
||||
payload: &Map<String, Value>,
|
||||
) -> Result<(), ApplicationError> {
|
||||
if !self.ios_policy_is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(value) = payload.get("enable_web_search") {
|
||||
let enabled = value.as_bool().ok_or_else(|| {
|
||||
ApplicationError::ValidationError(
|
||||
"Chat completion request field must be a boolean: enable_web_search"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if enabled
|
||||
&& !self
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.llm
|
||||
.chat_completion_features
|
||||
.web_search
|
||||
{
|
||||
return Err(ApplicationError::PermissionDenied(
|
||||
"iOS policy disabled capability: llm.chat_completion_features.web_search"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let request_images_enabled = match payload.get("request_images") {
|
||||
None => false,
|
||||
Some(value) => value.as_bool().ok_or_else(|| {
|
||||
ApplicationError::ValidationError(
|
||||
"Chat completion request field must be a boolean: request_images".to_string(),
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
let request_image_resolution = payload.get("request_image_resolution");
|
||||
let request_image_aspect_ratio = payload.get("request_image_aspect_ratio");
|
||||
|
||||
let mut request_image_overrides = Vec::new();
|
||||
for (key, value) in [
|
||||
("request_image_resolution", request_image_resolution),
|
||||
("request_image_aspect_ratio", request_image_aspect_ratio),
|
||||
] {
|
||||
let Some(value) = value else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let value = value.as_str().ok_or_else(|| {
|
||||
ApplicationError::ValidationError(format!(
|
||||
"Chat completion request field must be a string: {}",
|
||||
key
|
||||
))
|
||||
})?;
|
||||
|
||||
if !value.trim().is_empty() {
|
||||
request_image_overrides.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (request_images_enabled || !request_image_overrides.is_empty())
|
||||
&& !self
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.llm
|
||||
.chat_completion_features
|
||||
.request_images
|
||||
{
|
||||
let suffix = if request_image_overrides.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (used: {})", request_image_overrides.join(", "))
|
||||
};
|
||||
|
||||
return Err(ApplicationError::PermissionDenied(format!(
|
||||
"iOS policy disabled capability: llm.chat_completion_features.request_images{}",
|
||||
suffix
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_status(
|
||||
&self,
|
||||
dto: ChatCompletionStatusRequestDto,
|
||||
|
|
@ -63,6 +277,9 @@ impl ChatCompletionService {
|
|||
}
|
||||
|
||||
let source = self.resolve_source(&dto.chat_completion_source)?;
|
||||
self.ensure_chat_completion_source_allowed(source)?;
|
||||
self.ensure_endpoint_overrides_allowed_for_status(source, &dto)?;
|
||||
|
||||
if source == ChatCompletionSource::VertexAi {
|
||||
return Ok(json!({
|
||||
"bypass": true,
|
||||
|
|
@ -86,6 +303,9 @@ impl ChatCompletionService {
|
|||
dto.get_string("chat_completion_source")
|
||||
.unwrap_or(OPENAI_SOURCE),
|
||||
)?;
|
||||
self.ensure_chat_completion_source_allowed(source)?;
|
||||
self.ensure_endpoint_overrides_allowed_for_payload(source, &dto.payload)?;
|
||||
self.ensure_chat_completion_features_allowed(&dto.payload)?;
|
||||
|
||||
let settings = self.load_tauritavern_settings().await?;
|
||||
|
||||
|
|
@ -138,6 +358,9 @@ impl ChatCompletionService {
|
|||
dto.get_string("chat_completion_source")
|
||||
.unwrap_or(OPENAI_SOURCE),
|
||||
)?;
|
||||
self.ensure_chat_completion_source_allowed(source)?;
|
||||
self.ensure_endpoint_overrides_allowed_for_payload(source, &dto.payload)?;
|
||||
self.ensure_chat_completion_features_allowed(&dto.payload)?;
|
||||
|
||||
let settings = self.load_tauritavern_settings().await?;
|
||||
|
||||
|
|
|
|||
650
src-tauri/src/domain/ios_policy.rs
Normal file
650
src-tauri/src/domain/ios_policy.rs
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::repositories::chat_completion_repository::ChatCompletionSource;
|
||||
|
||||
pub const IOS_POLICY_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IosPolicyScope {
|
||||
Ios,
|
||||
Ignored,
|
||||
}
|
||||
|
||||
impl IosPolicyScope {
|
||||
pub fn for_current_platform() -> Self {
|
||||
if cfg!(target_os = "ios") {
|
||||
Self::Ios
|
||||
} else {
|
||||
Self::Ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IosPolicyProfile {
|
||||
Full,
|
||||
IosInternalFull,
|
||||
IosExternalBeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AllowlistMode {
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum AllowlistSetting {
|
||||
Mode(AllowlistMode),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl AllowlistSetting {
|
||||
pub fn allows(&self, value: &str) -> bool {
|
||||
match self {
|
||||
Self::Mode(AllowlistMode::All) => true,
|
||||
Self::List(items) => items.iter().any(|item| item == value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyCapabilities {
|
||||
pub extensions: IosPolicyExtensionCapabilities,
|
||||
pub content: IosPolicyContentCapabilities,
|
||||
pub updates: IosPolicyUpdateCapabilities,
|
||||
pub prompts: IosPolicyPromptCapabilities,
|
||||
pub llm: IosPolicyLlmCapabilities,
|
||||
pub network: IosPolicyNetworkCapabilities,
|
||||
pub scripting: IosPolicyScriptingCapabilities,
|
||||
pub ai: IosPolicyImageGenerationCapabilities,
|
||||
pub sync: IosPolicySyncCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyExtensionCapabilities {
|
||||
pub third_party_management: bool,
|
||||
pub third_party_execution: bool,
|
||||
pub system_allowlist: AllowlistSetting,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyContentCapabilities {
|
||||
pub external_import: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyUpdateCapabilities {
|
||||
pub startup_check: bool,
|
||||
pub manual_check: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyPromptCapabilities {
|
||||
pub nsfw_prompt: bool,
|
||||
pub jailbreak_prompt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyLlmCapabilities {
|
||||
pub chat_completion_sources: IosPolicyChatCompletionSourceCapabilities,
|
||||
pub chat_completion_features: IosPolicyChatCompletionFeatureCapabilities,
|
||||
pub endpoint_overrides: bool,
|
||||
pub text_completions: IosPolicyTextCompletionCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyChatCompletionSourceCapabilities {
|
||||
pub allowlist: AllowlistSetting,
|
||||
}
|
||||
|
||||
impl IosPolicyChatCompletionSourceCapabilities {
|
||||
pub fn allows_source(&self, source: ChatCompletionSource) -> bool {
|
||||
match &self.allowlist {
|
||||
AllowlistSetting::Mode(AllowlistMode::All) => true,
|
||||
AllowlistSetting::List(items) => items
|
||||
.iter()
|
||||
.any(|raw| ChatCompletionSource::parse(raw).is_some_and(|value| value == source)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyChatCompletionFeatureCapabilities {
|
||||
pub web_search: bool,
|
||||
pub request_images: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyTextCompletionCapabilities {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyNetworkCapabilities {
|
||||
pub request_proxy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyScriptingCapabilities {
|
||||
pub prompt_injections: bool,
|
||||
pub tool_registration: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyImageGenerationCapabilities {
|
||||
pub image_generation: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicySyncCapabilities {
|
||||
pub lan: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct IosPolicyActivationReport {
|
||||
pub version: u32,
|
||||
pub scope: IosPolicyScope,
|
||||
pub profile: IosPolicyProfile,
|
||||
pub capabilities: IosPolicyCapabilities,
|
||||
pub overridden_capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyConfig {
|
||||
pub version: u32,
|
||||
pub profile: IosPolicyProfile,
|
||||
#[serde(default)]
|
||||
pub overrides: IosPolicyOverridesConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyOverridesConfig {
|
||||
#[serde(default)]
|
||||
pub capabilities: IosPolicyCapabilitiesOverride,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyCapabilitiesOverride {
|
||||
#[serde(default)]
|
||||
pub extensions: IosPolicyExtensionCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub content: IosPolicyContentCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub updates: IosPolicyUpdateCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub prompts: IosPolicyPromptCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub llm: IosPolicyLlmCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub network: IosPolicyNetworkCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub scripting: IosPolicyScriptingCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub ai: IosPolicyImageGenerationCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub sync: IosPolicySyncCapabilitiesOverride,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyExtensionCapabilitiesOverride {
|
||||
pub third_party_management: Option<bool>,
|
||||
pub third_party_execution: Option<bool>,
|
||||
pub system_allowlist: Option<AllowlistSetting>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyContentCapabilitiesOverride {
|
||||
pub external_import: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyUpdateCapabilitiesOverride {
|
||||
pub startup_check: Option<bool>,
|
||||
pub manual_check: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyPromptCapabilitiesOverride {
|
||||
pub nsfw_prompt: Option<bool>,
|
||||
pub jailbreak_prompt: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyLlmCapabilitiesOverride {
|
||||
#[serde(default)]
|
||||
pub chat_completion_sources: IosPolicyChatCompletionSourceCapabilitiesOverride,
|
||||
#[serde(default)]
|
||||
pub chat_completion_features: IosPolicyChatCompletionFeatureCapabilitiesOverride,
|
||||
pub endpoint_overrides: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub text_completions: IosPolicyTextCompletionCapabilitiesOverride,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyChatCompletionSourceCapabilitiesOverride {
|
||||
pub allowlist: Option<AllowlistSetting>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyChatCompletionFeatureCapabilitiesOverride {
|
||||
pub web_search: Option<bool>,
|
||||
pub request_images: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyTextCompletionCapabilitiesOverride {
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyNetworkCapabilitiesOverride {
|
||||
pub request_proxy: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyScriptingCapabilitiesOverride {
|
||||
pub prompt_injections: Option<bool>,
|
||||
pub tool_registration: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicyImageGenerationCapabilitiesOverride {
|
||||
pub image_generation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
struct IosPolicySyncCapabilitiesOverride {
|
||||
pub lan: Option<bool>,
|
||||
}
|
||||
|
||||
impl IosPolicyCapabilities {
|
||||
pub fn baseline(profile: IosPolicyProfile) -> Self {
|
||||
match profile {
|
||||
IosPolicyProfile::Full => Self {
|
||||
extensions: IosPolicyExtensionCapabilities {
|
||||
third_party_management: true,
|
||||
third_party_execution: true,
|
||||
system_allowlist: AllowlistSetting::Mode(AllowlistMode::All),
|
||||
},
|
||||
content: IosPolicyContentCapabilities {
|
||||
external_import: true,
|
||||
},
|
||||
updates: IosPolicyUpdateCapabilities {
|
||||
startup_check: true,
|
||||
manual_check: true,
|
||||
},
|
||||
prompts: IosPolicyPromptCapabilities {
|
||||
nsfw_prompt: true,
|
||||
jailbreak_prompt: true,
|
||||
},
|
||||
llm: IosPolicyLlmCapabilities {
|
||||
chat_completion_sources: IosPolicyChatCompletionSourceCapabilities {
|
||||
allowlist: AllowlistSetting::Mode(AllowlistMode::All),
|
||||
},
|
||||
chat_completion_features: IosPolicyChatCompletionFeatureCapabilities {
|
||||
web_search: true,
|
||||
request_images: true,
|
||||
},
|
||||
endpoint_overrides: true,
|
||||
text_completions: IosPolicyTextCompletionCapabilities { enabled: true },
|
||||
},
|
||||
network: IosPolicyNetworkCapabilities {
|
||||
request_proxy: true,
|
||||
},
|
||||
scripting: IosPolicyScriptingCapabilities {
|
||||
prompt_injections: true,
|
||||
tool_registration: true,
|
||||
},
|
||||
ai: IosPolicyImageGenerationCapabilities {
|
||||
image_generation: true,
|
||||
},
|
||||
sync: IosPolicySyncCapabilities { lan: true },
|
||||
},
|
||||
IosPolicyProfile::IosInternalFull => Self {
|
||||
updates: IosPolicyUpdateCapabilities {
|
||||
startup_check: false,
|
||||
manual_check: true,
|
||||
},
|
||||
..Self::baseline(IosPolicyProfile::Full)
|
||||
},
|
||||
IosPolicyProfile::IosExternalBeta => Self {
|
||||
extensions: IosPolicyExtensionCapabilities {
|
||||
third_party_management: false,
|
||||
third_party_execution: false,
|
||||
system_allowlist: AllowlistSetting::List(vec![
|
||||
"data-migration".to_string(),
|
||||
"regex".to_string(),
|
||||
"quick-reply".to_string(),
|
||||
"tauritavern-version".to_string(),
|
||||
"token-counter".to_string(),
|
||||
]),
|
||||
},
|
||||
content: IosPolicyContentCapabilities {
|
||||
external_import: false,
|
||||
},
|
||||
updates: IosPolicyUpdateCapabilities {
|
||||
startup_check: false,
|
||||
manual_check: false,
|
||||
},
|
||||
prompts: IosPolicyPromptCapabilities {
|
||||
nsfw_prompt: false,
|
||||
jailbreak_prompt: false,
|
||||
},
|
||||
llm: IosPolicyLlmCapabilities {
|
||||
chat_completion_sources: IosPolicyChatCompletionSourceCapabilities {
|
||||
allowlist: AllowlistSetting::List(vec![
|
||||
"openai".to_string(),
|
||||
"claude".to_string(),
|
||||
"makersuite".to_string(),
|
||||
]),
|
||||
},
|
||||
chat_completion_features: IosPolicyChatCompletionFeatureCapabilities {
|
||||
web_search: false,
|
||||
request_images: false,
|
||||
},
|
||||
endpoint_overrides: false,
|
||||
text_completions: IosPolicyTextCompletionCapabilities { enabled: false },
|
||||
},
|
||||
network: IosPolicyNetworkCapabilities {
|
||||
request_proxy: false,
|
||||
},
|
||||
scripting: IosPolicyScriptingCapabilities {
|
||||
prompt_injections: false,
|
||||
tool_registration: false,
|
||||
},
|
||||
ai: IosPolicyImageGenerationCapabilities {
|
||||
image_generation: false,
|
||||
},
|
||||
sync: IosPolicySyncCapabilities { lan: false },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), DomainError> {
|
||||
if let AllowlistSetting::List(items) = &self.llm.chat_completion_sources.allowlist {
|
||||
for raw_source in items {
|
||||
if ChatCompletionSource::parse(raw_source).is_none() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"ios_policy: invalid llm.chat_completion_sources.allowlist entry: {}",
|
||||
raw_source
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IosPolicyCapabilitiesOverride {
|
||||
fn apply(self, capabilities: &mut IosPolicyCapabilities) -> Vec<String> {
|
||||
let mut overridden = Vec::new();
|
||||
|
||||
let ext = self.extensions;
|
||||
if let Some(value) = ext.third_party_management {
|
||||
capabilities.extensions.third_party_management = value;
|
||||
overridden.push("extensions.third_party_management".to_string());
|
||||
}
|
||||
if let Some(value) = ext.third_party_execution {
|
||||
capabilities.extensions.third_party_execution = value;
|
||||
overridden.push("extensions.third_party_execution".to_string());
|
||||
}
|
||||
if let Some(value) = ext.system_allowlist {
|
||||
capabilities.extensions.system_allowlist = value;
|
||||
overridden.push("extensions.system_allowlist".to_string());
|
||||
}
|
||||
|
||||
let content = self.content;
|
||||
if let Some(value) = content.external_import {
|
||||
capabilities.content.external_import = value;
|
||||
overridden.push("content.external_import".to_string());
|
||||
}
|
||||
|
||||
let updates = self.updates;
|
||||
if let Some(value) = updates.startup_check {
|
||||
capabilities.updates.startup_check = value;
|
||||
overridden.push("updates.startup_check".to_string());
|
||||
}
|
||||
if let Some(value) = updates.manual_check {
|
||||
capabilities.updates.manual_check = value;
|
||||
overridden.push("updates.manual_check".to_string());
|
||||
}
|
||||
|
||||
let prompts = self.prompts;
|
||||
if let Some(value) = prompts.nsfw_prompt {
|
||||
capabilities.prompts.nsfw_prompt = value;
|
||||
overridden.push("prompts.nsfw_prompt".to_string());
|
||||
}
|
||||
if let Some(value) = prompts.jailbreak_prompt {
|
||||
capabilities.prompts.jailbreak_prompt = value;
|
||||
overridden.push("prompts.jailbreak_prompt".to_string());
|
||||
}
|
||||
|
||||
let llm = self.llm;
|
||||
let sources = llm.chat_completion_sources;
|
||||
if let Some(value) = sources.allowlist {
|
||||
capabilities.llm.chat_completion_sources.allowlist = value;
|
||||
overridden.push("llm.chat_completion_sources.allowlist".to_string());
|
||||
}
|
||||
|
||||
let features = llm.chat_completion_features;
|
||||
if let Some(value) = features.web_search {
|
||||
capabilities.llm.chat_completion_features.web_search = value;
|
||||
overridden.push("llm.chat_completion_features.web_search".to_string());
|
||||
}
|
||||
if let Some(value) = features.request_images {
|
||||
capabilities.llm.chat_completion_features.request_images = value;
|
||||
overridden.push("llm.chat_completion_features.request_images".to_string());
|
||||
}
|
||||
|
||||
if let Some(value) = llm.endpoint_overrides {
|
||||
capabilities.llm.endpoint_overrides = value;
|
||||
overridden.push("llm.endpoint_overrides".to_string());
|
||||
}
|
||||
|
||||
let text_completions = llm.text_completions;
|
||||
if let Some(value) = text_completions.enabled {
|
||||
capabilities.llm.text_completions.enabled = value;
|
||||
overridden.push("llm.text_completions.enabled".to_string());
|
||||
}
|
||||
|
||||
let network = self.network;
|
||||
if let Some(value) = network.request_proxy {
|
||||
capabilities.network.request_proxy = value;
|
||||
overridden.push("network.request_proxy".to_string());
|
||||
}
|
||||
|
||||
let scripting = self.scripting;
|
||||
if let Some(value) = scripting.prompt_injections {
|
||||
capabilities.scripting.prompt_injections = value;
|
||||
overridden.push("scripting.prompt_injections".to_string());
|
||||
}
|
||||
if let Some(value) = scripting.tool_registration {
|
||||
capabilities.scripting.tool_registration = value;
|
||||
overridden.push("scripting.tool_registration".to_string());
|
||||
}
|
||||
|
||||
let ai = self.ai;
|
||||
if let Some(value) = ai.image_generation {
|
||||
capabilities.ai.image_generation = value;
|
||||
overridden.push("ai.image_generation".to_string());
|
||||
}
|
||||
|
||||
let sync = self.sync;
|
||||
if let Some(value) = sync.lan {
|
||||
capabilities.sync.lan = value;
|
||||
overridden.push("sync.lan".to_string());
|
||||
}
|
||||
|
||||
overridden
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_ios_policy_activation_report(
|
||||
scope: IosPolicyScope,
|
||||
raw_policy: Option<&Value>,
|
||||
) -> Result<IosPolicyActivationReport, DomainError> {
|
||||
if scope == IosPolicyScope::Ignored {
|
||||
let capabilities = IosPolicyCapabilities::baseline(IosPolicyProfile::Full);
|
||||
return Ok(IosPolicyActivationReport {
|
||||
version: IOS_POLICY_VERSION,
|
||||
scope,
|
||||
profile: IosPolicyProfile::Full,
|
||||
capabilities,
|
||||
overridden_capabilities: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let Some(raw_policy) = raw_policy else {
|
||||
let capabilities = IosPolicyCapabilities::baseline(IosPolicyProfile::Full);
|
||||
return Ok(IosPolicyActivationReport {
|
||||
version: IOS_POLICY_VERSION,
|
||||
scope,
|
||||
profile: IosPolicyProfile::Full,
|
||||
capabilities,
|
||||
overridden_capabilities: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let config: IosPolicyConfig = serde_json::from_value(raw_policy.clone()).map_err(|error| {
|
||||
DomainError::InvalidData(format!("ios_policy: invalid schema: {}", error))
|
||||
})?;
|
||||
|
||||
if config.version != IOS_POLICY_VERSION {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"ios_policy: unsupported version {} (expected {})",
|
||||
config.version, IOS_POLICY_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
let mut capabilities = IosPolicyCapabilities::baseline(config.profile);
|
||||
let overridden_capabilities = config.overrides.capabilities.apply(&mut capabilities);
|
||||
capabilities.validate()?;
|
||||
|
||||
Ok(IosPolicyActivationReport {
|
||||
version: config.version,
|
||||
scope,
|
||||
profile: config.profile,
|
||||
capabilities,
|
||||
overridden_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_full_profile_when_ignored() {
|
||||
let report = resolve_ios_policy_activation_report(
|
||||
IosPolicyScope::Ignored,
|
||||
Some(&serde_json::json!({
|
||||
"version": 1,
|
||||
"profile": "ios_external_beta",
|
||||
"overrides": {
|
||||
"capabilities": {
|
||||
"updates": { "manual_check": true }
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.scope, IosPolicyScope::Ignored);
|
||||
assert_eq!(report.profile, IosPolicyProfile::Full);
|
||||
assert!(report.capabilities.extensions.third_party_management);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_keys() {
|
||||
let error = resolve_ios_policy_activation_report(
|
||||
IosPolicyScope::Ios,
|
||||
Some(&serde_json::json!({
|
||||
"version": 1,
|
||||
"profile": "full",
|
||||
"overrides": {
|
||||
"capabilities": {
|
||||
"llm": {
|
||||
"chat_completion_sources": { "allowlist": ["openai"] },
|
||||
"typo_key": true
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("unknown field"),
|
||||
"unexpected error: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_overrides_and_tracks_paths() {
|
||||
let report = resolve_ios_policy_activation_report(
|
||||
IosPolicyScope::Ios,
|
||||
Some(&serde_json::json!({
|
||||
"version": 1,
|
||||
"profile": "ios_external_beta",
|
||||
"overrides": {
|
||||
"capabilities": {
|
||||
"updates": { "manual_check": true },
|
||||
"llm": { "endpoint_overrides": true }
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.profile, IosPolicyProfile::IosExternalBeta);
|
||||
assert!(report.capabilities.updates.manual_check);
|
||||
assert!(report.capabilities.llm.endpoint_overrides);
|
||||
assert!(
|
||||
report
|
||||
.overridden_capabilities
|
||||
.contains(&"updates.manual_check".to_string())
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.overridden_capabilities
|
||||
.contains(&"llm.endpoint_overrides".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// Domain layer - contains core business entities and logic
|
||||
pub mod errors;
|
||||
pub mod ios_policy;
|
||||
pub(crate) mod json_merge;
|
||||
pub mod models;
|
||||
pub mod repositories;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn default_ios_policy_seed() -> Option<Value> {
|
||||
if !cfg!(target_os = "ios") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let profile = env!("TAURITAVERN_IOS_POLICY_PROFILE").trim();
|
||||
if profile.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"version": crate::domain::ios_policy::IOS_POLICY_VERSION,
|
||||
"profile": profile,
|
||||
}))
|
||||
}
|
||||
|
||||
fn default_perf_profile() -> String {
|
||||
"auto".to_string()
|
||||
|
|
@ -203,6 +219,14 @@ pub struct TauriTavernSettings {
|
|||
pub dynamic_theme: DynamicThemeSettings,
|
||||
#[serde(default = "default_model_settings")]
|
||||
pub models: ModelSettings,
|
||||
/// iOS-only distribution policy (profile + capability overrides).
|
||||
///
|
||||
/// NOTE: This field is intentionally stored as raw JSON to ensure:
|
||||
/// - desktop builds can load settings exported from iOS even if the policy schema changes
|
||||
/// - iOS builds can validate the schema strictly at runtime (fail-fast) without forcing
|
||||
/// non-iOS platforms to parse/apply it.
|
||||
#[serde(default)]
|
||||
pub ios_policy: Option<Value>,
|
||||
}
|
||||
|
||||
impl Default for TauriTavernSettings {
|
||||
|
|
@ -222,6 +246,7 @@ impl Default for TauriTavernSettings {
|
|||
dev: DevLoggingSettings::default(),
|
||||
dynamic_theme: DynamicThemeSettings::default(),
|
||||
models: default_model_settings(),
|
||||
ios_policy: default_ios_policy_seed(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,44 @@ impl ChatCompletionSource {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAi => "openai",
|
||||
Self::OpenRouter => "openrouter",
|
||||
Self::Custom => "custom",
|
||||
Self::Claude => "claude",
|
||||
Self::Makersuite => "makersuite",
|
||||
Self::VertexAi => "vertexai",
|
||||
Self::DeepSeek => "deepseek",
|
||||
Self::Cohere => "cohere",
|
||||
Self::Groq => "groq",
|
||||
Self::Moonshot => "moonshot",
|
||||
Self::NanoGpt => "nanogpt",
|
||||
Self::Chutes => "chutes",
|
||||
Self::SiliconFlow => "siliconflow",
|
||||
Self::Zai => "zai",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAi => "OpenAI",
|
||||
Self::OpenRouter => "OpenRouter",
|
||||
Self::Custom => "Custom OpenAI",
|
||||
Self::Claude => "Claude",
|
||||
Self::Makersuite => "Google Gemini",
|
||||
Self::VertexAi => "Google Vertex AI",
|
||||
Self::DeepSeek => "DeepSeek",
|
||||
Self::Cohere => "Cohere",
|
||||
Self::Groq => "Groq",
|
||||
Self::Moonshot => "Moonshot AI",
|
||||
Self::NanoGpt => "NanoGPT",
|
||||
Self::Chutes => "Chutes",
|
||||
Self::SiliconFlow => "SiliconFlow",
|
||||
Self::Zai => "Z.AI (GLM)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
|
|||
|
|
@ -421,29 +421,23 @@ impl ChatCompletionRepository for HttpChatCompletionRepository {
|
|||
source: ChatCompletionSource,
|
||||
config: &ChatCompletionApiConfig,
|
||||
) -> Result<Value, DomainError> {
|
||||
let source_name = source.display_name();
|
||||
|
||||
match source {
|
||||
ChatCompletionSource::OpenAi => openai::list_models(self, config, "OpenAI").await,
|
||||
ChatCompletionSource::OpenRouter => {
|
||||
openai::list_models(self, config, "OpenRouter").await
|
||||
}
|
||||
ChatCompletionSource::Custom => {
|
||||
openai::list_models(self, config, "Custom OpenAI").await
|
||||
}
|
||||
ChatCompletionSource::DeepSeek => openai::list_models(self, config, "DeepSeek").await,
|
||||
ChatCompletionSource::OpenAi
|
||||
| ChatCompletionSource::OpenRouter
|
||||
| ChatCompletionSource::Custom
|
||||
| ChatCompletionSource::DeepSeek
|
||||
| ChatCompletionSource::Groq
|
||||
| ChatCompletionSource::Moonshot
|
||||
| ChatCompletionSource::Chutes
|
||||
| ChatCompletionSource::SiliconFlow
|
||||
| ChatCompletionSource::Zai => openai::list_models(self, config, source_name).await,
|
||||
ChatCompletionSource::Cohere => cohere::list_models(self, config).await,
|
||||
ChatCompletionSource::Groq => openai::list_models(self, config, "Groq").await,
|
||||
ChatCompletionSource::Moonshot => {
|
||||
openai::list_models(self, config, "Moonshot AI").await
|
||||
}
|
||||
ChatCompletionSource::NanoGpt => {
|
||||
openai::list_models_with_path(self, config, "NanoGPT", "/models?detailed=true")
|
||||
openai::list_models_with_path(self, config, source_name, "/models?detailed=true")
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::Chutes => openai::list_models(self, config, "Chutes").await,
|
||||
ChatCompletionSource::SiliconFlow => {
|
||||
openai::list_models(self, config, "SiliconFlow").await
|
||||
}
|
||||
ChatCompletionSource::Zai => openai::list_models(self, config, "Z.AI (GLM)").await,
|
||||
ChatCompletionSource::Claude => claude::list_models(self, config).await,
|
||||
ChatCompletionSource::Makersuite => makersuite::list_models(self, config).await,
|
||||
ChatCompletionSource::VertexAi => vertexai::list_models(self, config).await,
|
||||
|
|
@ -457,12 +451,19 @@ impl ChatCompletionRepository for HttpChatCompletionRepository {
|
|||
endpoint_path: &str,
|
||||
payload: &Value,
|
||||
) -> Result<Value, DomainError> {
|
||||
let source_name = source.display_name();
|
||||
|
||||
match source {
|
||||
ChatCompletionSource::OpenAi => {
|
||||
openai::generate(self, config, endpoint_path, payload, "OpenAI").await
|
||||
}
|
||||
ChatCompletionSource::OpenRouter => {
|
||||
openai::generate(self, config, endpoint_path, payload, "OpenRouter").await
|
||||
ChatCompletionSource::OpenAi
|
||||
| ChatCompletionSource::OpenRouter
|
||||
| ChatCompletionSource::DeepSeek
|
||||
| ChatCompletionSource::Groq
|
||||
| ChatCompletionSource::Moonshot
|
||||
| ChatCompletionSource::NanoGpt
|
||||
| ChatCompletionSource::Chutes
|
||||
| ChatCompletionSource::SiliconFlow
|
||||
| ChatCompletionSource::Zai => {
|
||||
openai::generate(self, config, endpoint_path, payload, source_name).await
|
||||
}
|
||||
ChatCompletionSource::Custom => {
|
||||
if endpoint_path == "/responses" {
|
||||
|
|
@ -494,35 +495,14 @@ impl ChatCompletionRepository for HttpChatCompletionRepository {
|
|||
)
|
||||
.await
|
||||
} else {
|
||||
openai::generate(self, config, endpoint_path, payload, "Custom OpenAI").await
|
||||
openai::generate(self, config, endpoint_path, payload, source_name).await
|
||||
}
|
||||
}
|
||||
ChatCompletionSource::DeepSeek => {
|
||||
openai::generate(self, config, endpoint_path, payload, "DeepSeek").await
|
||||
}
|
||||
ChatCompletionSource::Cohere => {
|
||||
cohere::generate(self, config, endpoint_path, payload).await
|
||||
}
|
||||
ChatCompletionSource::Groq => {
|
||||
openai::generate(self, config, endpoint_path, payload, "Groq").await
|
||||
}
|
||||
ChatCompletionSource::Moonshot => {
|
||||
openai::generate(self, config, endpoint_path, payload, "Moonshot AI").await
|
||||
}
|
||||
ChatCompletionSource::NanoGpt => {
|
||||
openai::generate(self, config, endpoint_path, payload, "NanoGPT").await
|
||||
}
|
||||
ChatCompletionSource::Chutes => {
|
||||
openai::generate(self, config, endpoint_path, payload, "Chutes").await
|
||||
}
|
||||
ChatCompletionSource::SiliconFlow => {
|
||||
openai::generate(self, config, endpoint_path, payload, "SiliconFlow").await
|
||||
}
|
||||
ChatCompletionSource::Zai => {
|
||||
openai::generate(self, config, endpoint_path, payload, "Z.AI (GLM)").await
|
||||
}
|
||||
ChatCompletionSource::Claude => {
|
||||
claude::generate(self, config, endpoint_path, payload, "Claude", true).await
|
||||
claude::generate(self, config, endpoint_path, payload, source_name, true).await
|
||||
}
|
||||
ChatCompletionSource::Makersuite => {
|
||||
makersuite::generate(self, config, endpoint_path, payload).await
|
||||
|
|
@ -542,26 +522,24 @@ impl ChatCompletionRepository for HttpChatCompletionRepository {
|
|||
sender: ChatCompletionStreamSender,
|
||||
cancel: ChatCompletionCancelReceiver,
|
||||
) -> Result<(), DomainError> {
|
||||
let source_name = source.display_name();
|
||||
|
||||
match source {
|
||||
ChatCompletionSource::OpenAi => {
|
||||
ChatCompletionSource::OpenAi
|
||||
| ChatCompletionSource::OpenRouter
|
||||
| ChatCompletionSource::DeepSeek
|
||||
| ChatCompletionSource::Groq
|
||||
| ChatCompletionSource::Moonshot
|
||||
| ChatCompletionSource::NanoGpt
|
||||
| ChatCompletionSource::Chutes
|
||||
| ChatCompletionSource::SiliconFlow
|
||||
| ChatCompletionSource::Zai => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"OpenAI",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::OpenRouter => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"OpenRouter",
|
||||
source_name,
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
|
|
@ -608,107 +586,23 @@ impl ChatCompletionRepository for HttpChatCompletionRepository {
|
|||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"Custom OpenAI",
|
||||
source_name,
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
ChatCompletionSource::DeepSeek => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"DeepSeek",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::Cohere => {
|
||||
cohere::generate_stream(self, config, endpoint_path, payload, sender, cancel).await
|
||||
}
|
||||
ChatCompletionSource::Groq => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"Groq",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::Moonshot => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"Moonshot AI",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::NanoGpt => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"NanoGPT",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::Chutes => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"Chutes",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::SiliconFlow => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"SiliconFlow",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::Zai => {
|
||||
openai::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"Z.AI (GLM)",
|
||||
sender,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ChatCompletionSource::Claude => {
|
||||
claude::generate_stream(
|
||||
self,
|
||||
config,
|
||||
endpoint_path,
|
||||
payload,
|
||||
"Claude",
|
||||
source_name,
|
||||
sender,
|
||||
cancel,
|
||||
true,
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ impl ChatCompletionRepository for LoggingChatCompletionRepository {
|
|||
timestamp_ms: started_at_ms,
|
||||
level,
|
||||
ok,
|
||||
source: source.to_string(),
|
||||
source: source.key().to_string(),
|
||||
model,
|
||||
endpoint,
|
||||
duration_ms,
|
||||
|
|
@ -495,7 +495,7 @@ impl ChatCompletionRepository for LoggingChatCompletionRepository {
|
|||
timestamp_ms: started_at_ms,
|
||||
level,
|
||||
ok,
|
||||
source: source.to_string(),
|
||||
source: source.key().to_string(),
|
||||
model,
|
||||
endpoint,
|
||||
duration_ms,
|
||||
|
|
@ -523,28 +523,6 @@ fn stream_readable_source(
|
|||
source
|
||||
}
|
||||
|
||||
impl ToString for ChatCompletionSource {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
ChatCompletionSource::OpenAi => "openai",
|
||||
ChatCompletionSource::OpenRouter => "openrouter",
|
||||
ChatCompletionSource::Custom => "custom",
|
||||
ChatCompletionSource::Claude => "claude",
|
||||
ChatCompletionSource::Makersuite => "makersuite",
|
||||
ChatCompletionSource::VertexAi => "vertexai",
|
||||
ChatCompletionSource::DeepSeek => "deepseek",
|
||||
ChatCompletionSource::Cohere => "cohere",
|
||||
ChatCompletionSource::Groq => "groq",
|
||||
ChatCompletionSource::Moonshot => "moonshot",
|
||||
ChatCompletionSource::NanoGpt => "nanogpt",
|
||||
ChatCompletionSource::Chutes => "chutes",
|
||||
ChatCompletionSource::SiliconFlow => "siliconflow",
|
||||
ChatCompletionSource::Zai => "zai",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamReadableCollector {
|
||||
source: ChatCompletionSource,
|
||||
buffer: String,
|
||||
|
|
|
|||
|
|
@ -150,10 +150,24 @@ pub async fn run() {
|
|||
app.manage(user_dirs.clone());
|
||||
|
||||
let tauritavern_settings = load_tauritavern_settings(&runtime_paths.data_root)?;
|
||||
let ios_policy = crate::domain::ios_policy::resolve_ios_policy_activation_report(
|
||||
crate::domain::ios_policy::IosPolicyScope::for_current_platform(),
|
||||
tauritavern_settings.ios_policy.as_ref(),
|
||||
)?;
|
||||
let thumbnail_policy = std::sync::Arc::new(ThumbnailEndpointPolicy::new(
|
||||
tauritavern_settings.avatar_persona_original_images_enabled,
|
||||
));
|
||||
app.manage(thumbnail_policy.clone());
|
||||
|
||||
if ios_policy.scope == crate::domain::ios_policy::IosPolicyScope::Ios
|
||||
&& tauritavern_settings.request_proxy.enabled
|
||||
&& !ios_policy.capabilities.network.request_proxy
|
||||
{
|
||||
return Err(Box::new(crate::domain::errors::DomainError::InvalidData(
|
||||
"iOS policy disabled capability: network.request_proxy".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
http_client_pool.apply_request_proxy_settings(&tauritavern_settings.request_proxy)?;
|
||||
llm_api_log_store.apply_settings(tauritavern_settings.dev.effective_llm_api_keep());
|
||||
let _main_window =
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ pub async fn get_bootstrap_snapshot(
|
|||
)?;
|
||||
|
||||
Ok(BootstrapSnapshotDto {
|
||||
ios_policy: app_state.ios_policy.clone(),
|
||||
settings,
|
||||
characters,
|
||||
groups,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use tauri::State;
|
|||
|
||||
use crate::app::AppState;
|
||||
use crate::infrastructure::http_client_pool::{HttpClientPool, HttpClientProfile};
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::commands::helpers::{
|
||||
ensure_ios_policy_allows, log_command, map_command_error,
|
||||
};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -48,10 +50,17 @@ pub struct ExternalImportDownloadResult {
|
|||
#[tauri::command]
|
||||
pub async fn download_external_import_url(
|
||||
url: String,
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
http_clients: State<'_, Arc<HttpClientPool>>,
|
||||
) -> Result<ExternalImportDownloadResult, CommandError> {
|
||||
log_command("download_external_import_url");
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state.ios_policy.capabilities.content.external_import,
|
||||
"content.external_import",
|
||||
)?;
|
||||
|
||||
let parsed_url = reqwest::Url::parse(url.trim())
|
||||
.map_err(|_| CommandError::BadRequest("Invalid import URL".to_string()))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ use tauri::State;
|
|||
|
||||
use crate::app::AppState;
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::ios_policy::IosPolicyScope;
|
||||
use crate::domain::models::extension::{
|
||||
Extension, ExtensionInstallResult, ExtensionUpdateResult, ExtensionVersion,
|
||||
};
|
||||
use crate::infrastructure::logging::logger;
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::commands::helpers::{
|
||||
ensure_ios_policy_allows, log_command, map_command_error,
|
||||
};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -17,11 +20,45 @@ pub async fn get_extensions(
|
|||
) -> Result<Vec<Extension>, CommandError> {
|
||||
log_command("get_extensions");
|
||||
|
||||
app_state
|
||||
let mut extensions = app_state
|
||||
.extension_service
|
||||
.get_extensions()
|
||||
.await
|
||||
.map_err(map_command_error("Failed to get extensions"))
|
||||
.map_err(map_command_error("Failed to get extensions"))?;
|
||||
|
||||
if app_state.ios_policy.scope == IosPolicyScope::Ios {
|
||||
let allow_third_party = app_state
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.extensions
|
||||
.third_party_execution;
|
||||
let system_allowlist = &app_state
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.extensions
|
||||
.system_allowlist;
|
||||
|
||||
let before = extensions.len();
|
||||
extensions.retain(|extension| match extension.extension_type {
|
||||
crate::domain::models::extension::ExtensionType::System => {
|
||||
system_allowlist.allows(&extension.name)
|
||||
}
|
||||
crate::domain::models::extension::ExtensionType::Local
|
||||
| crate::domain::models::extension::ExtensionType::Global => allow_third_party,
|
||||
});
|
||||
|
||||
let filtered = before.saturating_sub(extensions.len());
|
||||
if filtered > 0 {
|
||||
tracing::info!(
|
||||
filtered,
|
||||
before,
|
||||
profile = ?app_state.ios_policy.profile,
|
||||
"iOS policy filtered extensions during discovery"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -33,6 +70,16 @@ pub async fn install_extension(
|
|||
) -> Result<ExtensionInstallResult, CommandError> {
|
||||
log_command(format!("install_extension {}", url));
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.extensions
|
||||
.third_party_management,
|
||||
"extensions.third_party_management",
|
||||
)?;
|
||||
|
||||
app_state
|
||||
.extension_service
|
||||
.install_extension(&url, global, branch)
|
||||
|
|
@ -56,6 +103,16 @@ pub async fn update_extension(
|
|||
) -> Result<ExtensionUpdateResult, CommandError> {
|
||||
log_command(format!("update_extension {}", extension_name));
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.extensions
|
||||
.third_party_management,
|
||||
"extensions.third_party_management",
|
||||
)?;
|
||||
|
||||
app_state
|
||||
.extension_service
|
||||
.update_extension(&extension_name, global)
|
||||
|
|
@ -71,6 +128,16 @@ pub async fn delete_extension(
|
|||
) -> Result<(), CommandError> {
|
||||
log_command(format!("delete_extension {}", extension_name));
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.extensions
|
||||
.third_party_management,
|
||||
"extensions.third_party_management",
|
||||
)?;
|
||||
|
||||
app_state
|
||||
.extension_service
|
||||
.delete_extension(&extension_name, global)
|
||||
|
|
@ -86,6 +153,16 @@ pub async fn get_extension_version(
|
|||
) -> Result<ExtensionVersion, CommandError> {
|
||||
log_command(format!("get_extension_version {}", extension_name));
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.extensions
|
||||
.third_party_management,
|
||||
"extensions.third_party_management",
|
||||
)?;
|
||||
|
||||
app_state
|
||||
.extension_service
|
||||
.get_extension_version(&extension_name, global)
|
||||
|
|
@ -105,6 +182,16 @@ pub async fn move_extension(
|
|||
extension_name, source, destination
|
||||
));
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state
|
||||
.ios_policy
|
||||
.capabilities
|
||||
.extensions
|
||||
.third_party_management,
|
||||
"extensions.third_party_management",
|
||||
)?;
|
||||
|
||||
app_state
|
||||
.extension_service
|
||||
.move_extension(&extension_name, &source, &destination)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,20 @@ use std::fmt::Display;
|
|||
use crate::infrastructure::logging::logger;
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
pub fn ensure_ios_policy_allows(
|
||||
ios_policy: &crate::domain::ios_policy::IosPolicyActivationReport,
|
||||
allowed: bool,
|
||||
capability: &'static str,
|
||||
) -> Result<(), CommandError> {
|
||||
if ios_policy.scope == crate::domain::ios_policy::IosPolicyScope::Ios && !allowed {
|
||||
return Err(CommandError::Unauthorized(format!(
|
||||
"iOS policy disabled capability: {capability}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_command(command: impl AsRef<str>) {
|
||||
logger::debug(&format!("Command: {}", command.as_ref()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,25 @@ use tauri::State;
|
|||
|
||||
use crate::app::AppState;
|
||||
use crate::domain::models::lan_sync::{LanSyncPairedDevice, LanSyncStatus, LanSyncSyncMode};
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::commands::helpers::{
|
||||
ensure_ios_policy_allows, log_command, map_command_error,
|
||||
};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
fn ensure_lan_sync_allowed(app_state: &AppState) -> Result<(), CommandError> {
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state.ios_policy.capabilities.sync.lan,
|
||||
"sync.lan",
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn lan_sync_get_status(
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<LanSyncStatus, CommandError> {
|
||||
log_command("lan_sync_get_status");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -26,6 +37,7 @@ pub async fn lan_sync_start_server(
|
|||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<LanSyncStatus, CommandError> {
|
||||
log_command("lan_sync_start_server");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -37,6 +49,7 @@ pub async fn lan_sync_start_server(
|
|||
#[tauri::command]
|
||||
pub async fn lan_sync_stop_server(app_state: State<'_, Arc<AppState>>) -> Result<(), CommandError> {
|
||||
log_command("lan_sync_stop_server");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -59,6 +72,7 @@ pub async fn lan_sync_enable_pairing(
|
|||
address: Option<String>,
|
||||
) -> Result<LanSyncPairingInfoDto, CommandError> {
|
||||
log_command("lan_sync_enable_pairing");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -79,6 +93,7 @@ pub async fn lan_sync_get_pairing_info(
|
|||
address: String,
|
||||
) -> Result<LanSyncPairingInfoDto, CommandError> {
|
||||
log_command("lan_sync_get_pairing_info");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -120,6 +135,7 @@ pub async fn lan_sync_request_pairing(
|
|||
pair_uri: String,
|
||||
) -> Result<LanSyncPairedDeviceDto, CommandError> {
|
||||
log_command("lan_sync_request_pairing");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -136,6 +152,7 @@ pub async fn lan_sync_confirm_pairing(
|
|||
accept: bool,
|
||||
) -> Result<(), CommandError> {
|
||||
log_command("lan_sync_confirm_pairing");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -149,6 +166,7 @@ pub async fn lan_sync_list_devices(
|
|||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<Vec<LanSyncPairedDeviceDto>, CommandError> {
|
||||
log_command("lan_sync_list_devices");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -169,6 +187,7 @@ pub async fn lan_sync_remove_device(
|
|||
device_id: String,
|
||||
) -> Result<(), CommandError> {
|
||||
log_command("lan_sync_remove_device");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -183,6 +202,7 @@ pub async fn lan_sync_sync_from_device(
|
|||
device_id: String,
|
||||
) -> Result<(), CommandError> {
|
||||
log_command("lan_sync_sync_from_device");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -197,6 +217,7 @@ pub async fn lan_sync_push_to_device(
|
|||
device_id: String,
|
||||
) -> Result<(), CommandError> {
|
||||
log_command("lan_sync_push_to_device");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -212,6 +233,7 @@ pub async fn lan_sync_set_sync_mode(
|
|||
persist: bool,
|
||||
) -> Result<(), CommandError> {
|
||||
log_command("lan_sync_set_sync_mode");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state
|
||||
.lan_sync_service
|
||||
|
|
@ -225,6 +247,7 @@ pub async fn lan_sync_clear_sync_mode_override(
|
|||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<(), CommandError> {
|
||||
log_command("lan_sync_clear_sync_mode_override");
|
||||
ensure_lan_sync_allowed(&app_state)?;
|
||||
|
||||
app_state.lan_sync_service.clear_sync_mode_override().await;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ use crate::application::dto::settings_dto::{
|
|||
use crate::domain::models::settings::RequestProxySettings;
|
||||
use crate::infrastructure::http_client_pool::HttpClientPool;
|
||||
use crate::infrastructure::logging::llm_api_logs::LlmApiLogStore;
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::commands::helpers::{
|
||||
ensure_ios_policy_allows, log_command, map_command_error,
|
||||
};
|
||||
use crate::presentation::errors::CommandError;
|
||||
use crate::presentation::web_resources::thumbnail_endpoint::ThumbnailEndpointPolicy;
|
||||
|
||||
|
|
@ -42,6 +44,14 @@ pub async fn update_tauritavern_settings(
|
|||
let request_proxy_settings: Option<RequestProxySettings> =
|
||||
dto.request_proxy.clone().map(Into::into);
|
||||
if let Some(settings) = request_proxy_settings.as_ref() {
|
||||
if settings.enabled {
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state.ios_policy.capabilities.network.request_proxy,
|
||||
"network.request_proxy",
|
||||
)?;
|
||||
}
|
||||
|
||||
HttpClientPool::validate_request_proxy_settings(settings)
|
||||
.map_err(map_command_error("Invalid request proxy settings"))?;
|
||||
}
|
||||
|
|
@ -82,6 +92,14 @@ pub async fn update_tauritavern_settings(
|
|||
let request_proxy_settings: Option<RequestProxySettings> =
|
||||
dto.request_proxy.clone().map(Into::into);
|
||||
if let Some(settings) = request_proxy_settings.as_ref() {
|
||||
if settings.enabled {
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state.ios_policy.capabilities.network.request_proxy,
|
||||
"network.request_proxy",
|
||||
)?;
|
||||
}
|
||||
|
||||
HttpClientPool::validate_request_proxy_settings(settings)
|
||||
.map_err(map_command_error("Invalid request proxy settings"))?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ use tauri::State;
|
|||
|
||||
use crate::app::AppState;
|
||||
use crate::application::dto::stable_diffusion_dto::SdRouteResponseDto;
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::commands::helpers::{
|
||||
ensure_ios_policy_allows, log_command, map_command_error,
|
||||
};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -19,6 +21,12 @@ pub async fn sd_handle(
|
|||
validate_request_id(&request_id)?;
|
||||
log_command(format!("sd_handle {} {}", request_id, path));
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state.ios_policy.capabilities.ai.image_generation,
|
||||
"ai.image_generation",
|
||||
)?;
|
||||
|
||||
app_state
|
||||
.stable_diffusion_service
|
||||
.handle_request(&request_id, path, body)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ use tauri::State;
|
|||
|
||||
use crate::app::AppState;
|
||||
use crate::domain::models::update::UpdateCheckResult;
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::commands::helpers::{
|
||||
ensure_ios_policy_allows, log_command, map_command_error,
|
||||
};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -13,6 +15,12 @@ pub async fn check_for_update(
|
|||
) -> Result<UpdateCheckResult, CommandError> {
|
||||
log_command("check_for_update");
|
||||
|
||||
ensure_ios_policy_allows(
|
||||
&app_state.ios_policy,
|
||||
app_state.ios_policy.capabilities.updates.manual_check,
|
||||
"updates.manual_check",
|
||||
)?;
|
||||
|
||||
app_state
|
||||
.update_service
|
||||
.check_for_update()
|
||||
|
|
|
|||
|
|
@ -2341,6 +2341,7 @@
|
|||
"When enabled, previews in the newest message automatically replace the current last message view.": "启用后,最新消息中的预览会自动替换当前最后一条消息视图。",
|
||||
"Data Migration": "数据迁移",
|
||||
"Import a SillyTavern data zip archive and migrate it to TauriTavern.": "导入 SillyTavern 的 data 压缩包并迁移到 TauriTavern。",
|
||||
"Import a TauriTavern data zip archive from another device and migrate it to this TauriTavern.": "从其他设备导入 TauriTavern 数据压缩包并迁移到当前 TauriTavern。",
|
||||
"Imported user handles are normalized to default-user.": "导入的用户标识会统一规范为 default-user。",
|
||||
"Import Data Zip": "导入数据压缩包",
|
||||
"Export Current Data": "导出当前数据",
|
||||
|
|
|
|||
|
|
@ -2791,6 +2791,7 @@
|
|||
"When enabled, previews in the newest message automatically replace the current last message view.": "啟用後,最新訊息中的預覽會自動取代目前最後一則訊息的顯示。",
|
||||
"Data Migration": "資料遷移",
|
||||
"Import a SillyTavern data zip archive and migrate it to TauriTavern.": "匯入 SillyTavern 的 data 壓縮檔並遷移至 TauriTavern。",
|
||||
"Import a TauriTavern data zip archive from another device and migrate it to this TauriTavern.": "從其他裝置匯入 TauriTavern 的 data 壓縮檔並遷移至此 TauriTavern。",
|
||||
"Imported user handles are normalized to default-user.": "匯入的使用者代號會統一正規化為 default-user。",
|
||||
"Import Data Zip": "匯入資料壓縮檔",
|
||||
"Export Current Data": "匯出目前資料",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ import {
|
|||
focusChatInput,
|
||||
installChatInputFocusKeeper,
|
||||
} from './scripts/chat-input-focus.js';
|
||||
import { getActiveIosPolicyCapabilities } from './scripts/tauritavern/ios-policy.js';
|
||||
import { applyIosPolicyUiProjection } from './scripts/tauritavern/ios-policy-ui.js';
|
||||
|
||||
import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods } from './scripts/RossAscends-mods.js';
|
||||
import { userStatsHandler, statMesProcess, initStats } from './scripts/stats.js';
|
||||
|
|
@ -907,6 +909,15 @@ async function firstLoadInit() {
|
|||
const clientVersionPromise = getClientVersion();
|
||||
await initSecrets();
|
||||
const bootstrapSnapshot = await bootstrapPromise;
|
||||
if (bootstrapSnapshot?.ios_policy?.scope === 'ios') {
|
||||
if (!window.__TAURITAVERN__ || typeof window.__TAURITAVERN__ !== 'object') {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Host ABI is unavailable (window.__TAURITAVERN__).');
|
||||
}
|
||||
window.__TAURITAVERN__.iosPolicy = bootstrapSnapshot.ios_policy;
|
||||
} else if (window.__TAURITAVERN__ && typeof window.__TAURITAVERN__ === 'object') {
|
||||
window.__TAURITAVERN__.iosPolicy = bootstrapSnapshot.ios_policy;
|
||||
}
|
||||
applyIosPolicyUiProjection();
|
||||
const extensionsEnabled = Boolean(bootstrapSnapshot.settings?.enable_extensions)
|
||||
&& bootstrapSnapshot.settings?.result != 'file not find'
|
||||
&& Boolean(bootstrapSnapshot.settings?.settings);
|
||||
|
|
@ -8475,6 +8486,12 @@ async function applySettingsSnapshot(data) {
|
|||
settings.main_api = 'openai';
|
||||
}
|
||||
|
||||
const iosCaps = getActiveIosPolicyCapabilities();
|
||||
if (iosCaps && iosCaps.llm?.text_completions?.enabled === false && settings.main_api === 'textgenerationwebui') {
|
||||
settings.main_api = 'openai';
|
||||
globalThis.toastr?.warning?.('Text Completion is disabled by your iOS policy. Switched to Chat Completion.');
|
||||
}
|
||||
|
||||
main_api = settings.main_api;
|
||||
$('#main_api').val(main_api);
|
||||
$(`#main_api option[value=${main_api}]`).attr('selected', 'true');
|
||||
|
|
@ -13130,6 +13147,12 @@ jQuery(async function () {
|
|||
});
|
||||
|
||||
$(document).on('click', '.external_import_button, #external_import_button', async () => {
|
||||
const iosCaps = getActiveIosPolicyCapabilities();
|
||||
if (iosCaps && iosCaps.content?.external_import === false) {
|
||||
globalThis.toastr?.error?.('External import is disabled by your iOS policy.');
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await renderTemplateAsync('importCharacters');
|
||||
const input = await callGenericPopup(html, POPUP_TYPE.INPUT, '', { allowVerticalScrolling: true, wider: true, okButton: $('#popup_template').attr('popup-button-import'), rows: 4 });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { t } from '../../i18n.js';
|
||||
import { t, translate } from '../../i18n.js';
|
||||
import { Popup } from '../../popup.js';
|
||||
import { isAndroidRuntime, isIosRuntime } from '../../util/mobile-runtime.js';
|
||||
import { getActiveIosPolicyActivationReport } from '../../tauritavern/ios-policy.js';
|
||||
|
||||
const MODULE_NAME = 'data-migration';
|
||||
const JOB_POLL_INTERVAL_MS = 1200;
|
||||
|
|
@ -9,6 +10,9 @@ const TERMINAL_JOB_STATES = new Set(['completed', 'failed', 'cancelled']);
|
|||
const PREPARING_PROGRESS_INTERVAL_MS = 320;
|
||||
const PREPARING_PROGRESS_MAX_BEFORE_JOB = 96;
|
||||
|
||||
const SILLYTAVERN_MIGRATION_COPY_KEY = 'Import a SillyTavern data zip archive and migrate it to TauriTavern.';
|
||||
const TAURITAVERN_MIGRATION_COPY_KEY = 'Import a TauriTavern data zip archive from another device and migrate it to this TauriTavern.';
|
||||
|
||||
const jobState = {
|
||||
jobId: '',
|
||||
starting: false,
|
||||
|
|
@ -592,6 +596,17 @@ jQuery(async () => {
|
|||
$('#data_migration_container').append(html);
|
||||
refreshControls();
|
||||
|
||||
const iosPolicy = getActiveIosPolicyActivationReport();
|
||||
if (iosPolicy?.profile === 'ios_external_beta') {
|
||||
const description = document.querySelector(`#data_migration_settings .extensions_info[data-i18n="${CSS.escape(SILLYTAVERN_MIGRATION_COPY_KEY)}"]`);
|
||||
if (!(description instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Data migration description element not found');
|
||||
}
|
||||
|
||||
description.dataset.i18n = TAURITAVERN_MIGRATION_COPY_KEY;
|
||||
description.textContent = translate(TAURITAVERN_MIGRATION_COPY_KEY);
|
||||
}
|
||||
|
||||
$('#data_migration_import_button').on('click', onImportButtonClick);
|
||||
$('#data_migration_import_input').on('change', onImportInputChange);
|
||||
$('#data_migration_export_button').on('click', onExportClick);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ let startupUpdatePopupShown = false;
|
|||
let tauriTavernSettingsCache = null;
|
||||
let tauriTavernSettingsPromise = null;
|
||||
|
||||
function resolveIosUpdateCapabilities() {
|
||||
const report = window.__TAURITAVERN__?.iosPolicy;
|
||||
if (!report || report.scope !== 'ios') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return report.capabilities?.updates ?? null;
|
||||
}
|
||||
|
||||
function localize(key, fallback) {
|
||||
return translate(fallback, key);
|
||||
}
|
||||
|
|
@ -244,6 +253,17 @@ async function onCheckUpdateClick() {
|
|||
return;
|
||||
}
|
||||
|
||||
const updateCaps = resolveIosUpdateCapabilities();
|
||||
if (updateCaps && updateCaps.manual_check === false) {
|
||||
globalThis.toastr?.error?.(
|
||||
localize(
|
||||
'ttv_version.check_update_disabled',
|
||||
'Manual update checking is disabled by your iOS policy.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = $('#tauritavern_check_update');
|
||||
const $icon = $btn.find('i');
|
||||
const $text = $btn.find('span');
|
||||
|
|
@ -435,6 +455,12 @@ async function runStartupUpdateCheck() {
|
|||
}
|
||||
|
||||
eventSource.once(event_types.APP_READY, () => {
|
||||
const updateCaps = resolveIosUpdateCapabilities();
|
||||
if (updateCaps && updateCaps.startup_check === false) {
|
||||
console.debug('Startup update check skipped: disabled by iOS policy');
|
||||
return;
|
||||
}
|
||||
|
||||
void runStartupUpdateCheck();
|
||||
});
|
||||
|
||||
|
|
@ -447,7 +473,24 @@ jQuery(async () => {
|
|||
const html = await renderExtensionTemplateAsync(MODULE_NAME, 'settings', LINKS);
|
||||
container.append(html);
|
||||
$('#tauritavern_export_debug_bundle').on('click', () => void onExportDebugBundleClick());
|
||||
$('#tauritavern_check_update').on('click', onCheckUpdateClick);
|
||||
const updateCaps = resolveIosUpdateCapabilities();
|
||||
if (updateCaps && updateCaps.manual_check === false) {
|
||||
$('#tauritavern_check_update').remove();
|
||||
|
||||
const compatRow = document.getElementById('ttv-compat-row');
|
||||
if (!(compatRow instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] ttv-compat-row not found');
|
||||
}
|
||||
compatRow.hidden = true;
|
||||
|
||||
const discordLink = document.getElementById('ttv-discord-link');
|
||||
if (!(discordLink instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] ttv-discord-link not found');
|
||||
}
|
||||
discordLink.hidden = true;
|
||||
} else {
|
||||
$('#tauritavern_check_update').on('click', onCheckUpdateClick);
|
||||
}
|
||||
$('#tauritavern_update_dismiss').on('click', hideUpdateResult);
|
||||
container.on('click', 'a[target="_blank"]', onExternalLinkClick);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<span class="ttv-label" data-i18n="ttv_version.app_version_label">App Version</span>
|
||||
<strong id="tauritavern_version_number">Loading...</strong>
|
||||
</div>
|
||||
<div class="ttv-row">
|
||||
<div id="ttv-compat-row" class="ttv-row">
|
||||
<span class="ttv-label" data-i18n="ttv_version.compat_label">Compatibility Baseline</span>
|
||||
<strong id="tauritavern_compat_version">Loading...</strong>
|
||||
</div>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<i class="fa-brands fa-github"></i>
|
||||
<span data-i18n="ttv_version.github_repository">GitHub Repository</span>
|
||||
</a>
|
||||
<a class="menu_button menu_button_icon" href="{{discordUrl}}" target="_blank" rel="noopener noreferrer">
|
||||
<a id="ttv-discord-link" class="menu_button menu_button_icon" href="{{discordUrl}}" target="_blank" rel="noopener noreferrer">
|
||||
<i class="fa-brands fa-discord"></i>
|
||||
<span data-i18n="ttv_version.discord_updates">Discord Update Channel</span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
} from '../script.js';
|
||||
import { getGroupNames, selected_group } from './group-chats.js';
|
||||
import { extension_prompt_roles, extension_prompt_types } from './extension-prompts.js';
|
||||
import { allowlistSettingAllows, getActiveIosPolicyCapabilities } from './tauritavern/ios-policy.js';
|
||||
|
||||
import {
|
||||
chatCompletionDefaultPrompts,
|
||||
|
|
@ -4101,6 +4102,60 @@ function syncChatCompletionSourceSelector() {
|
|||
$('#chat_completion_source').find(`option[value="${CSS.escape(value)}"]`).prop('selected', true);
|
||||
}
|
||||
|
||||
function normalizeIosPolicyChatCompletionSourceKey(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized.startsWith('custom_')) {
|
||||
return chat_completion_sources.CUSTOM;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function enforceIosPolicyChatCompletionSourceSelector() {
|
||||
const caps = getActiveIosPolicyCapabilities();
|
||||
if (!caps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const select = document.getElementById('chat_completion_source');
|
||||
if (!(select instanceof HTMLSelectElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Chat completion source selector not found');
|
||||
}
|
||||
|
||||
const allowlist = caps.llm?.chat_completion_sources?.allowlist;
|
||||
const endpointOverridesAllowed = caps.llm?.endpoint_overrides !== false;
|
||||
|
||||
const isAllowed = (value) => {
|
||||
const key = normalizeIosPolicyChatCompletionSourceKey(value);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!endpointOverridesAllowed && key === chat_completion_sources.CUSTOM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowlistSettingAllows(allowlist, key);
|
||||
};
|
||||
|
||||
const currentValue = String(select.value || '').trim();
|
||||
if (currentValue && isAllowed(currentValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = Array.from(select.options)
|
||||
.map((option) => option.value)
|
||||
.find(isAllowed);
|
||||
|
||||
if (!fallback) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] No allowed chat completion source remains after applying policy');
|
||||
}
|
||||
|
||||
if (fallback !== currentValue) {
|
||||
select.value = fallback;
|
||||
globalThis.toastr?.warning?.('Chat Completion Source was reset due to your iOS policy.');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCustomEndpointBaseUrl(value) {
|
||||
const url = String(value || '').trim();
|
||||
return url ? url.replace(/\/+$/, '') : '';
|
||||
|
|
@ -4243,6 +4298,7 @@ function loadOpenAISettings(data, settings) {
|
|||
$('#openrouter_providers_chat').trigger('change');
|
||||
$('#openrouter_quantizations_chat').trigger('change');
|
||||
syncChatCompletionSourceSelector();
|
||||
enforceIosPolicyChatCompletionSourceSelector();
|
||||
$('#chat_completion_source').trigger('change');
|
||||
updateCustomEndpointPreview();
|
||||
}
|
||||
|
|
@ -6688,6 +6744,7 @@ export function initOpenAI() {
|
|||
});
|
||||
|
||||
$('#chat_completion_source').on('change', function () {
|
||||
enforceIosPolicyChatCompletionSourceSelector();
|
||||
cancelStatusCheck('Chat Completion source changed');
|
||||
model_list = [];
|
||||
applyChatCompletionSourceSelection(String($(this).find(':selected').val()));
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js';
|
|||
import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
|
||||
import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
|
||||
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
|
||||
import { getActiveIosPolicyCapabilities } from './tauritavern/ios-policy.js';
|
||||
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
|
||||
import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
|
||||
|
|
@ -232,6 +233,7 @@ function setupConnectAPIMap() {
|
|||
export function initDefaultSlashCommands() {
|
||||
eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);
|
||||
setupConnectAPIMap();
|
||||
const promptInjectionsAllowed = getActiveIosPolicyCapabilities()?.scripting?.prompt_injections !== false;
|
||||
|
||||
async function enableInstructCallback() {
|
||||
$('#instruct_enabled').prop('checked', true).trigger('input').trigger('change');
|
||||
|
|
@ -2496,99 +2498,101 @@ export function initDefaultSlashCommands() {
|
|||
],
|
||||
helpString: t`Trims the text to the end of the last full sentence.`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'inject',
|
||||
returns: t`injection ID`,
|
||||
callback: injectCallback,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'id',
|
||||
description: t`injection ID`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: false,
|
||||
enumProvider: commonEnumProviders.injects,
|
||||
}),
|
||||
new SlashCommandNamedArgument(
|
||||
'position', t`injection position`, [ARGUMENT_TYPE.STRING], false, false, 'after', ['before', 'after', 'chat', 'none'],
|
||||
),
|
||||
new SlashCommandNamedArgument(
|
||||
'depth', t`injection depth`, [ARGUMENT_TYPE.NUMBER], false, false, '4',
|
||||
),
|
||||
new SlashCommandNamedArgument(
|
||||
'scan', t`include injection content into World Info scans`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
|
||||
),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'role',
|
||||
description: t`role for in-chat injections`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: false,
|
||||
enumList: [
|
||||
new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system),
|
||||
new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant),
|
||||
new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user),
|
||||
],
|
||||
}),
|
||||
new SlashCommandNamedArgument(
|
||||
'ephemeral', t`remove injection after generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
|
||||
),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'filter',
|
||||
description: t`if a filter is defined, an injection will only be performed if the closure returns true`,
|
||||
typeList: [ARGUMENT_TYPE.CLOSURE],
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
t`text`, [ARGUMENT_TYPE.STRING], false,
|
||||
),
|
||||
],
|
||||
helpString: t`Injects a text into the LLM prompt for the current chat. Requires a unique injection ID (will be auto-generated if not provided). Positions: "before" main prompt, "after" main prompt, in-"chat", hidden with "none" (default: after). Depth: injection depth for the prompt (default: 4). Role: role for in-chat injections (default: system). Scan: include injection content into World Info scans (default: false). Hidden injects in "none" position are not inserted into the prompt but can be used for triggering WI entries. Returns the injection ID.`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'listinjects',
|
||||
callback: listInjectsCallback,
|
||||
helpString: t`Lists all script injections for the current chat. Displays injects in a popup by default. Use the <code>return</code> argument to change the return type.`,
|
||||
returns: t`Optionally the JSON object of script injections`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'return',
|
||||
description: t`The way how you want the return value to be provided`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: 'popup-html',
|
||||
enumList: slashCommandReturnHelper.enumList({ allowPipe: false, allowObject: true, allowChat: true, allowPopup: true, allowTextVersion: false }),
|
||||
forceEnum: true,
|
||||
}),
|
||||
// TODO remove some day
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'format',
|
||||
description: t`!!! DEPRECATED - use "return" instead !!! output format`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
forceEnum: true,
|
||||
enumList: [
|
||||
new SlashCommandEnumValue('popup', t`Show injects in a popup.`, enumTypes.enum, enumIcons.default),
|
||||
new SlashCommandEnumValue('chat', t`Post a system message to the chat.`, enumTypes.enum, enumIcons.default),
|
||||
new SlashCommandEnumValue('none', t`Just return the injects as a JSON object.`, enumTypes.enum, enumIcons.default),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'flushinject',
|
||||
aliases: ['flushinjects'],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: t`injection ID or a variable name pointing to ID`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: '',
|
||||
enumProvider: commonEnumProviders.injects,
|
||||
}),
|
||||
],
|
||||
callback: flushInjectsCallback,
|
||||
helpString: t`Removes a script injection for the current chat. If no ID is provided, removes all script injections.`,
|
||||
}));
|
||||
if (promptInjectionsAllowed) {
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'inject',
|
||||
returns: t`injection ID`,
|
||||
callback: injectCallback,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'id',
|
||||
description: t`injection ID`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: false,
|
||||
enumProvider: commonEnumProviders.injects,
|
||||
}),
|
||||
new SlashCommandNamedArgument(
|
||||
'position', t`injection position`, [ARGUMENT_TYPE.STRING], false, false, 'after', ['before', 'after', 'chat', 'none'],
|
||||
),
|
||||
new SlashCommandNamedArgument(
|
||||
'depth', t`injection depth`, [ARGUMENT_TYPE.NUMBER], false, false, '4',
|
||||
),
|
||||
new SlashCommandNamedArgument(
|
||||
'scan', t`include injection content into World Info scans`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
|
||||
),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'role',
|
||||
description: t`role for in-chat injections`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: false,
|
||||
enumList: [
|
||||
new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system),
|
||||
new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant),
|
||||
new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user),
|
||||
],
|
||||
}),
|
||||
new SlashCommandNamedArgument(
|
||||
'ephemeral', t`remove injection after generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
|
||||
),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'filter',
|
||||
description: t`if a filter is defined, an injection will only be performed if the closure returns true`,
|
||||
typeList: [ARGUMENT_TYPE.CLOSURE],
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
t`text`, [ARGUMENT_TYPE.STRING], false,
|
||||
),
|
||||
],
|
||||
helpString: t`Injects a text into the LLM prompt for the current chat. Requires a unique injection ID (will be auto-generated if not provided). Positions: "before" main prompt, "after" main prompt, in-"chat", hidden with "none" (default: after). Depth: injection depth for the prompt (default: 4). Role: role for in-chat injections (default: system). Scan: include injection content into World Info scans (default: false). Hidden injects in "none" position are not inserted into the prompt but can be used for triggering WI entries. Returns the injection ID.`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'listinjects',
|
||||
callback: listInjectsCallback,
|
||||
helpString: t`Lists all script injections for the current chat. Displays injects in a popup by default. Use the <code>return</code> argument to change the return type.`,
|
||||
returns: t`Optionally the JSON object of script injections`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'return',
|
||||
description: t`The way how you want the return value to be provided`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: 'popup-html',
|
||||
enumList: slashCommandReturnHelper.enumList({ allowPipe: false, allowObject: true, allowChat: true, allowPopup: true, allowTextVersion: false }),
|
||||
forceEnum: true,
|
||||
}),
|
||||
// TODO remove some day
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'format',
|
||||
description: t`!!! DEPRECATED - use "return" instead !!! output format`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
forceEnum: true,
|
||||
enumList: [
|
||||
new SlashCommandEnumValue('popup', t`Show injects in a popup.`, enumTypes.enum, enumIcons.default),
|
||||
new SlashCommandEnumValue('chat', t`Post a system message to the chat.`, enumTypes.enum, enumIcons.default),
|
||||
new SlashCommandEnumValue('none', t`Just return the injects as a JSON object.`, enumTypes.enum, enumIcons.default),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'flushinject',
|
||||
aliases: ['flushinjects'],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: t`injection ID or a variable name pointing to ID`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: '',
|
||||
enumProvider: commonEnumProviders.injects,
|
||||
}),
|
||||
],
|
||||
callback: flushInjectsCallback,
|
||||
helpString: t`Removes a script injection for the current chat. If no ID is provided, removes all script injections.`,
|
||||
}));
|
||||
}
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'tokens',
|
||||
callback: (_, text) => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
setChatHistoryBootstrapModeName,
|
||||
} from '../../../../tauri/main/services/chat-history/chat-history-mode-state.js';
|
||||
import { runOrPopup } from './popup-utils.js';
|
||||
import { getActiveIosPolicyCapabilities } from '../../../tauritavern/ios-policy.js';
|
||||
|
||||
function isWindowsPlatform() {
|
||||
return typeof navigator !== 'undefined'
|
||||
|
|
@ -30,6 +31,9 @@ function isWindowsPlatform() {
|
|||
|
||||
export async function openTauriTavernSettingsPopup() {
|
||||
const settings = await getTauriTavernSettings();
|
||||
const iosCaps = getActiveIosPolicyCapabilities();
|
||||
const requestProxyAllowed = iosCaps?.network?.request_proxy !== false;
|
||||
const lanSyncAllowed = iosCaps?.sync?.lan !== false;
|
||||
const supportsCloseToTrayOnClose = isWindowsPlatform() && !isMobile();
|
||||
// Data directory selection is a desktop-only feature. Do not gate this on Bowser's `isMobile()`,
|
||||
// because iPadOS may present a desktop-like user agent (e.g. platform "MacIntel").
|
||||
|
|
@ -157,7 +161,7 @@ export async function openTauriTavernSettingsPopup() {
|
|||
<small style="opacity: 0.85;" data-i18n="Requires reload to apply.">Requires reload to apply.</small>
|
||||
</div>
|
||||
|
||||
<div class="flex-container flexFlowColumn" style="gap: 10px; padding: 12px; border: 1px solid rgba(255,255,255,0.10); border-radius: 10px; background: rgba(0,0,0,0.12);">
|
||||
<div id="tt-system-panel" class="flex-container flexFlowColumn" style="gap: 10px; padding: 12px; border: 1px solid rgba(255,255,255,0.10); border-radius: 10px; background: rgba(0,0,0,0.12);">
|
||||
<div class="flex-container alignItemsBaseline" style="justify-content: space-between; gap: 10px;">
|
||||
<b data-i18n="System">System</b>
|
||||
</div>
|
||||
|
|
@ -321,7 +325,7 @@ export async function openTauriTavernSettingsPopup() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-container flexFlowColumn" style="gap: 10px; padding: 12px; border: 1px solid rgba(255,255,255,0.10); border-radius: 10px; background: rgba(0,0,0,0.12);">
|
||||
<div id="tt-sync-panel" class="flex-container flexFlowColumn" style="gap: 10px; padding: 12px; border: 1px solid rgba(255,255,255,0.10); border-radius: 10px; background: rgba(0,0,0,0.12);">
|
||||
<div class="flex-container alignItemsBaseline" style="justify-content: space-between; gap: 10px;">
|
||||
<b data-i18n="Sync">Sync</b>
|
||||
</div>
|
||||
|
|
@ -457,6 +461,16 @@ export async function openTauriTavernSettingsPopup() {
|
|||
if (!(requestProxyDetails instanceof HTMLDetailsElement)) {
|
||||
throw new Error('TauriTavern settings: request proxy details not found');
|
||||
}
|
||||
if (!requestProxyAllowed) {
|
||||
requestProxyDetails.hidden = true;
|
||||
}
|
||||
if (!requestProxyAllowed && !supportsDataRootSelection) {
|
||||
const systemPanel = root.querySelector('#tt-system-panel');
|
||||
if (!(systemPanel instanceof HTMLElement)) {
|
||||
throw new Error('TauriTavern settings: system panel not found');
|
||||
}
|
||||
systemPanel.hidden = true;
|
||||
}
|
||||
|
||||
const requestProxySummaryHint = root.querySelector('#tt-request-proxy-summary-hint');
|
||||
if (!(requestProxySummaryHint instanceof HTMLElement)) {
|
||||
|
|
@ -712,10 +726,18 @@ export async function openTauriTavernSettingsPopup() {
|
|||
if (!(openSyncButton instanceof HTMLElement)) {
|
||||
throw new Error('TauriTavern settings: sync button not found');
|
||||
}
|
||||
openSyncButton.addEventListener('click', () => runOrPopup(async () => {
|
||||
const { openSyncPopup } = await import('./sync-popup.js');
|
||||
await openSyncPopup();
|
||||
}));
|
||||
if (!lanSyncAllowed) {
|
||||
const syncPanel = root.querySelector('#tt-sync-panel');
|
||||
if (!(syncPanel instanceof HTMLElement)) {
|
||||
throw new Error('TauriTavern settings: sync panel not found');
|
||||
}
|
||||
syncPanel.hidden = true;
|
||||
} else {
|
||||
openSyncButton.addEventListener('click', () => runOrPopup(async () => {
|
||||
const { openSyncPopup } = await import('./sync-popup.js');
|
||||
await openSyncPopup();
|
||||
}));
|
||||
}
|
||||
|
||||
const panelRuntimeHelp = root.querySelector('#tt-help-panel-runtime');
|
||||
if (!(panelRuntimeHelp instanceof HTMLElement)) {
|
||||
|
|
|
|||
210
src/scripts/tauritavern/ios-policy-ui.js
Normal file
210
src/scripts/tauritavern/ios-policy-ui.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { allowlistSettingAllows, getActiveIosPolicyActivationReport } from './ios-policy.js';
|
||||
|
||||
const STYLE_ID = 'tt-ios-policy-ui-projection';
|
||||
|
||||
function ensureStyleElement() {
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (style instanceof HTMLStyleElement) {
|
||||
return style;
|
||||
}
|
||||
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
return style;
|
||||
}
|
||||
|
||||
function requireElement(selector, context = document) {
|
||||
const element = context.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error(`[TauriTavern][iOSPolicy] Expected element not found: ${selector}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function hideElement(element) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] hideElement: element must be an HTMLElement');
|
||||
}
|
||||
|
||||
element.hidden = true;
|
||||
element.setAttribute('data-tt-ios-policy-hidden', '1');
|
||||
}
|
||||
|
||||
function normalizeChatCompletionSourceKey(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized.startsWith('custom_')) {
|
||||
return 'custom';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function filterChatCompletionSourceOptions({ allowlist, endpointOverridesAllowed }) {
|
||||
const select = requireElement('#chat_completion_source');
|
||||
if (!(select instanceof HTMLSelectElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] #chat_completion_source must be a <select>');
|
||||
}
|
||||
|
||||
const isAllowed = (value) => {
|
||||
const key = normalizeChatCompletionSourceKey(value);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!endpointOverridesAllowed && key === 'custom') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowlistSettingAllows(allowlist, key);
|
||||
};
|
||||
|
||||
const options = Array.from(select.options);
|
||||
for (const option of options) {
|
||||
if (!isAllowed(option.value)) {
|
||||
option.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (select.options.length === 0) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Chat completion source list became empty after applying iOS policy');
|
||||
}
|
||||
|
||||
if (!isAllowed(select.value)) {
|
||||
select.value = select.options[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyIosPolicyUiProjection() {
|
||||
const report = getActiveIosPolicyActivationReport();
|
||||
const caps = report?.capabilities ?? null;
|
||||
if (!caps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(document.body instanceof HTMLBodyElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] document.body is unavailable');
|
||||
}
|
||||
|
||||
document.body.dataset.ttIosPolicyScope = 'ios';
|
||||
if (typeof report.profile !== 'string' || !report.profile.trim()) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Active policy profile is missing');
|
||||
}
|
||||
document.body.dataset.ttIosPolicyProfile = report.profile;
|
||||
|
||||
const cssRules = [];
|
||||
|
||||
if (report.profile === 'ios_external_beta') {
|
||||
cssRules.push(`
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-profile="ios_external_beta"] #ttv-compat-row,
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-profile="ios_external_beta"] #ttv-discord-link,
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-profile="ios_external_beta"] #tt-welcome-discord-link {
|
||||
display: none !important;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
if (caps.extensions?.third_party_management === false) {
|
||||
document.body.dataset.ttIosPolicyNoThirdPartyExtensions = '1';
|
||||
cssRules.push(`
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-no-third-party-extensions="1"] #extensions_details,
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-no-third-party-extensions="1"] #third_party_extension_button,
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-no-third-party-extensions="1"] label[for="extensions_notify_updates"] {
|
||||
display: none !important;
|
||||
}
|
||||
`);
|
||||
|
||||
hideElement(requireElement('#extensions_details'));
|
||||
hideElement(requireElement('#third_party_extension_button'));
|
||||
hideElement(requireElement('label[for="extensions_notify_updates"]'));
|
||||
}
|
||||
|
||||
if (caps.content?.external_import === false) {
|
||||
document.body.dataset.ttIosPolicyNoExternalImport = '1';
|
||||
cssRules.push(`
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-no-external-import="1"] #external_import_button,
|
||||
body[data-tt-ios-policy-scope="ios"][data-tt-ios-policy-no-external-import="1"] .external_import_button {
|
||||
display: none !important;
|
||||
}
|
||||
`);
|
||||
|
||||
hideElement(requireElement('#external_import_button'));
|
||||
for (const element of document.querySelectorAll('.external_import_button')) {
|
||||
if (element instanceof HTMLElement) {
|
||||
hideElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
const onboardingRoot = requireElement('#onboarding_template .onboarding');
|
||||
hideElement(requireElement('ul.justifyLeft.marginTopBot5', onboardingRoot));
|
||||
hideElement(requireElement('.textAlignCenter', onboardingRoot));
|
||||
}
|
||||
|
||||
if (caps.prompts?.nsfw_prompt === false) {
|
||||
const textarea = requireElement('#nsfw_prompt_quick_edit_textarea');
|
||||
const container = textarea.closest('.range-block');
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] NSFW prompt container not found');
|
||||
}
|
||||
hideElement(container);
|
||||
}
|
||||
|
||||
if (caps.prompts?.jailbreak_prompt === false) {
|
||||
const textarea = requireElement('#jailbreak_prompt_quick_edit_textarea');
|
||||
const container = textarea.closest('.range-block');
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Jailbreak prompt container not found');
|
||||
}
|
||||
hideElement(container);
|
||||
|
||||
const preferJailbreak = requireElement('#prefer_character_jailbreak');
|
||||
const label = preferJailbreak.closest('label');
|
||||
if (!(label instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Jailbreak preference label not found');
|
||||
}
|
||||
hideElement(label);
|
||||
}
|
||||
|
||||
const endpointOverridesAllowed = caps.llm?.endpoint_overrides !== false;
|
||||
filterChatCompletionSourceOptions({
|
||||
allowlist: caps.llm?.chat_completion_sources?.allowlist,
|
||||
endpointOverridesAllowed,
|
||||
});
|
||||
|
||||
if (!endpointOverridesAllowed) {
|
||||
const reverseProxyUrl = requireElement('#openai_reverse_proxy');
|
||||
const reverseProxyDrawer = reverseProxyUrl.closest('.inline-drawer');
|
||||
if (!(reverseProxyDrawer instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Reverse proxy drawer not found');
|
||||
}
|
||||
hideElement(reverseProxyDrawer);
|
||||
hideElement(requireElement('#ReverseProxyWarningMessage'));
|
||||
hideElement(requireElement('#ReverseProxyWarningMessage2'));
|
||||
hideElement(requireElement('#custom_form'));
|
||||
}
|
||||
|
||||
if (caps.llm?.chat_completion_features?.web_search === false) {
|
||||
const checkbox = requireElement('#openai_enable_web_search');
|
||||
const container = checkbox.closest('.range-block');
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error('[TauriTavern][iOSPolicy] Web search settings container not found');
|
||||
}
|
||||
hideElement(container);
|
||||
}
|
||||
|
||||
if (caps.llm?.chat_completion_features?.request_images === false) {
|
||||
hideElement(requireElement('#request_images_block'));
|
||||
}
|
||||
|
||||
if (caps.llm?.text_completions?.enabled === false) {
|
||||
const textCompletionOption = requireElement('#main_api option[value="textgenerationwebui"]');
|
||||
textCompletionOption.remove();
|
||||
hideElement(requireElement('#textgenerationwebui_api'));
|
||||
}
|
||||
|
||||
if (caps.ai?.image_generation === false) {
|
||||
hideElement(requireElement('#bg_chat_hint'));
|
||||
}
|
||||
|
||||
const style = ensureStyleElement();
|
||||
style.textContent = cssRules.join('\n');
|
||||
}
|
||||
31
src/scripts/tauritavern/ios-policy.js
Normal file
31
src/scripts/tauritavern/ios-policy.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { getHostAbi } from './layout-kit.js';
|
||||
|
||||
export function getIosPolicyActivationReport() {
|
||||
return getHostAbi()?.iosPolicy ?? null;
|
||||
}
|
||||
|
||||
export function getActiveIosPolicyActivationReport() {
|
||||
const report = getIosPolicyActivationReport();
|
||||
if (!report || report.scope !== 'ios') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
export function getActiveIosPolicyCapabilities() {
|
||||
return getActiveIosPolicyActivationReport()?.capabilities ?? null;
|
||||
}
|
||||
|
||||
export function allowlistSettingAllows(allowlist, value) {
|
||||
if (allowlist === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(allowlist)) {
|
||||
return allowlist.includes(value);
|
||||
}
|
||||
|
||||
throw new Error(`[TauriTavern][iOSPolicy] Unsupported allowlist setting: ${JSON.stringify(allowlist)}`);
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
Recent Chats
|
||||
</div>
|
||||
<div class="welcomeShortcuts">
|
||||
<a class="menu_button menu_button_icon" target="_blank" href="https://github.com/Darkatse/TauriTavern/tree/main/docs/">
|
||||
<a class="menu_button menu_button_icon" target="_blank" href="https://tauritavern.github.io">
|
||||
<i class="fa-solid fa-question-circle"></i>
|
||||
<span data-i18n="Docs">Docs</span>
|
||||
</a>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<i class="fa-brands fa-github"></i>
|
||||
<span data-i18n="GitHub">GitHub</span>
|
||||
</a>
|
||||
<a class="menu_button menu_button_icon" target="_blank" href="https://discord.com/channels/1134557553011998840/1472415443078742188">
|
||||
<a id="tt-welcome-discord-link" class="menu_button menu_button_icon" target="_blank" href="https://discord.com/channels/1134557553011998840/1472415443078742188">
|
||||
<i class="fa-brands fa-discord"></i>
|
||||
<span data-i18n="Discord">Discord</span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandE
|
|||
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
|
||||
import { isTrueBoolean } from './utils.js';
|
||||
import { getActiveIosPolicyCapabilities } from './tauritavern/ios-policy.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} ToolInvocation
|
||||
|
|
@ -901,6 +902,7 @@ export class ToolManager {
|
|||
}
|
||||
|
||||
static initToolSlashCommands() {
|
||||
const toolRegistrationAllowed = getActiveIosPolicyCapabilities()?.scripting?.tool_registration !== false;
|
||||
const toolsEnumProvider = () => ToolManager.tools.map(tool => {
|
||||
const toolOpenAI = tool.toFunctionOpenAI();
|
||||
return new SlashCommandEnumValue(toolOpenAI.function.name, toolOpenAI.function.description, enumTypes.enum, enumIcons.closure);
|
||||
|
|
@ -965,10 +967,11 @@ export class ToolManager {
|
|||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'tools-register',
|
||||
aliases: ['tool-register'],
|
||||
helpString: `<div>Registers a new tool with the tool registry.</div>
|
||||
if (toolRegistrationAllowed) {
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'tools-register',
|
||||
aliases: ['tool-register'],
|
||||
helpString: `<div>Registers a new tool with the tool registry.</div>
|
||||
<ul>
|
||||
<li>The <code>parameters</code> argument MUST be a JSON-serialized object with a valid JSON schema.</li>
|
||||
<li>The unnamed argument MUST be a closure that accepts the function parameters as local script variables.</li>
|
||||
|
|
@ -1114,30 +1117,31 @@ export class ToolManager {
|
|||
|
||||
return '';
|
||||
},
|
||||
}));
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'tools-unregister',
|
||||
aliases: ['tool-unregister'],
|
||||
helpString: 'Unregisters a tool from the tool registry.',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The name of the tool to unregister.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
forceEnum: true,
|
||||
enumProvider: toolsEnumProvider,
|
||||
}),
|
||||
],
|
||||
callback: async (_, name) => {
|
||||
if (typeof name !== 'string' || !name) {
|
||||
throw new Error('The unnamed argument must be a non-empty string.');
|
||||
}
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'tools-unregister',
|
||||
aliases: ['tool-unregister'],
|
||||
helpString: 'Unregisters a tool from the tool registry.',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The name of the tool to unregister.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
forceEnum: true,
|
||||
enumProvider: toolsEnumProvider,
|
||||
}),
|
||||
],
|
||||
callback: async (_, name) => {
|
||||
if (typeof name !== 'string' || !name) {
|
||||
throw new Error('The unnamed argument must be a non-empty string.');
|
||||
}
|
||||
|
||||
ToolManager.unregisterFunctionTool(name);
|
||||
return '';
|
||||
},
|
||||
}));
|
||||
ToolManager.unregisterFunctionTool(name);
|
||||
return '';
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
src/tauri/main/api/chat-utils.js
Normal file
77
src/tauri/main/api/chat-utils.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {{ kind: 'character'; characterId: string; fileName: string }} CharacterChatRef
|
||||
* @typedef {{ kind: 'group'; chatId: string }} GroupChatRef
|
||||
* @typedef {CharacterChatRef | GroupChatRef} ChatRef
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {any[]} lines
|
||||
* @returns {any[]}
|
||||
*/
|
||||
export function parseJsonLines(lines) {
|
||||
return lines.map((line) => JSON.parse(String(line)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export function mustArray(value, label) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an array`);
|
||||
}
|
||||
return /** @type {T[]} */ (value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
* @returns {number}
|
||||
*/
|
||||
export function mustNumber(value, label) {
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
throw new Error(`${label} must be a finite number`);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function stripJsonl(value) {
|
||||
return String(value || '').trim().replace(/\.jsonl$/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} ref
|
||||
* @returns {ChatRef}
|
||||
*/
|
||||
export function normalizeChatRef(ref) {
|
||||
if (!ref || typeof ref !== 'object') {
|
||||
throw new Error('ChatRef must be an object');
|
||||
}
|
||||
|
||||
const kind = String(ref.kind || '').trim();
|
||||
if (kind === 'character') {
|
||||
const characterId = String(ref.characterId || '').trim();
|
||||
const fileName = stripJsonl(ref.fileName);
|
||||
if (!characterId || !fileName) {
|
||||
throw new Error('Character ChatRef requires characterId and fileName');
|
||||
}
|
||||
return { kind: 'character', characterId, fileName };
|
||||
}
|
||||
|
||||
if (kind === 'group') {
|
||||
const chatId = stripJsonl(ref.chatId);
|
||||
if (!chatId) {
|
||||
throw new Error('Group ChatRef requires chatId');
|
||||
}
|
||||
return { kind: 'group', chatId };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported ChatRef kind: ${kind}`);
|
||||
}
|
||||
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { getActiveChatSnapshot } from '../adapters/st/active-chat-ref.js';
|
||||
import { getChatHistoryBootstrapModeName } from '../services/chat-history/chat-history-mode-state.js';
|
||||
import { createChatSearchMessages } from './chat-search-messages.js';
|
||||
import { mustArray, mustNumber, normalizeChatRef, parseJsonLines } from './chat-utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {{ kind: 'character'; characterId: string; fileName: string }} CharacterChatRef
|
||||
|
|
@ -18,75 +19,6 @@ import { createChatSearchMessages } from './chat-search-messages.js';
|
|||
* }} ChatHistoryPage
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {any[]} lines
|
||||
* @returns {any[]}
|
||||
*/
|
||||
function parseJsonLines(lines) {
|
||||
return lines.map((line) => JSON.parse(String(line)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
* @returns {T[]}
|
||||
*/
|
||||
function mustArray(value, label) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an array`);
|
||||
}
|
||||
return /** @type {T[]} */ (value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
* @returns {number}
|
||||
*/
|
||||
function mustNumber(value, label) {
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
throw new Error(`${label} must be a finite number`);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function stripJsonl(value) {
|
||||
return String(value || '').trim().replace(/\.jsonl$/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} ref
|
||||
* @returns {ChatRef}
|
||||
*/
|
||||
function normalizeChatRef(ref) {
|
||||
if (!ref || typeof ref !== 'object') {
|
||||
throw new Error('ChatRef must be an object');
|
||||
}
|
||||
|
||||
const kind = String(ref.kind || '').trim();
|
||||
if (kind === 'character') {
|
||||
const characterId = String(ref.characterId || '').trim();
|
||||
const fileName = stripJsonl(ref.fileName);
|
||||
if (!characterId || !fileName) {
|
||||
throw new Error('Character ChatRef requires characterId and fileName');
|
||||
}
|
||||
return { kind: 'character', characterId, fileName };
|
||||
}
|
||||
|
||||
if (kind === 'group') {
|
||||
const chatId = stripJsonl(ref.chatId);
|
||||
if (!chatId) {
|
||||
throw new Error('Group ChatRef requires chatId');
|
||||
}
|
||||
return { kind: 'group', chatId };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported ChatRef kind: ${kind}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ safeInvoke: (command: any, args?: any) => Promise<any>; ref: ChatRef }} deps
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { createTokenCountBroker, estimateTokenCount, trimOpenAiMessage } from '../brokers/token-count-broker.js';
|
||||
import { estimateTokenCount } from '../brokers/token-count-broker.js';
|
||||
import { createAndroidGenerationBridge } from '../adapters/android/android-generation-bridge.js';
|
||||
import { confirmAiNotificationPermissionRationale } from '../adapters/st/ai-notification-permission-rationale-popup.js';
|
||||
import { translateSillyTavern } from '../adapters/st/sillytavern-i18n.js';
|
||||
import { createGenerationLifecycleService } from '../services/ai/generation-lifecycle-service.js';
|
||||
import { createGenerationStatusBridge } from '../services/ai/generation-status-bridge.js';
|
||||
import { createSystemNotificationService } from '../services/notifications/system-notification-service.js';
|
||||
import { registerOpenAiTokenizerRoutes } from './openai-tokenizer-routes.js';
|
||||
import { createChannel } from '../../../tauri-bridge.js';
|
||||
import { stripCommandErrorPrefixes } from '../../../scripts/util/command-error-utils.js';
|
||||
|
||||
|
|
@ -611,7 +612,6 @@ async function createChatCompletionStreamResponse(context, payload, signal, life
|
|||
}
|
||||
|
||||
export function registerAiRoutes(router, context, { jsonResponse }) {
|
||||
const tokenCountBroker = createTokenCountBroker({ context });
|
||||
const notificationService = createSystemNotificationService({
|
||||
safeInvoke: context.safeInvoke,
|
||||
confirmPermissionRationale: confirmAiNotificationPermissionRationale,
|
||||
|
|
@ -705,79 +705,5 @@ export function registerAiRoutes(router, context, { jsonResponse }) {
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/api/backends/chat-completions/bias', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
const entries = Array.isArray(body) ? body : [];
|
||||
const dto = { model, entries };
|
||||
|
||||
try {
|
||||
const bias = await context.safeInvoke('build_openai_logit_bias', { dto });
|
||||
return jsonResponse(bias || {});
|
||||
} catch (error) {
|
||||
console.error('Failed to build logit bias:', error);
|
||||
return jsonResponse({});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/count', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
if (!Array.isArray(body)) return jsonResponse({ error: 'OpenAI token count body must be an array' }, 400);
|
||||
try {
|
||||
return jsonResponse({ token_count: await tokenCountBroker.count({ model, messages: body }) });
|
||||
} catch (error) {
|
||||
console.warn('OpenAI token count failed:', error);
|
||||
return jsonResponse({ error: getErrorMessage(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/count-batch', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
if (!Array.isArray(body)) return jsonResponse({ error: 'OpenAI token count batch body must be an array' }, 400);
|
||||
|
||||
const dto = { model, requests: body.map((message) => ({ messages: [trimOpenAiMessage(message)] })) };
|
||||
|
||||
try {
|
||||
return jsonResponse(await context.safeInvoke('count_openai_tokens_batch', { dto }));
|
||||
} catch (error) {
|
||||
console.warn('OpenAI token count batch failed:', error);
|
||||
return jsonResponse({ error: getErrorMessage(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/encode', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
const payload = asObject(body);
|
||||
const dto = {
|
||||
model,
|
||||
text: String(payload.text || ''),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await context.safeInvoke('encode_openai_tokens', { dto });
|
||||
return jsonResponse(result || { ids: [], count: 0, chunks: [] });
|
||||
} catch (error) {
|
||||
console.error('OpenAI token encode failed:', error);
|
||||
return jsonResponse({ ids: [], count: 0, chunks: [] });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/decode', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
const payload = asObject(body);
|
||||
const ids = Array.isArray(payload.ids)
|
||||
? payload.ids
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isInteger(id) && id >= 0)
|
||||
: [];
|
||||
|
||||
const dto = { model, ids };
|
||||
|
||||
try {
|
||||
const result = await context.safeInvoke('decode_openai_tokens', { dto });
|
||||
return jsonResponse(result || { text: '', chunks: [] });
|
||||
} catch (error) {
|
||||
console.error('OpenAI token decode failed:', error);
|
||||
return jsonResponse({ text: '', chunks: [] });
|
||||
}
|
||||
});
|
||||
registerOpenAiTokenizerRoutes(router, context, { jsonResponse });
|
||||
}
|
||||
|
|
|
|||
1
src/tauri/main/routes/bootstrap-routes.js
vendored
1
src/tauri/main/routes/bootstrap-routes.js
vendored
|
|
@ -4,6 +4,7 @@ export function registerBootstrapRoutes(router, context, { jsonResponse }) {
|
|||
const normalizedCharacters = snapshot.characters.map((character) => context.normalizeCharacter(character));
|
||||
|
||||
return jsonResponse({
|
||||
ios_policy: snapshot.ios_policy,
|
||||
settings: snapshot.settings,
|
||||
characters: normalizedCharacters,
|
||||
groups: snapshot.groups,
|
||||
|
|
|
|||
|
|
@ -81,14 +81,9 @@ export function registerContentRoutes(router, context, { jsonResponse }) {
|
|||
return jsonResponse({ error: 'Import URL is not whitelisted' }, 403);
|
||||
}
|
||||
|
||||
let downloadResult;
|
||||
try {
|
||||
downloadResult = await context.safeInvoke('download_external_import_url', {
|
||||
url: targetUrl.toString(),
|
||||
});
|
||||
} catch {
|
||||
return jsonResponse({ error: 'Unable to download external content' }, 502);
|
||||
}
|
||||
const downloadResult = await context.safeInvoke('download_external_import_url', {
|
||||
url: targetUrl.toString(),
|
||||
});
|
||||
|
||||
const contentType = String(downloadResult?.mimeType || '');
|
||||
if (!isPngResponse(targetUrl, contentType)) {
|
||||
|
|
|
|||
98
src/tauri/main/routes/openai-tokenizer-routes.js
Normal file
98
src/tauri/main/routes/openai-tokenizer-routes.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { createTokenCountBroker, trimOpenAiMessage } from '../brokers/token-count-broker.js';
|
||||
|
||||
function asObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
if (!error) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return error.message || error.toString?.() || 'Unknown error';
|
||||
}
|
||||
|
||||
export function registerOpenAiTokenizerRoutes(router, context, { jsonResponse }) {
|
||||
const tokenCountBroker = createTokenCountBroker({ context });
|
||||
|
||||
router.post('/api/backends/chat-completions/bias', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
const entries = Array.isArray(body) ? body : [];
|
||||
const dto = { model, entries };
|
||||
|
||||
try {
|
||||
const bias = await context.safeInvoke('build_openai_logit_bias', { dto });
|
||||
return jsonResponse(bias || {});
|
||||
} catch (error) {
|
||||
console.error('Failed to build logit bias:', error);
|
||||
return jsonResponse({});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/count', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
if (!Array.isArray(body)) return jsonResponse({ error: 'OpenAI token count body must be an array' }, 400);
|
||||
try {
|
||||
return jsonResponse({ token_count: await tokenCountBroker.count({ model, messages: body }) });
|
||||
} catch (error) {
|
||||
console.warn('OpenAI token count failed:', error);
|
||||
return jsonResponse({ error: getErrorMessage(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/count-batch', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
if (!Array.isArray(body)) return jsonResponse({ error: 'OpenAI token count batch body must be an array' }, 400);
|
||||
|
||||
const dto = { model, requests: body.map((message) => ({ messages: [trimOpenAiMessage(message)] })) };
|
||||
|
||||
try {
|
||||
return jsonResponse(await context.safeInvoke('count_openai_tokens_batch', { dto }));
|
||||
} catch (error) {
|
||||
console.warn('OpenAI token count batch failed:', error);
|
||||
return jsonResponse({ error: getErrorMessage(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/encode', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
const payload = asObject(body);
|
||||
const dto = {
|
||||
model,
|
||||
text: String(payload.text || ''),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await context.safeInvoke('encode_openai_tokens', { dto });
|
||||
return jsonResponse(result || { ids: [], count: 0, chunks: [] });
|
||||
} catch (error) {
|
||||
console.error('OpenAI token encode failed:', error);
|
||||
return jsonResponse({ ids: [], count: 0, chunks: [] });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tokenizers/openai/decode', async ({ body, url }) => {
|
||||
const model = String(url?.searchParams?.get('model') || '');
|
||||
const payload = asObject(body);
|
||||
const ids = Array.isArray(payload.ids)
|
||||
? payload.ids
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isInteger(id) && id >= 0)
|
||||
: [];
|
||||
|
||||
const dto = { model, ids };
|
||||
|
||||
try {
|
||||
const result = await context.safeInvoke('decode_openai_tokens', { dto });
|
||||
return jsonResponse(result || { text: '', chunks: [] });
|
||||
} catch (error) {
|
||||
console.error('OpenAI token decode failed:', error);
|
||||
return jsonResponse({ text: '', chunks: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue