tavern-harness 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,7 @@ The first run downloads the package, starts the local service, and opens your br
33
33
  ```bash
34
34
  npx tavern-harness --no-open
35
35
  npx tavern-harness --port 5273
36
+ npx tavern-harness --workspace /absolute/path/to/workspace
36
37
  ```
37
38
 
38
39
  ### Option 2: Global Installation (Recommended for Regular Use)
@@ -73,6 +74,10 @@ After starting the app, open **Settings → Model Service Provider**:
73
74
  2. **Test the connection**: the app fetches the model list automatically. If the target service does not expose CORS headers, requests are forwarded through the built-in same-origin proxy for private-network or LAN addresses. On success, the Base URL is rewritten to its proxy form and saved.
74
75
  3. **Select a model**: click the model name at the top of the chat page, then return to the conversation and start chatting.
75
76
 
77
+ ### Automatic Tool Approval (YOLO Mode)
78
+
79
+ Enable **Settings → Tool execution → YOLO mode** to automatically approve tool changes and Shell commands during conversations. It is off by default, is saved locally, and can be disabled at any time. Only enable it for characters and skills you trust. Agent execution limits still require manual continuation.
80
+
76
81
  ### Data Privacy
77
82
 
78
83
  - **All data is stored in browser IndexedDB**: conversations, character cards, lorebooks, skills, statistics, and API keys are not uploaded to any server.
package/README.zh.md CHANGED
@@ -33,6 +33,7 @@ npx tavern-harness
33
33
  ```bash
34
34
  npx tavern-harness --no-open
35
35
  npx tavern-harness --port 5273
36
+ npx tavern-harness --workspace /absolute/path/to/workspace
36
37
  ```
37
38
 
38
39
 
@@ -74,6 +75,10 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
74
75
  2. **测试连接**:自动拉取模型列表。若目标服务未开 CORS 头,会自动经内置同源代理转发(仅限内网 / 局域网地址),成功后 Base URL 自动改写为代理形式保存
75
76
  3. **选模型**:聊天页顶部点击模型名即可,回到对话开聊!
76
77
 
78
+ ### 自动审批工具(YOLO 模式)
79
+
80
+ 在「**设置 → 工具执行 → YOLO 模式**」开启后,对话中的工具修改和 Shell 执行会自动批准,无需逐次确认。默认关闭,设置保存在本地,可随时关闭。仅在信任角色和技能时开启;Agent 执行限额仍需手动确认继续。
81
+
77
82
  ### 数据隐私须知
78
83
 
79
84
  - **全部数据存在浏览器 IndexedDB 里**:会话、角色卡、世界书、技能、统计、API Key,均**不上传任何服务器**。
package/bin/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // tavern-harness --no-open 只启动,不打开浏览器
6
6
  // tavern-harness --port 5273 指定端口(5173 被占用时 vite 会自动顺延,
7
7
  // 会打开顺延后的实际端口)
8
+ // tavern-harness --workspace /path 指定生成技能文件工作区
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { dirname, join } from 'node:path';
10
11
  import { createServer } from 'vite';
@@ -17,15 +18,19 @@ const configFile = join(root, 'vite.config.ts'); // 复用现有配置(cors
17
18
  const args = process.argv.slice(2);
18
19
  let open = true; // 默认自动打开浏览器
19
20
  let port;
21
+ let workspace;
20
22
  for (let i = 0; i < args.length; i++) {
21
23
  const a = args[i];
22
24
  if (a === '--no-open') open = false;
23
25
  else if (a === '--open') open = true;
24
26
  else if (a === '--port') port = Number(args[++i]);
25
27
  else if (a.startsWith('--port=')) port = Number(a.slice('--port='.length));
28
+ else if (a === '--workspace') workspace = args[++i];
29
+ else if (a.startsWith('--workspace=')) workspace = a.slice('--workspace='.length);
26
30
  }
27
31
  const serverOptions = { open };
28
32
  if (Number.isInteger(port) && port > 0 && port < 65536) serverOptions.port = port;
33
+ if (workspace) process.env.TAVERN_HARNESS_WORKSPACE = workspace;
29
34
 
30
35
  try {
31
36
  // inlineConfig 与 vite.config.ts 合并(inline 优先),open 会覆盖配置文件里的 open:false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -24,6 +24,8 @@
24
24
  "start": "vite",
25
25
  "dev": "vite",
26
26
  "test:command-service": "node sandbox-server.test.mjs",
27
+ "test:yolo-mode": "node yolo-mode.test.mjs",
28
+ "test:tool-arguments": "node tool-arguments.test.mjs",
27
29
  "build": "tsc --noEmit && vite build",
28
30
  "preview": "vite preview"
29
31
  },
@@ -45,6 +47,7 @@
45
47
  "@types/katex": "^0.16.7",
46
48
  "@types/react": "^18.3.5",
47
49
  "@types/react-dom": "^18.3.0",
50
+ "fake-indexeddb": "^6.2.5",
48
51
  "typescript": "^5.5.4"
49
52
  }
50
53
  }
@@ -22,10 +22,10 @@ import { createServer } from 'node:http';
22
22
  import { spawn, spawnSync } from 'node:child_process';
23
23
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
24
24
  import {
25
- writeFileSync, existsSync, lstatSync, readlinkSync, readFile, writeFile, mkdirSync, mkdtempSync, readdir, statSync, symlinkSync, realpathSync, rmSync, unlink,
25
+ writeFileSync, existsSync, lstatSync, readlinkSync, readFile, writeFile, mkdirSync, mkdtempSync, readdir, statSync, symlinkSync, realpathSync, rmSync, unlink, cpSync,
26
26
  } from 'node:fs';
27
- import { tmpdir } from 'node:os';
28
- import { join, dirname, resolve, sep, delimiter } from 'node:path';
27
+ import { tmpdir, homedir } from 'node:os';
28
+ import { join, dirname, resolve, sep, delimiter, isAbsolute } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
 
31
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -299,11 +299,40 @@ function consumeApprovedConfirmation(requestId, operation, sessionBase) {
299
299
  }
300
300
 
301
301
  // ---- 真实文件工作区(generate_skill 的 file_read / file_write 落盘区) ----
302
- // 项目根目录下 sandbox_workspace/,仅允许读写该目录内文件(虚拟磁盘)。
303
- // 会话隔离:每次请求必须携带会话工作目录(session 字段,如 "session-12"),
304
- // 所有读写/执行都锁定在该目录内,不允许回退到共享根工作区。
305
- const WORKSPACE_ROOT = resolve(__dirname, 'sandbox_workspace');
306
- const PUBLIC_WORKSPACE = join(WORKSPACE_ROOT, 'public');
302
+ // 默认放在用户数据目录,避免 npm 更新或重装删除用户创建的文件。
303
+ // 可通过 TAVERN_HARNESS_WORKSPACE 指向自定义绝对路径。
304
+ function defaultWorkspaceRoot() {
305
+ if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Tavern Harness', 'sandbox_workspace');
306
+ if (process.platform === 'win32') return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Tavern Harness', 'sandbox_workspace');
307
+ return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'tavern-harness', 'sandbox_workspace');
308
+ }
309
+
310
+ function resolveWorkspaceRoot() {
311
+ const configured = process.env.TAVERN_HARNESS_WORKSPACE?.trim();
312
+ if (configured) {
313
+ if (!isAbsolute(configured)) throw new Error('TAVERN_HARNESS_WORKSPACE must be an absolute path');
314
+ return resolve(configured);
315
+ }
316
+ return defaultWorkspaceRoot();
317
+ }
318
+
319
+ const WORKSPACE_ROOT = resolveWorkspaceRoot();
320
+ const LEGACY_WORKSPACE_ROOT = resolve(__dirname, 'sandbox_workspace');
321
+
322
+ function migrateLegacyWorkspace() {
323
+ if (WORKSPACE_ROOT === LEGACY_WORKSPACE_ROOT || existsSync(WORKSPACE_ROOT) || !existsSync(LEGACY_WORKSPACE_ROOT)) return;
324
+ try {
325
+ mkdirSync(dirname(WORKSPACE_ROOT), { recursive: true });
326
+ cpSync(LEGACY_WORKSPACE_ROOT, WORKSPACE_ROOT, { recursive: true, dereference: false, errorOnExist: true });
327
+ console.log(`[command-service] migrated workspace to ${WORKSPACE_ROOT}`);
328
+ } catch (error) {
329
+ console.warn(`[command-service] could not migrate legacy workspace: ${error.message}`);
330
+ }
331
+ }
332
+
333
+ migrateLegacyWorkspace();
334
+ const RESOLVED_WORKSPACE_ROOT = existsSync(WORKSPACE_ROOT) ? realpathSync(WORKSPACE_ROOT) : WORKSPACE_ROOT;
335
+ const PUBLIC_WORKSPACE = join(RESOLVED_WORKSPACE_ROOT, 'public');
307
336
  const MAX_FILE_READ_CHARS = 100_000; // 单文件读取上限(与前端虚拟工作区一致)
308
337
  const MAX_FILE_WRITE_BYTES = 400 * 1024; // 单文件写入上限 400KB
309
338
  const MAX_LIST_ENTRIES = 500;
@@ -320,7 +349,7 @@ function resolveSessionBase(session) {
320
349
  if (!raw || raw.startsWith('/') || raw === '.' || raw === '..' || !SESSION_DIR_RE.test(raw)) {
321
350
  throw new Error('A valid session workspace is required');
322
351
  }
323
- return join(WORKSPACE_ROOT, raw);
352
+ return join(RESOLVED_WORKSPACE_ROOT, raw);
324
353
  }
325
354
 
326
355
  /** 创建会话目录,并为普通会话提供固定的只读公共目录入口。 */
@@ -50,6 +50,26 @@ export function SettingsView() {
50
50
  {/* 模型服务 Provider(置顶) */}
51
51
  <ProviderManager providers={providers} onChanged={refreshProviders} />
52
52
 
53
+ <div className="set-section">
54
+ <h3>{t('settings.toolExecution')}</h3>
55
+ <div className="set-row settings-tool-row">
56
+ <div>
57
+ <div className="s-label" id="yolo-mode-label">{t('settings.yoloMode')}</div>
58
+ <div className="s-desc" id="yolo-mode-desc">{t('settings.yoloModeDesc')}</div>
59
+ </div>
60
+ <label className="switch">
61
+ <input
62
+ type="checkbox"
63
+ aria-labelledby="yolo-mode-label"
64
+ aria-describedby="yolo-mode-desc"
65
+ checked={settings.yoloMode === true}
66
+ onChange={(e) => setSettings({ yoloMode: e.target.checked })}
67
+ />
68
+ <span className="switch-slider" />
69
+ </label>
70
+ </div>
71
+ </div>
72
+
53
73
  {/* 生成参数 */}
54
74
  <div className="set-section">
55
75
  <h3>{t('settings.genParams')}</h3>
@@ -148,42 +168,32 @@ export function SettingsView() {
148
168
  {/* 界面语言 */}
149
169
  <div className="set-section">
150
170
  <h3>{t('settings.language')}</h3>
151
- <div className="field">
152
- <label>{t('settings.languageLabel')}</label>
153
- <select
154
- className="select"
155
- value={settings.language ?? ''}
156
- onChange={(e) => setSettings({ language: (e.target.value || null) as AppLanguage })}
157
- >
158
- <option value="">{t('settings.followBrowser')}</option>
159
- <option value="zh-CN">简体中文</option>
160
- <option value="zh-TW">繁體中文</option>
161
- <option value="en">English</option>
162
- </select>
163
- </div>
164
- <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>
165
- {t('settings.followBrowser')}
166
- </p>
171
+ <select
172
+ className="select"
173
+ aria-label={t('settings.languageLabel')}
174
+ value={settings.language ?? ''}
175
+ onChange={(e) => setSettings({ language: (e.target.value || null) as AppLanguage })}
176
+ >
177
+ <option value="">{t('settings.followBrowser')}</option>
178
+ <option value="zh-CN">简体中文</option>
179
+ <option value="zh-TW">繁體中文</option>
180
+ <option value="en">English</option>
181
+ </select>
167
182
  </div>
168
183
 
169
184
  {/* 主题 */}
170
185
  <div className="set-section">
171
186
  <h3>{t('settings.theme')}</h3>
172
- <div className="field">
173
- <label>{t('settings.appearance')}</label>
174
- <select
175
- className="select"
176
- value={settings.themeMode}
177
- onChange={(e) => setSettings({ themeMode: e.target.value as ThemeMode })}
178
- >
179
- <option value="system">{t('settings.system')}</option>
180
- <option value="light">{t('settings.light')}</option>
181
- <option value="dark">{t('settings.dark')}</option>
182
- </select>
183
- </div>
184
- <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>
185
- {t('settings.themeTip')}
186
- </p>
187
+ <select
188
+ className="select"
189
+ aria-label={t('settings.appearance')}
190
+ value={settings.themeMode}
191
+ onChange={(e) => setSettings({ themeMode: e.target.value as ThemeMode })}
192
+ >
193
+ <option value="system">{t('settings.system')}</option>
194
+ <option value="light">{t('settings.light')}</option>
195
+ <option value="dark">{t('settings.dark')}</option>
196
+ </select>
187
197
  </div>
188
198
  </div>
189
199
 
package/src/core/i18n.ts CHANGED
@@ -173,6 +173,9 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
173
173
  // ---- 设置 ----
174
174
  'settings.title': '设置',
175
175
  'settings.sub': '模型 API 配置、超参数与主题',
176
+ 'settings.toolExecution': '🛠️ 工具执行',
177
+ 'settings.yoloMode': 'YOLO 模式',
178
+ 'settings.yoloModeDesc': '自动批准工具修改和 Shell 执行,无需逐次确认。仅在信任角色和技能时开启。',
176
179
  'settings.genParams': '🎛 生成参数',
177
180
  'settings.tempDesc': '创造性与随机性',
178
181
  'settings.topPDesc': '核采样',
@@ -615,6 +618,9 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
615
618
 
616
619
  'settings.title': '設定',
617
620
  'settings.sub': '模型 API 設定、超參數與主題',
621
+ 'settings.toolExecution': '🛠️ 工具執行',
622
+ 'settings.yoloMode': 'YOLO 模式',
623
+ 'settings.yoloModeDesc': '自動批准工具修改和 Shell 執行,無需逐次確認。僅在信任角色和技能時開啟。',
618
624
  'settings.genParams': '🎛 產生參數',
619
625
  'settings.tempDesc': '創造性與隨機性',
620
626
  'settings.topPDesc': '核採樣',
@@ -1048,6 +1054,9 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1048
1054
 
1049
1055
  'settings.title': 'Settings',
1050
1056
  'settings.sub': 'Model API config, hyperparameters and theme',
1057
+ 'settings.toolExecution': '🛠️ Tool execution',
1058
+ 'settings.yoloMode': 'YOLO mode',
1059
+ 'settings.yoloModeDesc': 'Automatically approve tool changes and Shell commands without asking each time. Only enable for trusted characters and skills.',
1051
1060
  'settings.genParams': '🎛 Generation parameters',
1052
1061
  'settings.tempDesc': 'Creativity & randomness',
1053
1062
  'settings.topPDesc': 'Nucleus sampling',
@@ -1403,4 +1412,4 @@ export function useT() {
1403
1412
  /** 静态翻译:读当前语言快照(store / 工具执行器使用) */
1404
1413
  export function translate(key: string, params?: Record<string, string | number>): string {
1405
1414
  return translateFor(currentLang, key, params);
1406
- }
1415
+ }
@@ -9,6 +9,14 @@ import { translate } from './i18n';
9
9
 
10
10
  const OPENAI_TIMEOUTS = { read: 60_000 };
11
11
 
12
+ interface ToolDelta {
13
+ id: string;
14
+ name: string;
15
+ args: string;
16
+ snapshotArgs: string | null;
17
+ lastEmitted: string;
18
+ }
19
+
12
20
  /**
13
21
  * 计算请求 URL 候选:先直连,若失败且开发服务器可用(存在 /api/ 代理),
14
22
  * 自动回退到同源代理地址。
@@ -92,7 +100,7 @@ async function attemptStream(
92
100
  const decoder = new TextDecoder();
93
101
 
94
102
  // Delta 工具调用按 index 组装
95
- const toolDeltas = new Map<number, { id: string; name: string; args: string; lastEmitted: string }>();
103
+ const toolDeltas = new Map<number, ToolDelta>();
96
104
  let buffer = '';
97
105
  while (true) {
98
106
  const { done, value } = await reader.read();
@@ -115,19 +123,15 @@ async function attemptStream(
115
123
  }
116
124
  }
117
125
 
118
- // 收尾:补发未完成的工具调用
119
- for (const [idx, td] of toolDeltas) {
120
- const id = td.id || fallbackToolCallId(idx);
121
- const nextEmit = `${id}\n${td.name}\n${td.args}`;
122
- if (td.lastEmitted === nextEmit) continue;
123
- td.lastEmitted = nextEmit;
124
- onChunk({
125
- type: 'tool_call',
126
- id,
127
- name: td.name,
128
- argJson: td.args,
129
- });
126
+ // Some compatible endpoints close after the final data line without a newline.
127
+ buffer += decoder.decode();
128
+ if (buffer.startsWith('data:')) {
129
+ const data = buffer.slice(5).trim();
130
+ if (data && data !== '[DONE]') handleDataLine(data, onChunk, toolDeltas);
130
131
  }
132
+
133
+ // 收尾:补发未完成的工具调用
134
+ emitFinalToolCalls(toolDeltas, onChunk);
131
135
  onChunk({ type: 'done' });
132
136
  return true;
133
137
  } catch (e) {
@@ -154,7 +158,7 @@ async function attemptStream(
154
158
  function handleDataLine(
155
159
  data: string,
156
160
  onChunk: (c: ChatStreamChunk) => void,
157
- toolDeltas: Map<number, { id: string; name: string; args: string; lastEmitted: string }>,
161
+ toolDeltas: Map<number, ToolDelta>,
158
162
  ): void {
159
163
  try {
160
164
  const json = JSON.parse(data);
@@ -186,8 +190,7 @@ function handleDataLine(
186
190
  }>;
187
191
  if (!choices) return;
188
192
  for (const choice of choices) {
189
- const delta = choice.delta;
190
- if (!delta) continue;
193
+ const delta = choice.delta ?? {};
191
194
 
192
195
  const thinking =
193
196
  delta.reasoning ?? delta.reasoning_content ?? delta.thinking_content ?? null;
@@ -206,7 +209,7 @@ function handleDataLine(
206
209
  if (delta.tool_calls) {
207
210
  for (const tc of delta.tool_calls) {
208
211
  const idx = tc.index ?? 0;
209
- const cur = toolDeltas.get(idx) ?? { id: '', name: '', args: '', lastEmitted: '' };
212
+ const cur = toolDeltas.get(idx) ?? { id: '', name: '', args: '', snapshotArgs: '', lastEmitted: '' };
210
213
  if (tc.id) cur.id = tc.id;
211
214
  if (!cur.id) cur.id = fallbackToolCallId(idx);
212
215
  if (tc.function?.name) {
@@ -216,7 +219,13 @@ function handleDataLine(
216
219
  }
217
220
  }
218
221
  if (tc.function?.arguments) {
219
- cur.args = mergeToolArguments(cur.args, tc.function.arguments);
222
+ const incoming = tc.function.arguments;
223
+ // Deltas are lossless. Repeated braces and prefixes are still data.
224
+ cur.args += incoming;
225
+ // Retain snapshot compatibility only while every fragment extends
226
+ // (or repeats) the previous one. Never mix snapshots with deltas.
227
+ cur.snapshotArgs = cur.snapshotArgs !== null && incoming.startsWith(cur.snapshotArgs)
228
+ ? incoming : null;
220
229
  }
221
230
  toolDeltas.set(idx, cur);
222
231
  if (cur.name) {
@@ -234,19 +243,7 @@ function handleDataLine(
234
243
  }
235
244
  }
236
245
  if (choice.finish_reason === 'tool_calls') {
237
- // 立即发出发射工具调用事件
238
- for (const [idx, td] of toolDeltas) {
239
- const id = td.id || fallbackToolCallId(idx);
240
- const nextEmit = `${id}\n${td.name}\n${td.args}`;
241
- if (td.lastEmitted === nextEmit) continue;
242
- td.lastEmitted = nextEmit;
243
- onChunk({
244
- type: 'tool_call',
245
- id,
246
- name: td.name,
247
- argJson: td.args,
248
- });
249
- }
246
+ emitFinalToolCalls(toolDeltas, onChunk);
250
247
  toolDeltas.clear();
251
248
  } else if (choice.finish_reason && choice.finish_reason !== 'stop') {
252
249
  // stop/reasoning/content_filter 等
@@ -257,12 +254,26 @@ function handleDataLine(
257
254
  }
258
255
  }
259
256
 
260
- function mergeToolArguments(current: string, incoming: string): string {
261
- if (!current) return incoming;
262
- if (!incoming || incoming === current) return current;
263
- if (incoming.startsWith(current)) return incoming;
264
- if (current.startsWith(incoming)) return current;
265
- return current + incoming;
257
+ function emitFinalToolCalls(toolDeltas: Map<number, ToolDelta>, onChunk: (c: ChatStreamChunk) => void): void {
258
+ for (const [idx, td] of toolDeltas) {
259
+ let argJson = td.args;
260
+ if (td.snapshotArgs !== null && td.snapshotArgs !== argJson) {
261
+ try {
262
+ JSON.parse(argJson);
263
+ } catch {
264
+ try {
265
+ // Only fall back from invalid deltas to a complete snapshot object.
266
+ const snapshot: unknown = JSON.parse(td.snapshotArgs);
267
+ if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) argJson = td.snapshotArgs;
268
+ } catch { /* Leave invalid arguments unchanged for the tool executor. */ }
269
+ }
270
+ }
271
+ const id = td.id || fallbackToolCallId(idx);
272
+ const nextEmit = `${id}\n${td.name}\n${argJson}`;
273
+ if (td.lastEmitted === nextEmit) continue;
274
+ td.lastEmitted = nextEmit;
275
+ onChunk({ type: 'tool_call', id, name: td.name, argJson });
276
+ }
266
277
  }
267
278
 
268
279
  /** 拆分 deepseek / kimi 风格的 思考→回答 标记 */
@@ -249,6 +249,7 @@ const DEFAULT_SETTINGS: AppSettings = {
249
249
  isStreaming: true,
250
250
  isThinkingModeEnabled: true,
251
251
  isToolCallsEnabled: true,
252
+ yoloMode: false,
252
253
  statsResetTime: null,
253
254
  };
254
255
 
@@ -282,6 +283,9 @@ export async function initDatabase(): Promise<void> {
282
283
  if (settings && settings.contextCompressionThreshold === undefined) {
283
284
  await db.settings.update(1, { contextCompressionThreshold: 'off' });
284
285
  }
286
+ if (settings && settings.yoloMode === undefined) {
287
+ await db.settings.update(1, { yoloMode: false });
288
+ }
285
289
  }
286
290
  const statsCount = await db.careerStats.count();
287
291
  if (statsCount === 0) {
@@ -644,4 +648,4 @@ async function migrateDisplayFileRename(): Promise<void> {
644
648
  /* 坏 JSON 不处理 */
645
649
  }
646
650
  }
647
- }
651
+ }
@@ -300,6 +300,10 @@ export const useStore = create<AppState>((set, get) => ({
300
300
  const next = { ...current, ...partial };
301
301
  await db.settings.put(next);
302
302
  set({ settings: next });
303
+ const pending = get().pendingConfirmation;
304
+ if (next.yoloMode === true && pending && pending.kind !== 'limit') {
305
+ get().resolveConfirmation(true);
306
+ }
303
307
  if (partial.language !== undefined) {
304
308
  setLanguage(next.language ?? null);
305
309
  // 切换语言后:内置角色与世界书文本一并本地化并刷新。
@@ -2342,8 +2346,11 @@ async function continueRegeneratedGroupLoop(
2342
2346
  return continueGroupConversation(sessionId);
2343
2347
  }
2344
2348
 
2345
- /** 挂起一个确认请求,直到用户在弹窗上应答(resolveConfirmation)。所有确认共用此唯一挂起点。 */
2349
+ /** 工具确认在 YOLO 模式下自动批准;执行限额仍挂起等待用户应答。 */
2346
2350
  function suspendConfirmation(req: ToolConfirmationRequest): Promise<boolean> {
2351
+ if (req.kind !== 'limit' && useStore.getState().settings?.yoloMode === true) {
2352
+ return Promise.resolve(true);
2353
+ }
2347
2354
  return new Promise((resolve) => {
2348
2355
  confirmationDeferreds.set(req, { resolve });
2349
2356
  useStore.setState({ pendingConfirmation: req });
@@ -2416,4 +2423,4 @@ function displayRefForToolName(toolName: string, result: string): string | null
2416
2423
  title: payload.title,
2417
2424
  sessionId,
2418
2425
  });
2419
- }
2426
+ }
@@ -599,10 +599,19 @@
599
599
  font-weight: 600;
600
600
  color: var(--text-dim);
601
601
  }
602
- .set-row .s-desc {
602
+ .set-row .s-desc,
603
+ .param-table .s-desc {
603
604
  font-size: 11px;
604
605
  color: var(--text-faint);
605
606
  }
607
+ .settings-tool-row {
608
+ padding: 9px 4px;
609
+ }
610
+ .set-row.settings-tool-row .s-label {
611
+ font-size: 13px;
612
+ font-weight: normal;
613
+ color: var(--text);
614
+ }
606
615
  .range-row {
607
616
  display: flex;
608
617
  align-items: center;
@@ -53,6 +53,8 @@ export interface AppSettings {
53
53
  isStreaming: boolean;
54
54
  isThinkingModeEnabled: boolean;
55
55
  isToolCallsEnabled: boolean;
56
+ /** 自动批准工具执行确认(YOLO);默认关闭。 */
57
+ yoloMode: boolean;
56
58
  statsResetTime: number | null;
57
59
  }
58
60