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 +5 -0
- package/README.zh.md +5 -0
- package/bin/cli.js +5 -0
- package/package.json +4 -1
- package/sandbox-server.mjs +38 -9
- package/src/components/SettingsView.tsx +41 -31
- package/src/core/i18n.ts +10 -1
- package/src/core/openai.ts +48 -37
- package/src/db/database.ts +5 -1
- package/src/store/store.ts +9 -2
- package/src/theme/views.css +10 -1
- package/src/types/models.ts +2 -0
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.
|
|
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
|
}
|
package/sandbox-server.mjs
CHANGED
|
@@ -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
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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(
|
|
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
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
}
|
package/src/core/openai.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 风格的 思考→回答 标记 */
|
package/src/db/database.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/store/store.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|
package/src/theme/views.css
CHANGED
|
@@ -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;
|