feat (iOS policy): Implement iOS policy tiering strategy

This commit is contained in:
Darkatse 2026-04-15 21:39:01 -04:00
parent 433e82375f
commit 99b1939976
48 changed files with 2474 additions and 504 deletions

View file

@ -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 投影的维护约束

View 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 导入把桌面端拖死。

View file

@ -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) - 日志捕获/存储/展示链路的有界化与性能治理路线图
## 项目概述

View file

@ -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`

View file

@ -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() {

View file

@ -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"
},

View file

@ -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
View 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);
}

View file

@ -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() {

View file

@ -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(_) => {}

View file

@ -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,
})
}

View file

@ -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,
}

View file

@ -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,

View file

@ -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?;

View 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())
);
}
}

View file

@ -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;

View file

@ -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(),
}
}
}

View file

@ -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)]

View file

@ -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,

View file

@ -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,

View file

@ -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 =

View file

@ -74,6 +74,7 @@ pub async fn get_bootstrap_snapshot(
)?;
Ok(BootstrapSnapshotDto {
ios_policy: app_state.ios_policy.clone(),
settings,
characters,
groups,

View file

@ -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()))?;

View file

@ -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)

View file

@ -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()));
}

View file

@ -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(())

View file

@ -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"))?;
}

View file

@ -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)

View file

@ -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()

View file

@ -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": "导出当前数据",

View file

@ -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": "匯出目前資料",

View file

@ -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 });

View file

@ -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);

View file

@ -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);

View file

@ -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>

View file

@ -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()));

View file

@ -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) => {

View file

@ -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)) {

View 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');
}

View 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)}`);
}

View file

@ -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>

View file

@ -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 '';
},
}));
}
}
}

View 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}`);
}

View file

@ -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
*/

View file

@ -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 });
}

View file

@ -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,

View file

@ -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)) {

View 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: [] });
}
});
}