dlaoshi-outfit 2.1.4 → 2.2.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.
- package/CHANGELOG.md +23 -0
- package/README.md +17 -0
- package/client.js +103 -3
- package/index.js +109 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.1 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
立牌只出现在聊天页。
|
|
6
|
+
|
|
7
|
+
- 轮询判断当前是否在聊天页:设置/插件等以 `[role="dialog"][aria-modal="true"]` 打开的页面,
|
|
8
|
+
或会话区不可见时,立牌整体隐藏(`display:none`),不再浮在二级页面之上
|
|
9
|
+
- 回到聊天页自动恢复,气泡状态保留
|
|
10
|
+
- 客户端测试新增 `data-on-chat` 断言
|
|
11
|
+
|
|
12
|
+
## 2.2.0 — 2026-10-22
|
|
13
|
+
|
|
14
|
+
重磅功能:**称呼与语气**(默认称呼「主人」)。
|
|
15
|
+
|
|
16
|
+
- 宿主通过官方 `ctx.systemPrompt.section()` 注入一段「称呼规则」,`text` 是函数,
|
|
17
|
+
改设置后**下一个模型步骤立刻生效**,不用重启
|
|
18
|
+
- 只影响称呼与句末语气词;提示词里明确禁止改变回答的事实、结论、结构、代码、
|
|
19
|
+
工具调用、专业程度与长度
|
|
20
|
+
- 设置 › 通用新增一行:称呼输入框 + 两个开关(启用称呼 / 语气词)
|
|
21
|
+
- 设置持久化到 `~/.dsh/fatfish-outfit/persona.json`
|
|
22
|
+
- 路由 `/fatfish/persona` 支持 GET/POST,仅回环可用
|
|
23
|
+
- 测试新增:提示词段落注册、默认「主人」、改名即时生效、语气开关、禁用后段落为空、
|
|
24
|
+
非回环 403;测试使用临时 HOME,不再污染真实配置
|
|
25
|
+
|
|
3
26
|
## 2.1.4 — 2026-10-22
|
|
4
27
|
|
|
5
28
|
按 DSH 的「可逆副作用」规范重写挂载流程,支持热重组。
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
| 🫧 点击立牌 | q 弹回弹 + 气泡膜"啵"声 + 单独一个余额气泡(读取官方 `deepseekAccount`) |
|
|
29
29
|
| 🖼️ 聊天背景 | 用你自己的插画做会话区壁纸,主题自适应蒙版保证可读性 |
|
|
30
30
|
| 🧊 液态玻璃 | 输入框磨砂玻璃 + 高光描边(不再有黑气泡,消息为纯文字) |
|
|
31
|
+
| 🗣️ 称呼与语气 | 设置 › 通用 里设置它怎么称呼你(默认「主人」),只改措辞不改回答方式 |
|
|
31
32
|
| ⚙️ 主题开关 | 设置 › 通用 里的滑动开关:一键启用 / 禁用整套外观 |
|
|
32
33
|
|
|
33
34
|
## 安装
|
|
@@ -100,6 +101,22 @@ Copy-Item "C:\Users\你\Documents\dlaoshi-outfit\*" `
|
|
|
100
101
|
- **开**:壁纸 + 液态玻璃 + 桌宠 + 立牌 + 侧栏头像
|
|
101
102
|
- **关**:以上全部隐藏,恢复 DSH 原始界面(开关状态会记住)
|
|
102
103
|
|
|
104
|
+
## 称呼与语气
|
|
105
|
+
|
|
106
|
+
设置 › 通用 › **「D老师」怎么称呼你**:
|
|
107
|
+
|
|
108
|
+
- 输入框里填称呼,默认 **主人**
|
|
109
|
+
- 左侧开关 = 启用 / 关闭称呼
|
|
110
|
+
- 右侧开关 = 是否允许偶尔加句末语气词(呀 / 呢 / 哦 / 啦)
|
|
111
|
+
|
|
112
|
+
实现方式:宿主用官方 `ctx.systemPrompt.section('dlaoshi:address')` 注入一段规则,段落文本是函数,
|
|
113
|
+
改完设置**下一个模型步骤就生效**。提示词里明确写了:
|
|
114
|
+
|
|
115
|
+
> 只改变称呼和语气词:回答的事实、结论、结构、代码、工具调用、专业程度与长度都保持原样。
|
|
116
|
+
|
|
117
|
+
所以它不会改变回答习惯,只是称呼与措辞更亲近。设置保存在
|
|
118
|
+
`~/.dsh/fatfish-outfit/persona.json`,重启后保持。
|
|
119
|
+
|
|
103
120
|
## 兼容性
|
|
104
121
|
|
|
105
122
|
- 只依赖 DSH 自带的 Client 运行时与官方 `deepseekAccount` 能力,**无第三方依赖**
|
package/client.js
CHANGED
|
@@ -251,6 +251,16 @@ html:not(.${SKIN_CLASS}) .${PREFIX}-hero { display: none !important; }
|
|
|
251
251
|
border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.25);
|
|
252
252
|
transition: transform .2s cubic-bezier(.2,.9,.3,1); }
|
|
253
253
|
.${PREFIX}-switch[aria-checked='true'] .${PREFIX}-switch-knob { transform: translateX(20px); }
|
|
254
|
+
/* the addressing row: text field + two switches */
|
|
255
|
+
.${PREFIX}-persona-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
256
|
+
justify-content: flex-end; max-width: 320px; }
|
|
257
|
+
.${PREFIX}-input { flex: 1; min-width: 120px; padding: 6px 10px; border-radius: 8px;
|
|
258
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(150,190,255,.3));
|
|
259
|
+
background: transparent; color: inherit; outline: none;
|
|
260
|
+
font: 500 13px/1.4 "Segoe UI","Microsoft YaHei",system-ui,sans-serif; }
|
|
261
|
+
.${PREFIX}-input:focus { border-color: #2F55E0; }
|
|
262
|
+
.${PREFIX}-persona-hint { flex: 1 0 100%; text-align: right;
|
|
263
|
+
font: 400 11px/16px "Segoe UI","Microsoft YaHei",system-ui,sans-serif; opacity: .7; }
|
|
254
264
|
|
|
255
265
|
/* ============================================================ chat skin == */
|
|
256
266
|
/* The supplied illustration is the chat page's wallpaper. A theme-aware veil
|
|
@@ -973,6 +983,34 @@ html.${SKIN_CLASS} body[data-ds-dark-theme] [data-turn-process-answer] {
|
|
|
973
983
|
balanceTimer.current = setTimeout(() => setBalanceOpen(false), ms === undefined ? BUBBLE_HOLD_MS : ms)
|
|
974
984
|
}
|
|
975
985
|
|
|
986
|
+
// The standee belongs to the conversation view only: settings, the plugin
|
|
987
|
+
// manager and other secondary pages are modal dialogs or replace the chat
|
|
988
|
+
// pane, and the standee must not float over them.
|
|
989
|
+
const [onChat, setOnChat] = useState(true)
|
|
990
|
+
useEffect(() => {
|
|
991
|
+
if (typeof document === 'undefined') return undefined
|
|
992
|
+
const check = () => {
|
|
993
|
+
let visible = true
|
|
994
|
+
try {
|
|
995
|
+
if (document.querySelector('[role="dialog"][aria-modal="true"]')) visible = false
|
|
996
|
+
if (visible) {
|
|
997
|
+
const content = document.querySelector('[data-conversation-content]')
|
|
998
|
+
if (content) {
|
|
999
|
+
const rect = typeof content.getBoundingClientRect === 'function'
|
|
1000
|
+
? content.getBoundingClientRect()
|
|
1001
|
+
: null
|
|
1002
|
+
visible = rect === null || (rect.width > 0 && rect.height > 0)
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
} catch (err) { visible = true }
|
|
1006
|
+
setOnChat((prev) => (prev === visible ? prev : visible))
|
|
1007
|
+
}
|
|
1008
|
+
check()
|
|
1009
|
+
const timer = setInterval(check, 600)
|
|
1010
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
1011
|
+
return () => clearInterval(timer)
|
|
1012
|
+
}, [])
|
|
1013
|
+
|
|
976
1014
|
// Responsive size: the standee must fit the free right margin of the
|
|
977
1015
|
// conversation, so a windowed app gets a smaller standee instead of one
|
|
978
1016
|
// that covers the messages. Falls back to a viewport-proportional width,
|
|
@@ -1070,9 +1108,12 @@ html.${SKIN_CLASS} body[data-ds-dark-theme] [data-turn-process-answer] {
|
|
|
1070
1108
|
|
|
1071
1109
|
return h('div', {
|
|
1072
1110
|
class: `${PREFIX}-stats`,
|
|
1073
|
-
style:
|
|
1111
|
+
style: onChat
|
|
1112
|
+
? { right: STATS_PIN.right + 'px', bottom: STATS_PIN.bottom + 'px' }
|
|
1113
|
+
: { right: STATS_PIN.right + 'px', bottom: STATS_PIN.bottom + 'px', display: 'none' },
|
|
1074
1114
|
title: detail,
|
|
1075
1115
|
'data-popping': popping ? 'true' : 'false',
|
|
1116
|
+
'data-on-chat': onChat ? 'true' : 'false',
|
|
1076
1117
|
},
|
|
1077
1118
|
reportVisible || balanceVisible ? h('div', { class: `${PREFIX}-clouds` },
|
|
1078
1119
|
balanceVisible ? h('div', { key: 'balance', class: `${PREFIX}-cloud`, 'data-kind': 'balance' },
|
|
@@ -1104,7 +1145,65 @@ html.${SKIN_CLASS} body[data-ds-dark-theme] [data-turn-process-answer] {
|
|
|
1104
1145
|
}
|
|
1105
1146
|
|
|
1106
1147
|
/**
|
|
1107
|
-
*
|
|
1148
|
+
* Settings row for the addressing feature: what the model calls the user.
|
|
1149
|
+
*
|
|
1150
|
+
* The Host owns the value (it is written into the prompt as a section), so
|
|
1151
|
+
* this row is a thin client: GET on mount, POST on change.
|
|
1152
|
+
*/
|
|
1153
|
+
function PersonaSettingsRow() {
|
|
1154
|
+
const [persona, setPersona] = useState({ enabled: true, address: '主人', tone: true })
|
|
1155
|
+
useEffect(() => {
|
|
1156
|
+
if (typeof fetch !== 'function') return
|
|
1157
|
+
fetch('/fatfish/persona', { cache: 'no-store' })
|
|
1158
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
1159
|
+
.then((data) => { if (data && data.ok && data.persona) setPersona(data.persona) })
|
|
1160
|
+
.catch(() => { /* the host route may be absent on an old build */ })
|
|
1161
|
+
}, [])
|
|
1162
|
+
const save = (next) => {
|
|
1163
|
+
setPersona(next)
|
|
1164
|
+
if (typeof fetch !== 'function') return
|
|
1165
|
+
fetch('/fatfish/persona', {
|
|
1166
|
+
method: 'POST',
|
|
1167
|
+
headers: { 'content-type': 'application/json' },
|
|
1168
|
+
body: JSON.stringify(next),
|
|
1169
|
+
}).catch(() => { /* keep the local value; the next poll retries */ })
|
|
1170
|
+
}
|
|
1171
|
+
return h('div', { class: `${PREFIX}-settings` },
|
|
1172
|
+
h('div', { class: `${PREFIX}-settings-text` },
|
|
1173
|
+
h('div', { class: `${PREFIX}-settings-title` }, '「D老师」怎么称呼你'),
|
|
1174
|
+
h('div', { class: `${PREFIX}-settings-desc` },
|
|
1175
|
+
'默认称呼「主人」;只增加称呼与语气词,不改变回答方式与内容。')),
|
|
1176
|
+
h('div', { class: `${PREFIX}-persona-row` },
|
|
1177
|
+
h('input', {
|
|
1178
|
+
class: `${PREFIX}-input`,
|
|
1179
|
+
type: 'text',
|
|
1180
|
+
value: persona.address,
|
|
1181
|
+
maxLength: 24,
|
|
1182
|
+
placeholder: '主人',
|
|
1183
|
+
'aria-label': '称呼',
|
|
1184
|
+
onChange: (event) => save({ ...persona, address: event.target.value }),
|
|
1185
|
+
}),
|
|
1186
|
+
h('button', {
|
|
1187
|
+
type: 'button',
|
|
1188
|
+
role: 'switch',
|
|
1189
|
+
'aria-checked': persona.enabled ? 'true' : 'false',
|
|
1190
|
+
class: `${PREFIX}-switch`,
|
|
1191
|
+
title: persona.enabled ? '关闭称呼' : '启用称呼',
|
|
1192
|
+
onClick: () => save({ ...persona, enabled: !persona.enabled }),
|
|
1193
|
+
}, h('span', { class: `${PREFIX}-switch-knob` })),
|
|
1194
|
+
h('button', {
|
|
1195
|
+
type: 'button',
|
|
1196
|
+
role: 'switch',
|
|
1197
|
+
'aria-checked': persona.tone ? 'true' : 'false',
|
|
1198
|
+
class: `${PREFIX}-switch`,
|
|
1199
|
+
title: persona.tone ? '关闭语气词' : '启用语气词',
|
|
1200
|
+
onClick: () => save({ ...persona, tone: !persona.tone }),
|
|
1201
|
+
}, h('span', { class: `${PREFIX}-switch-knob` })),
|
|
1202
|
+
h('div', { class: `${PREFIX}-persona-hint` }, '左边开关=称呼,右边开关=语气词')))
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* The Settings row for the whole outfit switch.
|
|
1108
1207
|
*
|
|
1109
1208
|
* Rendered inside DSH's own "General" settings list, so it inherits the
|
|
1110
1209
|
* page's typography and spacing. The switch drives the whole outfit: the
|
|
@@ -1204,8 +1303,9 @@ html.${SKIN_CLASS} body[data-ds-dark-theme] [data-turn-process-answer] {
|
|
|
1204
1303
|
occupy('shell.overlay', 'dlaoshi-companion', 20, Companion),
|
|
1205
1304
|
occupy('sidebar.brand.mark', 'dlaoshi-sidebar', 5, SidebarMark),
|
|
1206
1305
|
occupy('conversation.hero.brand.mark', 'dlaoshi-hero', 5, HeroCharacter),
|
|
1207
|
-
// the
|
|
1306
|
+
// the switches live in Settings › General, like DSH's own rows
|
|
1208
1307
|
occupy('settings.general.item', 'dlaoshi-theme', 40, ThemeSettingsRow),
|
|
1308
|
+
occupy('settings.general.item', 'dlaoshi-persona', 41, PersonaSettingsRow),
|
|
1209
1309
|
]
|
|
1210
1310
|
return () => { for (const fn of stop) if (typeof fn === 'function') fn() }
|
|
1211
1311
|
}
|
package/index.js
CHANGED
|
@@ -22,10 +22,69 @@
|
|
|
22
22
|
/** The web server is the only service required. */
|
|
23
23
|
export const inject = ['webServer']
|
|
24
24
|
|
|
25
|
+
import fs from 'node:fs'
|
|
26
|
+
import os from 'node:os'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
|
|
25
29
|
const BASE = '/fatfish'
|
|
26
30
|
const MAX_SESSIONS = 16
|
|
27
31
|
const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
28
32
|
|
|
33
|
+
/* ------------------------------------------------------------ persona ----- */
|
|
34
|
+
|
|
35
|
+
/** Where the "how should it address me" setting lives (survives restarts). */
|
|
36
|
+
const PERSONA_PATH = path.join(os.homedir(), '.dsh', 'fatfish-outfit', 'persona.json')
|
|
37
|
+
const DEFAULT_PERSONA = { enabled: true, address: '主人', tone: true }
|
|
38
|
+
|
|
39
|
+
function loadPersona() {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(PERSONA_PATH, 'utf8'))
|
|
42
|
+
return {
|
|
43
|
+
enabled: parsed.enabled !== false,
|
|
44
|
+
address: typeof parsed.address === 'string' && parsed.address.trim()
|
|
45
|
+
? parsed.address.trim().slice(0, 24)
|
|
46
|
+
: DEFAULT_PERSONA.address,
|
|
47
|
+
tone: parsed.tone !== false,
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { ...DEFAULT_PERSONA }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function savePersona(persona) {
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(path.dirname(PERSONA_PATH), { recursive: true })
|
|
57
|
+
fs.writeFileSync(PERSONA_PATH, JSON.stringify(persona, null, 2), 'utf8')
|
|
58
|
+
} catch (err) { /* best effort: the setting still applies for this run */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The prompt section that carries the addressing rule.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately narrow: it changes how the model addresses the user and which
|
|
65
|
+
* light sentence-final particles it may use. It explicitly forbids touching the
|
|
66
|
+
* answer's content, structure, code, tools or length, so answer habits stay the
|
|
67
|
+
* same. Returns '' when the feature is switched off, which makes the section
|
|
68
|
+
* empty and therefore invisible to the model.
|
|
69
|
+
*/
|
|
70
|
+
function personaPrompt(persona) {
|
|
71
|
+
if (!persona.enabled) return ''
|
|
72
|
+
const address = persona.address || DEFAULT_PERSONA.address
|
|
73
|
+
const lines = [
|
|
74
|
+
'## 称呼与语气(只影响措辞,不影响回答内容)',
|
|
75
|
+
`- 在称呼用户时使用「${address}」。`,
|
|
76
|
+
]
|
|
77
|
+
if (persona.tone) {
|
|
78
|
+
lines.push('- 可以在句首或句尾偶尔加一个很轻的语气词(呀 / 呢 / 哦 / 啦),让语气更有陪伴感;不要每句都加。')
|
|
79
|
+
}
|
|
80
|
+
lines.push(
|
|
81
|
+
'- 这条规则只改变称呼和语气词:回答的事实、结论、结构、代码、工具调用、专业程度与长度都保持原样。',
|
|
82
|
+
'- 不要为了使用称呼而重复用户的话,也不要刻意卖萌或降低信息密度。',
|
|
83
|
+
'- 如果用户使用其他语言,请使用该语言里对应的称呼(例如英文用 "Master")。',
|
|
84
|
+
)
|
|
85
|
+
return lines.join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
29
88
|
/** One turn's accounting; replaced at every `turn/start`. */
|
|
30
89
|
function blank(sessionId) {
|
|
31
90
|
return {
|
|
@@ -274,6 +333,56 @@ export function apply(ctx) {
|
|
|
274
333
|
return balanceCache.value
|
|
275
334
|
}
|
|
276
335
|
|
|
336
|
+
// ---- addressing: one persona section the model sees on every step ------
|
|
337
|
+
// The section text is a function, so changing the setting takes effect on the
|
|
338
|
+
// very next model step without re-registering anything.
|
|
339
|
+
let persona = loadPersona()
|
|
340
|
+
ctx.inject(['systemPrompt'], (scope) => {
|
|
341
|
+
const registry = scope && scope.systemPrompt
|
|
342
|
+
if (!registry || typeof registry.section !== 'function') return
|
|
343
|
+
registry.section({
|
|
344
|
+
name: 'dlaoshi:address',
|
|
345
|
+
order: 9000,
|
|
346
|
+
text: () => personaPrompt(persona),
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
ctx.effect(() => ctx.webServer.register({
|
|
351
|
+
kind: 'exact',
|
|
352
|
+
path: `${BASE}/persona`,
|
|
353
|
+
handler: (req, res) => {
|
|
354
|
+
if (!isLoopback(req)) {
|
|
355
|
+
respondJson(res, 403, { ok: false, error: 'loopback only' })
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
if (req.method === 'POST') {
|
|
359
|
+
let body = ''
|
|
360
|
+
req.on('data', (chunk) => {
|
|
361
|
+
body += chunk
|
|
362
|
+
if (body.length > 4096) req.destroy()
|
|
363
|
+
})
|
|
364
|
+
req.on('end', () => {
|
|
365
|
+
try {
|
|
366
|
+
const parsed = JSON.parse(body || '{}')
|
|
367
|
+
persona = {
|
|
368
|
+
enabled: parsed.enabled !== false,
|
|
369
|
+
address: typeof parsed.address === 'string' && parsed.address.trim()
|
|
370
|
+
? parsed.address.trim().slice(0, 24)
|
|
371
|
+
: DEFAULT_PERSONA.address,
|
|
372
|
+
tone: parsed.tone !== false,
|
|
373
|
+
}
|
|
374
|
+
savePersona(persona)
|
|
375
|
+
respondJson(res, 200, { ok: true, persona })
|
|
376
|
+
} catch (err) {
|
|
377
|
+
respondJson(res, 400, { ok: false, error: 'invalid json' })
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
respondJson(res, 200, { ok: true, persona })
|
|
383
|
+
},
|
|
384
|
+
}), 'fatfish: persona route')
|
|
385
|
+
|
|
277
386
|
ctx.effect(() => ctx.webServer.register({
|
|
278
387
|
kind: 'exact',
|
|
279
388
|
path: `${BASE}/balance`,
|