dsh-agentic-pet 0.3.2 → 0.4.1

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.
@@ -12,6 +12,7 @@ window.__ModuleLoader__.load({
12
12
  var useState = React.useState
13
13
  var useEffect = React.useEffect
14
14
  var useCallback = React.useCallback
15
+ var useRef = React.useRef
15
16
 
16
17
  var READ = '/api/pet-settings/read'
17
18
  var UPDATE = '/api/pet-settings/update'
@@ -60,6 +61,12 @@ window.__ModuleLoader__.load({
60
61
  var busyState = useState(false)
61
62
  var busy = busyState[0]
62
63
  var setBusy = busyState[1]
64
+ // 语音朗读的声音选择器:null = 收起;展开后 {loading, error, items, search}。
65
+ var voiceState = useState(null)
66
+ var voice = voiceState[0]
67
+ var setVoice = voiceState[1]
68
+ var previewAudioRef = useRef(null)
69
+ var keyInputRef = useRef(null)
63
70
 
64
71
  var refresh = useCallback(function () {
65
72
  fetch(READ, { credentials: 'same-origin' })
@@ -91,14 +98,81 @@ window.__ModuleLoader__.load({
91
98
  var settings = (data && data.settings) || {}
92
99
  var pro = settings.proactivity || {}
93
100
  var iv = settings.innerVoice || {}
101
+ var tts = settings.tts || {}
94
102
  var debug = settings.debug || {}
95
103
  var affection = (data && data.affection) || {}
96
104
  var pets = (data && data.pets) || []
97
105
  if (!(pets && pets.length) && data && data.pet) pets = [Object.assign({ id: 'electron' }, data.pet)]
98
106
  var moodOf = function (a) { return a >= 80 ? '开心' : a >= 40 ? '满足' : '平静' }
107
+ // 显示名:用户自定义(settings.names)优先,缺省回落内置名;
108
+ // 宠物区改名后好感度区自动跟随(同一状态、同一渲染趟)。
109
+ var defaultName = function (id) { return id === 'electron' ? '鲸鱼娘' : id === 'pure' ? 'Pure 宠物' : id }
110
+ var BOND_TIERS = [[0, "陌生"], [5, "认识"], [15, "熟了"], [40, "亲近"], [90, "离不开"], [180, "只有你"]]
111
+ var bondTier = function (days) {
112
+ var name = "陌生"
113
+ BOND_TIERS.forEach(function (t) { if (days >= t[0]) name = t[1] })
114
+ return name
115
+ }
116
+ var petName = function (id) {
117
+ var custom = settings.names && settings.names[id]
118
+ return custom ? String(custom) : defaultName(id)
119
+ }
120
+ // 提交一次改名(失焦/回车调用):与当前值比较,无变化零开销;
121
+ // 空值删键(回落默认)。一次编辑 = 至多一次 update = 至多一条 [改名]。
122
+ var commitName = function (id, raw) {
123
+ var v = (raw || '').trim()
124
+ var next = Object.assign({}, (settings.names || {}))
125
+ var current = next[id]
126
+ if (v !== '' && v !== defaultName(id)) {
127
+ if (current === v) return
128
+ next[id] = v
129
+ } else {
130
+ if (current === undefined) return
131
+ delete next[id]
132
+ }
133
+ update({ names: next })
134
+ }
99
135
 
100
136
  function update(patch, doneMessage) { act(UPDATE, { patch: patch }, doneMessage) }
101
137
 
138
+ // ── 语音朗读(BreezeBlue TTS)的取声/试听 ──
139
+ // 列表走宿主代理路由(浏览器直连 api.breeze.blue 会被 CORS 拦下,
140
+ // key 也不该进页面日志);试听直接播 preview_url,不经过本插件。
141
+ var loadVoices = function (search) {
142
+ setVoice({ loading: true, error: '', items: (voice && voice.items) || [], search: search || '' })
143
+ fetch('/api/pet-settings/tts/voices?search=' + encodeURIComponent(search || ''), { credentials: 'same-origin' })
144
+ .then(function (r) { return r.json() })
145
+ .then(function (d) {
146
+ if (d && d.error) setVoice({ loading: false, error: d.error, items: [], search: search || '' })
147
+ else setVoice({ loading: false, error: '', items: (d && d.voices) || [], search: search || '' })
148
+ })
149
+ .catch(function (e) {
150
+ setVoice({ loading: false, error: '请求失败: ' + ((e && e.message) || e), items: [], search: search || '' })
151
+ })
152
+ }
153
+ var playPreview = function (url) {
154
+ if (!url) return
155
+ try {
156
+ if (previewAudioRef.current) previewAudioRef.current.pause()
157
+ var audio = new Audio(url)
158
+ previewAudioRef.current = audio
159
+ audio.play().catch(function () { /* 试听失败不打断配置 */ })
160
+ } catch (e) { /* ignore */ }
161
+ }
162
+ // 取声音列表前先把 key 输入框里未提交的值落盘(输入框是失焦提交,
163
+ // “输完 key 立刻点选择声音”时 blur 的 POST 会和列表 GET 赛跑——
164
+ // 代理读到旧文件,401。这里等提交完成再查。)
165
+ var flushKeyThen = function (done) {
166
+ var el = keyInputRef.current
167
+ var current = el ? String(el.value || '').trim() : ''
168
+ if (current === '' || current === (tts.apiKey || '')) { done(); return }
169
+ fetch(UPDATE, {
170
+ method: 'POST', credentials: 'same-origin',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ patch: { tts: { apiKey: current } } }),
173
+ }).then(function () { done() }, function () { done() })
174
+ }
175
+
102
176
  return h('div', { style: { width: '100%', maxWidth: 720 } },
103
177
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, margin: '2px 0 10px' } },
104
178
  h('span', { style: { fontWeight: 700, fontSize: 15 } }, '桌宠'),
@@ -111,9 +185,13 @@ window.__ModuleLoader__.load({
111
185
  h(Section, { title: '宠物' },
112
186
  pets.length
113
187
  ? pets.map(function (p) {
114
- var name = p.id === 'electron' ? 'TS 宠物' : p.id === 'pure' ? 'Pure 宠物' : String(p.id)
115
188
  return h('div', { key: p.id, style: row },
116
- h('span', { style: { fontWeight: 700 } }, name),
189
+ // Uncontrolled + commit-on-blur/Enter: typing is LOCAL until the
190
+ // edit completes — a per-keystroke update would fire a [改名]
191
+ // turn per character.清空 + 失焦 = 恢复默认名。
192
+ h('input', { style: Object.assign({}, inputStyle, { width: 120, fontWeight: 700 }), defaultValue: petName(p.id), title: '改名后点击空白处或回车生效;清空恢复默认',
193
+ onBlur: function (e) { commitName(p.id, e.target.value) },
194
+ onKeyDown: function (e) { if (e.key === 'Enter') { commitName(p.id, e.target.value); e.target.blur() } } }),
117
195
  p.online
118
196
  ? h('span', null, '在线',
119
197
  h('span', { style: muted }, 'pid ' + String(p.pid ?? '?')),
@@ -121,7 +199,7 @@ window.__ModuleLoader__.load({
121
199
  p.animation ? h('span', { style: muted }, String(p.animation)) : null)
122
200
  : h('span', { style: muted }, '离线'))
123
201
  })
124
- : h('div', null, '没有在线的宠物', h(Hint, null, '启动 DeepSeek Harness 后 TS 宠物会自动上桌;Pure 宠物需自行启动。'))),
202
+ : h('div', null, '没有在线的宠物', h(Hint, null, '启动 DeepSeek Harness 后鲸鱼娘会自动上桌;Pure 宠物需自行启动。'))),
125
203
 
126
204
  // ── 好感度(每宠独立账本) ──
127
205
  h(Section, { title: '好感度' },
@@ -133,18 +211,20 @@ window.__ModuleLoader__.load({
133
211
  if (!ids.length) return h('div', { style: muted }, '还没有互动记录')
134
212
  return ids.map(function (id) {
135
213
  var book = (affection && affection[id]) || { affection: 0, total: 0 }
136
- var name = id === 'electron' ? 'TS 宠物' : id === 'pure' ? 'Pure 宠物' : id
214
+ var name = petName(id)
137
215
  return h('div', { key: id, style: row },
138
216
  h('span', { style: { fontWeight: 700 } }, name),
139
217
  h('span', { style: { fontSize: 18, fontWeight: 700 } }, String(book.affection ?? 0)),
140
- h('span', { style: muted }, '/ 100 · ' + moodOf(book.affection ?? 0)),
218
+ h('span', { style: muted }, '/ 100 · ' + moodOf(book.affection ?? 0) + ' · 羁绊:' + bondTier(book.bond ? (book.bond.value ?? book.bond.days ?? 0) : 0) + '(' + String(book.bond ? (book.bond.value ?? book.bond.days ?? 0) : 0) + ' 天)'),
141
219
  h('span', { style: muted }, '累计 ' + String(book.total ?? 0) + ' 次'))
142
220
  })
143
221
  })(),
144
222
  h('div', { style: row }, h(SButton, { variant: 'danger', disabled: busy, onClick: function () {
145
223
  if (window.confirm('清空所有好感度账本?此操作不可撤销。')) act('/api/pet-settings/reset-affection', {}, '账本已清空')
146
224
  } }, '重置账本'),
147
- h('span', { style: muted }, '每跨 10 点该宠物会自主庆祝一次'))),
225
+ h('span', { style: muted }, '每 10 次触摸该宠物会自主庆祝一次'))),
226
+
227
+
148
228
 
149
229
  // ── 内心戏(宠物自主说话)──
150
230
  h(Section, { title: h('span', null, '自主表达 ', h('span', { style: { fontSize: 11, fontWeight: 400, color: 'rgba(127,127,127,.9)' } }, '模型驱动')) },
@@ -190,11 +270,11 @@ window.__ModuleLoader__.load({
190
270
  marginLeft: side === 'right' ? -1 : 0 }
191
271
  }
192
272
  return h('div', { style: { display: 'inline-flex' } },
193
- h('button', { style: seg(iv.preset !== 'pet', 'left'), onClick: function () {
194
- if (iv.preset !== 'pet-inner-voice') update({ innerVoice: { preset: 'pet-inner-voice' } })
273
+ h('button', { style: seg(iv.preset === 'whale598', 'left'), onClick: function () {
274
+ if (iv.preset !== 'whale598') update({ innerVoice: { preset: 'whale598' } })
195
275
  } }, '精简(仅表达)'),
196
- h('button', { style: seg(iv.preset === 'pet', 'right'), onClick: function () {
197
- if (iv.preset !== 'pet') update({ innerVoice: { preset: 'pet' } })
276
+ h('button', { style: seg(iv.preset === 'whale598-standard', 'right'), onClick: function () {
277
+ if (iv.preset !== 'whale598-standard') update({ innerVoice: { preset: 'whale598-standard' } })
198
278
  } }, '普通(含工作工具)'))
199
279
  })()),
200
280
  h('div', { style: row }, '指定会话(高级)',
@@ -220,6 +300,61 @@ window.__ModuleLoader__.load({
220
300
  '分钟'),
221
301
  h(Hint, null, '自由活动:宠物空闲时随机播放一段小动画(纯动画,无模型参与),改动即时推送。')),
222
302
 
303
+ // ── 语音朗读(BreezeBlue TTS)──
304
+ h(Section, { title: h('span', null, '语音朗读 ', h('span', { style: { fontSize: 11, fontWeight: 400, color: 'rgba(127,127,127,.9)' } }, 'BreezeBlue TTS')) },
305
+ h('div', { style: row }, '朗读开关', h(Toggle, { value: tts.enabled === true, onChange: function () {
306
+ update({ tts: { enabled: !(tts.enabled === true) } })
307
+ } }),
308
+ h('span', { style: muted }, '宠物展示说话气泡的同时读出来(语音就绪立即一起显示;最多等 5 秒,超时只展示气泡)')),
309
+ // key / 声音 / 语速音量均为 commit-on-blur:输入期间不落盘,
310
+ // 清空 = 删除配置(宠物回落为不朗读)。
311
+ h('div', { style: row },
312
+ 'API Key',
313
+ h('input', { key: 'tts-key-' + String(tts.apiKey || ''), ref: function (el) { keyInputRef.current = el }, style: Object.assign({}, inputStyle, { width: 260 }), type: 'password', defaultValue: tts.apiKey || '', placeholder: 'brz_...(breezeblue.ai 申请)',
314
+ onBlur: function (e) { var v = e.target.value.trim(); if (v !== (tts.apiKey || '')) update({ tts: { apiKey: v } }) },
315
+ onKeyDown: function (e) { if (e.key === 'Enter') e.target.blur() } }),
316
+ h('a', { href: 'https://breezeblue.ai/app/developer/api-keys', target: '_blank', rel: 'noopener noreferrer',
317
+ style: Object.assign(btnStyle('primary'), { display: 'inline-block', textDecoration: 'none' }), title: '打开 BreezeBlue 开发者控制台的 API Keys 页面' }, '获取 Key')),
318
+ h('div', { style: row },
319
+ '声音',
320
+ h('input', { key: 'tts-voice-' + String(tts.voiceId || ''), style: Object.assign({}, inputStyle, { width: 210, fontFamily: 'monospace' }), defaultValue: tts.voiceId || '', placeholder: 'voice_id,点右侧选择',
321
+ onBlur: function (e) { var v = e.target.value.trim(); if (v !== (tts.voiceId || '')) update({ tts: { voiceId: v } }) },
322
+ onKeyDown: function (e) { if (e.key === 'Enter') e.target.blur() } }),
323
+ h(SButton, { disabled: busy, onClick: function () {
324
+ if (voice) { setVoice(null); return }
325
+ flushKeyThen(function () { loadVoices('') })
326
+ } }, voice ? '收起' : '选择声音')),
327
+ voice
328
+ ? h('div', { style: { border: '1px solid rgba(127,127,127,.3)', borderRadius: 8, padding: '8px 10px', margin: '4px 0 6px' } },
329
+ h('div', { style: row },
330
+ h('input', { style: Object.assign({}, inputStyle, { width: 190 }), value: voice.search || '', placeholder: '搜索声音(名称/风格)',
331
+ onChange: function (e) { setVoice(Object.assign({}, voice, { search: e.target.value })) },
332
+ onKeyDown: function (e) { if (e.key === 'Enter') loadVoices(voice.search) } }),
333
+ h(SButton, { disabled: voice.loading, onClick: function () { flushKeyThen(function () { loadVoices(voice.search) }) } }, '搜索')),
334
+ voice.error ? h('div', { style: { color: '#ff7369', fontSize: 12, margin: '2px 0' } }, voice.error) : null,
335
+ h('div', { style: { maxHeight: 220, overflowY: 'auto' } },
336
+ (voice.items || []).map(function (v) {
337
+ return h('div', { key: v.voice_id, style: Object.assign({}, row, { borderBottom: '1px solid rgba(127,127,127,.15)' }) },
338
+ h('span', { style: { fontWeight: 600 } }, v.name || v.voice_id),
339
+ h('span', { style: muted }, [v.language_code, v.gender, (v.tone || []).join('/')].filter(Boolean).join(' · ')),
340
+ v.voice_id === tts.voiceId ? h('span', { style: { color: '#3ddc84', fontSize: 12, fontWeight: 700 } }, '当前') : null,
341
+ h('span', { style: { flex: 1 } }),
342
+ v.preview_url ? h(SButton, { onClick: function () { playPreview(v.preview_url) } }, '试听') : null,
343
+ h(SButton, { variant: 'primary', onClick: function () { update({ tts: { voiceId: v.voice_id } }, '已选用 ' + (v.name || v.voice_id)) } }, '选用'))
344
+ }),
345
+ (!voice.loading && !voice.error && (voice.items || []).length === 0) ? h('div', { style: muted }, '没有匹配的声音') : null))
346
+ : null,
347
+ h('div', { style: row },
348
+ '语速',
349
+ h('input', { key: 'tts-speed-' + String(tts.speed ?? 1), style: inputStyle, defaultValue: String(tts.speed ?? 1), title: '0.5 – 2.0,1 = 正常',
350
+ onBlur: function (e) { var v = parseFloat(e.target.value); if (Number.isFinite(v) && v !== (tts.speed ?? 1)) update({ tts: { speed: Math.min(2, Math.max(0.5, v)) } }) },
351
+ onKeyDown: function (e) { if (e.key === 'Enter') e.target.blur() } }),
352
+ '音量',
353
+ h('input', { key: 'tts-volume-' + String(tts.volume ?? 1), style: inputStyle, defaultValue: String(tts.volume ?? 1), title: '0.01 – 2.0,1 = 默认',
354
+ onBlur: function (e) { var v = parseFloat(e.target.value); if (Number.isFinite(v) && v !== (tts.volume ?? 1)) update({ tts: { volume: Math.min(2, Math.max(0.01, v)) } }) },
355
+ onKeyDown: function (e) { if (e.key === 'Enter') e.target.blur() } })),
356
+ h(Hint, null, '接入 BreezeBlue(breezeblue.ai)文字转语音:说话气泡显示时用所选声音朗读。key 只保存在本机 pet-settings.json;改动即时生效,无需重启宠物。气泡等语音的超时上限可在 pet-settings.json 的 tts.holdMs 调整(0–8000 毫秒,默认 5000;语音先就绪就立刻展示,超时则只展示气泡)。')),
357
+
223
358
  // ── 调试 ──
224
359
  h(Section, { title: '调试' },
225
360
  h('div', { style: row }, 'CDP 调试端口 (9222)', h(Toggle, { value: debug.cdp === true, onChange: function () {
package/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { i as installPetDriver, n as installPetService } from "./service-Vj-lLpIv.js";
1
+ import { c as globalFilePath, d as readJsonl, f as readPetName, h as writePetName, i as installPetDriver, l as readAffectionRaw, m as writeGlobalJson, n as installPetService, p as writeAffectionRaw, r as petHome, s as eventsFile, u as readGlobalJson } from "./service-BjTawwUn.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { spawn } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
6
+ import { mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
20
20
  * dispatch — the webserver throws on duplicate path registration.
21
21
  * @module dsh-agentic-pet/settings-page
22
22
  */
23
+ /** The touch ledger the driver owns; reset writes the driver's empty shape. */
23
24
  const DEFAULT_SETTINGS = {
24
25
  proactivity: {
25
26
  enabled: false,
@@ -30,16 +31,16 @@ const DEFAULT_SETTINGS = {
30
31
  },
31
32
  innerVoice: {
32
33
  enabled: true,
33
- preset: "pet-inner-voice",
34
+ preset: "whale598",
34
35
  wakeIntervalMinutes: 2,
35
36
  speakProbability: .5,
36
37
  touchReplyProbability: .3,
37
38
  touchReplyCooldownSeconds: 30,
38
39
  boundSessions: {}
39
40
  },
41
+ tts: { enabled: false },
40
42
  debug: { cdp: false }
41
43
  };
42
- /** The touch ledger the driver owns; reset writes the driver's empty shape. */
43
44
  const EMPTY_LEDGER = {
44
45
  affection: 0,
45
46
  total: 0,
@@ -141,10 +142,30 @@ function mergeSettings(current, patch) {
141
142
  const d = patchDebug;
142
143
  if (typeof d.cdp === "boolean") debug.cdp = d.cdp;
143
144
  }
145
+ const tts = { ...current.tts };
146
+ const patchTts = patch.tts;
147
+ if (typeof patchTts === "object" && patchTts !== null) {
148
+ const t = patchTts;
149
+ if (typeof t.enabled === "boolean") tts.enabled = t.enabled;
150
+ const setText = (key) => {
151
+ const value = t[key];
152
+ if (typeof value !== "string") return;
153
+ const trimmed = value.trim();
154
+ if (trimmed === "") Reflect.deleteProperty(tts, key);
155
+ else tts[key] = trimmed;
156
+ };
157
+ setText("apiKey");
158
+ setText("voiceId");
159
+ setText("modelId");
160
+ setText("languageCode");
161
+ if (typeof t.speed === "number" && Number.isFinite(t.speed)) tts.speed = Math.min(2, Math.max(.5, t.speed));
162
+ if (typeof t.volume === "number" && Number.isFinite(t.volume)) tts.volume = Math.min(2, Math.max(.01, t.volume));
163
+ }
144
164
  return {
145
165
  ...current,
146
166
  proactivity,
147
167
  innerVoice,
168
+ tts,
148
169
  debug
149
170
  };
150
171
  }
@@ -168,9 +189,7 @@ function openPathInFileManager(path) {
168
189
  * @param home - the dsh home holding the settings/ledger/manifest files.
169
190
  */
170
191
  function installSettingsPage(ctx, home, pet) {
171
- const settingsPath = join(home, "pet-settings.json");
172
- const ledgerPath = join(home, "pet-affection.json");
173
- const windowPath = join(home, "pet-window.json");
192
+ const windowPath = globalFilePath(home, "pet-window.json");
174
193
  /** Summarize EVERY live pet (id, online flag, normalized /status). */
175
194
  const petStatus = async () => {
176
195
  return { pets: (await Promise.all((await pet.list()).map((entry) => pet.snapshot(entry)))).map(({ id, online, ...rest }) => ({
@@ -180,11 +199,7 @@ function installSettingsPage(ctx, home, pet) {
180
199
  })) };
181
200
  };
182
201
  const readSettings = async () => {
183
- try {
184
- return mergeSettings(JSON.parse(await readFile(settingsPath, "utf8")), {});
185
- } catch {
186
- return mergeSettings({}, {});
187
- }
202
+ return mergeSettings(await readGlobalJson(home, "pet-settings.json"), {});
188
203
  };
189
204
  const routeByPath = /* @__PURE__ */ new Map();
190
205
  const route = (method, path, handler) => {
@@ -220,22 +235,46 @@ function installSettingsPage(ctx, home, pet) {
220
235
  route("GET", "/api/pet-settings/read", async (_req, res) => {
221
236
  const [settings, windowPosition, status] = await Promise.all([
222
237
  readSettings(),
223
- readFile(windowPath, "utf8").then((raw) => JSON.parse(raw), () => null),
238
+ readGlobalJson(home, "pet-window.json").then((raw) => Object.keys(raw).length > 0 ? raw : null),
224
239
  petStatus()
225
240
  ]);
226
241
  const affection = {};
227
- const legacyBook = await readFile(ledgerPath, "utf8").then((raw) => JSON.parse(raw), () => null);
228
242
  const bookIds = new Set(status.pets.map((pet) => String(pet.id)));
229
243
  bookIds.add("electron");
230
244
  for (const id of bookIds) {
231
- const book = await readFile(id === "electron" && legacyBook !== null ? ledgerPath : join(home, "pet-affection.d", `${id}.json`), "utf8").then((raw) => JSON.parse(raw), () => null);
245
+ const book = await readAffectionRaw(home, id);
232
246
  if (book !== null) affection[id] = book;
233
247
  }
248
+ const names = {};
249
+ for (const id of bookIds) {
250
+ const label = await readPetName(home, id);
251
+ if (label !== void 0) names[id] = label;
252
+ }
253
+ const composed = {
254
+ ...settings,
255
+ names
256
+ };
257
+ const touchTotals = {};
258
+ for (const id of bookIds) {
259
+ const events = await readJsonl(eventsFile(home, id), 600);
260
+ const totals = {
261
+ total: 0,
262
+ byPart: {}
263
+ };
264
+ for (const event of events) {
265
+ if (event.type !== "touch") continue;
266
+ const part = typeof event.part === "string" ? event.part : "miss";
267
+ totals.total += 1;
268
+ totals.byPart[part] = (totals.byPart[part] ?? 0) + 1;
269
+ }
270
+ if (totals.total > 0) touchTotals[id] = totals;
271
+ }
234
272
  writeJson(res, 200, {
235
- settings,
273
+ settings: composed,
236
274
  affection,
237
275
  windowPosition,
238
- pets: status.pets
276
+ pets: status.pets,
277
+ touchTotals
239
278
  });
240
279
  });
241
280
  route("POST", "/api/pet-settings/update", async (req, res) => {
@@ -245,8 +284,13 @@ function installSettingsPage(ctx, home, pet) {
245
284
  writeJson(res, 400, { error: "body must be {patch:{...}}" });
246
285
  return;
247
286
  }
287
+ const patchNames = patch.names;
288
+ if (typeof patchNames === "object" && patchNames !== null) {
289
+ for (const [petId, label] of Object.entries(patchNames)) await writePetName(home, petId, typeof label === "string" && label.trim() !== "" ? label : null);
290
+ delete patch.names;
291
+ }
248
292
  const next = mergeSettings(await readSettings(), patch);
249
- await writeFile(settingsPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
293
+ await writeGlobalJson(home, "pet-settings.json", next);
250
294
  ctx.emit("pet-settings/changed");
251
295
  writeJson(res, 200, {
252
296
  ok: true,
@@ -254,14 +298,14 @@ function installSettingsPage(ctx, home, pet) {
254
298
  });
255
299
  });
256
300
  route("POST", "/api/pet-settings/reset-affection", async (_req, res) => {
257
- await writeFile(ledgerPath, `${JSON.stringify(EMPTY_LEDGER)}\n`, "utf8");
301
+ await writeAffectionRaw(home, "electron", EMPTY_LEDGER);
258
302
  writeJson(res, 200, {
259
303
  ok: true,
260
304
  affection: EMPTY_LEDGER
261
305
  });
262
306
  });
263
307
  route("POST", "/api/pet-settings/reset-position", async (_req, res) => {
264
- if (existsSync(windowPath)) await unlink(windowPath);
308
+ for (const p of [windowPath, join(home, "pet-window.json")]) await rm(p, { force: true });
265
309
  writeJson(res, 200, { ok: true });
266
310
  });
267
311
  route("POST", "/api/pet-settings/open-logs", async (_req, res) => {
@@ -273,6 +317,59 @@ function installSettingsPage(ctx, home, pet) {
273
317
  opened: target
274
318
  });
275
319
  });
320
+ route("GET", "/api/pet-settings/tts/voices", async (req, res) => {
321
+ const settings = await readSettings();
322
+ const apiKey = typeof settings.tts?.apiKey === "string" ? settings.tts.apiKey : "";
323
+ if (apiKey === "") {
324
+ writeJson(res, 400, { error: "尚未配置 BreezeBlue API key" });
325
+ return;
326
+ }
327
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
328
+ const params = new URLSearchParams();
329
+ for (const key of [
330
+ "search",
331
+ "language_code",
332
+ "gender",
333
+ "accent"
334
+ ]) {
335
+ const value = url.searchParams.get(key);
336
+ if (value !== null && value !== "") params.set(key, value);
337
+ }
338
+ const rawPageSize = url.searchParams.get("page_size");
339
+ const pageSize = rawPageSize !== null ? clampNumber(Number(rawPageSize), 1, 100) : void 0;
340
+ params.set("page_size", String(pageSize ?? 30));
341
+ try {
342
+ const response = await fetch(`https://api.breeze.blue/v1/voices?${params.toString()}`, {
343
+ headers: { "xi-api-key": apiKey },
344
+ signal: AbortSignal.timeout(1e4)
345
+ });
346
+ if (!response.ok) {
347
+ const detail = await response.text().catch(() => "");
348
+ writeJson(res, 502, { error: response.status === 401 ? "BreezeBlue 拒绝了这个 key(401):请到 breezeblue.ai 的 Developer Console 复制 brz_ 开头的 API key" : `BreezeBlue HTTP ${String(response.status)}${detail ? `: ${detail.slice(0, 200)}` : ""}` });
349
+ return;
350
+ }
351
+ const raw = await response.json();
352
+ const voices = (Array.isArray(raw.voices) ? raw.voices : []).map((entry) => {
353
+ const voice = entry;
354
+ const str = (key) => typeof voice[key] === "string" ? voice[key] : null;
355
+ return {
356
+ voice_id: str("voice_id") ?? "",
357
+ name: str("name") ?? "",
358
+ language_code: str("language_code"),
359
+ gender: str("gender"),
360
+ tone: Array.isArray(voice.tone) ? voice.tone.filter((item) => typeof item === "string").slice(0, 3) : [],
361
+ accent: str("accent"),
362
+ preview_url: str("preview_url")
363
+ };
364
+ }).filter((voice) => voice.voice_id !== "");
365
+ writeJson(res, 200, {
366
+ voices,
367
+ total: typeof raw.total === "number" ? raw.total : voices.length
368
+ });
369
+ } catch (error) {
370
+ writeJson(res, 502, { error: `声音列表请求失败: ${error instanceof Error ? error.message : String(error)}` });
371
+ }
372
+ });
276
373
  }
277
374
  //#endregion
278
375
  //#region lib/types/index.js
@@ -311,7 +408,7 @@ const inject = [
311
408
  "workspaceController"
312
409
  ];
313
410
  /** The URL path prefix the pet renderer is served under. No trailing slash. */
314
- const PET_PATH_PREFIX = "/pet";
411
+ const PET_PATH_PREFIX = "/whale598";
315
412
  /**
316
413
  * Locator file naming this runtime's authenticated root for the pet carrier.
317
414
  * The desktop shell keeps its authenticated URL in process memory only, so a
@@ -320,7 +417,7 @@ const PET_PATH_PREFIX = "/pet";
320
417
  * publisher, then falls back to spawning its own profile.
321
418
  */
322
419
  function petLocatorPath() {
323
- return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "pet-surface-url.json");
420
+ return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "whale598", "pet-surface-url.json");
324
421
  }
325
422
  const HTML_MIME = "text/html; charset=utf-8";
326
423
  const MIME = {
@@ -367,7 +464,7 @@ function bytesBody(bytes) {
367
464
  /**
368
465
  * Answer one GET or HEAD `/pet` request. The URL path is the full server
369
466
  * pathname (`/pet` prefix included); it must be exactly the prefix or stay
370
- * under it. The index entry (`/pet`, `/pet/`, `/pet/index.html`) passes
467
+ * under it. The index entry (`/whale598`, `/whale598/`, `/whale598/index.html`) passes
371
468
  * Connection's browser authentication; every other file is public with a weak
372
469
  * ETag and `cache-control: no-cache`, answering a matching `If-None-Match`
373
470
  * with 304. Traversal outside the dist root is 403, missing paths are 404, a
@@ -379,7 +476,8 @@ function bytesBody(bytes) {
379
476
  */
380
477
  async function servePetStatic(request, distRoot, distIndex, authorizeIndex) {
381
478
  const pathname = decodeURIComponent(new URL(request.url).pathname);
382
- const target = resolve(normalize(join(distRoot, pathname === "/pet" || pathname === `/pet/` ? "/" : pathname.slice(4))));
479
+ if (!(pathname === "/whale598" || pathname.startsWith(`/whale598/`))) return new Response(null, { status: 404 });
480
+ const target = resolve(normalize(join(distRoot, pathname === "/whale598" || pathname === `/whale598/` ? "/" : pathname.slice(9))));
383
481
  if (target !== distRoot && !target.startsWith(distRoot + sep)) return new Response(null, { status: 403 });
384
482
  let body;
385
483
  let headers;
@@ -587,9 +685,10 @@ function apply(ctx) {
587
685
  const settled = ctx.get("loader")?.await();
588
686
  if (settled === void 0) settledReady();
589
687
  else settled.then(settledReady).catch(() => {});
590
- const pet = installPetService(ctx, dirname(petLocatorPath()));
591
- installPetDriver(ctx, dirname(petLocatorPath()), pet);
592
- installSettingsPage(ctx, dirname(petLocatorPath()), pet);
688
+ const home = petHome();
689
+ const pet = installPetService(ctx, home);
690
+ installPetDriver(ctx, home, pet);
691
+ installSettingsPage(ctx, home, pet);
593
692
  ctx.effect(() => () => {
594
693
  readFile(petLocatorPath(), "utf8").then((raw) => {
595
694
  if (JSON.parse(raw).pid === process.pid) return unlink(petLocatorPath());