dsh-plugin-maoxuan-pet 0.2.2 → 0.3.0
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 +29 -0
- package/README.en.md +22 -2
- package/README.md +16 -3
- package/cordis.patch.yml +2 -1
- package/desktop/Info.plist +20 -0
- package/desktop/README.md +74 -0
- package/desktop/build.sh +17 -0
- package/desktop/maoxuan-pet.swift +418 -0
- package/desktop/pet.html +415 -0
- package/docs/RELEASE.md +4 -4
- package/index.js +159 -4
- package/package.json +9 -2
package/desktop/pet.html
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
dsh-plugin-maoxuan-pet · 桌面壳渲染页
|
|
4
|
+
|
|
5
|
+
学 dsh-tauri-pet 的窗口思路,但进程独立于 DSH:
|
|
6
|
+
宿主数据由 Swift 原生层轮询后经 window.__petState() 注入(绕开 WKWebView 跨域),
|
|
7
|
+
页面只负责画「鲸鱼娘·小毛」+ 红皮书卡片,并把可交互矩形上报给原生层做点击穿透。
|
|
8
|
+
视觉与交互语义与 client.js(web 宠物)保持一致。
|
|
9
|
+
-->
|
|
10
|
+
<html lang="zh-CN">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<style>
|
|
14
|
+
html,body{margin:0;padding:0;width:100%;height:100%;background:transparent;overflow:hidden}
|
|
15
|
+
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#ffe9d6;font-size:13px;line-height:1.6}
|
|
16
|
+
*{box-sizing:border-box}
|
|
17
|
+
#stage{position:absolute;right:44px;bottom:56px}
|
|
18
|
+
.mp-anchor{position:relative;display:flex;flex-direction:column;align-items:center}
|
|
19
|
+
.mp-pet{position:relative;z-index:1;flex:0 0 auto;display:flex;flex-direction:column;align-items:center;cursor:grab;user-select:none;transition:transform .25s ease}
|
|
20
|
+
.mp-pet:active{cursor:grabbing}
|
|
21
|
+
.mp-pet:hover{transform:translateY(-3px)}
|
|
22
|
+
.mp-img{display:block;height:132px;width:auto;max-width:140px;object-fit:contain;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
23
|
+
.mp-img[data-talking="on"]{animation:mp-bob 1.6s ease-in-out infinite}
|
|
24
|
+
.mp-fallback{height:132px;width:100px;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
25
|
+
.mp-tag{margin-top:2px;font-size:10px;font-weight:700;letter-spacing:.06em;color:#ffe2bd;background:#5c0f0fd9;border:1px solid #ffd9a833;border-radius:999px;padding:2px 8px;white-space:nowrap;backdrop-filter:blur(8px)}
|
|
26
|
+
.mp-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;background:#f6cf9a;box-shadow:0 0 6px #f6cf9aaa;vertical-align:1px}
|
|
27
|
+
.mp-dot[data-off="off"]{background:#fca5a5;box-shadow:0 0 6px #fca5a5aa}
|
|
28
|
+
.mp-bubble{position:absolute;z-index:2;right:calc(100% + 12px);bottom:30px;width:340px;animation:mp-in .16s ease-out}
|
|
29
|
+
.mp-card{position:relative;max-height:min(58vh,480px);overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#f6cf9a #5f1010dd;color:#fff3e4;border:1px solid #8f1616;border-radius:12px;background:linear-gradient(155deg,#b32323 0%,#941a1a 46%,#771111 100%);box-shadow:0 22px 54px #35040466,0 0 0 1px #ffd9a814 inset}
|
|
30
|
+
.mp-card::-webkit-scrollbar{width:8px;height:8px}
|
|
31
|
+
.mp-card::-webkit-scrollbar-track{background:#5f1010cc;border-radius:8px}
|
|
32
|
+
.mp-card::-webkit-scrollbar-thumb{background:linear-gradient(180deg,#f7d9a4,#d09a3f);border-radius:8px;border:1px solid #5f1010}
|
|
33
|
+
.mp-card::-webkit-scrollbar-thumb:hover{background:linear-gradient(180deg,#ffe9c4,#e0aa4d)}
|
|
34
|
+
.mp-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;border-radius:11px 0 0 11px;background:linear-gradient(180deg,#f7d9a4,#d09a3f 45%,#b8792a);opacity:.92}
|
|
35
|
+
.mp-tail{position:absolute;right:-5px;bottom:38px;width:10px;height:10px;background:#8f1616;border-right:1px solid #8f1616;border-top:1px solid #8f1616;transform:rotate(45deg)}
|
|
36
|
+
.mp-head{display:flex;align-items:center;gap:6px;padding:9px 8px 8px 14px;border-bottom:1px solid #ffd9a82e;cursor:grab}
|
|
37
|
+
.mp-head:active{cursor:grabbing}
|
|
38
|
+
.mp-state{flex:1;min-width:0;font-size:10.5px;font-weight:750;letter-spacing:.06em;color:#ffe0b6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
39
|
+
.mp-nav{border:0;background:none;color:#ffd9a8;font-size:15px;line-height:1;padding:1px 4px;cursor:pointer;border-radius:4px;opacity:.8}
|
|
40
|
+
.mp-nav:hover:not(:disabled){color:#fff8ee;opacity:1}
|
|
41
|
+
.mp-nav:disabled{opacity:.3;cursor:default}
|
|
42
|
+
.mp-close{border:0;background:none;color:#ffd9a8;font-size:16px;line-height:1;padding:2px 4px;cursor:pointer;border-radius:4px;opacity:.85}
|
|
43
|
+
.mp-close:hover{color:#fff8ee;opacity:1}
|
|
44
|
+
.mp-body{padding:10px 14px 11px 16px}
|
|
45
|
+
.mp-cite{display:flex;gap:9px;margin:0 0 8px}
|
|
46
|
+
.mp-rule{flex:0 0 auto;width:2px;border-radius:2px;background:#f6cf9a}
|
|
47
|
+
.mp-cite-main{min-width:0}
|
|
48
|
+
.mp-cite p{margin:0;font-size:13px;color:#fff8ee;white-space:pre-wrap;word-break:break-word}
|
|
49
|
+
.mp-cite[data-kind="summary"] .mp-rule{background:#f0b57a}
|
|
50
|
+
.mp-meta{display:flex;align-items:center;gap:6px;margin:0 0 8px;font-size:10px;color:#f3cfa8}
|
|
51
|
+
.mp-meta b{font-weight:750;color:#ffe0b6}
|
|
52
|
+
.mp-pua{display:flex;gap:6px;margin:0 0 8px;font-size:13.5px;font-weight:750;color:#ffe7c2;letter-spacing:.01em}
|
|
53
|
+
.mp-pua span:first-child{color:#ffcf8a;font-size:15px;line-height:1.35;font-weight:800}
|
|
54
|
+
.mp-lesson{margin:0;font-size:12.5px;color:#ffeede}
|
|
55
|
+
.mp-lesson b{color:#ffd08a;font-weight:750}
|
|
56
|
+
.mp-axis{display:inline-block;font-size:9.5px;font-weight:800;letter-spacing:.08em;padding:1px 6px;border-radius:4px;margin-right:6px;color:#5a1208;vertical-align:1px}
|
|
57
|
+
.mp-foot{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:8px;border-top:1px solid #ffd9a82a}
|
|
58
|
+
.mp-src{flex:1;min-width:0;font-size:10px;color:#f0c9a2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
59
|
+
.mp-src a{color:#f0c9a2;text-decoration:none}
|
|
60
|
+
.mp-src a:hover{color:#fff8ee;text-decoration:underline}
|
|
61
|
+
.mp-offline{color:#f3cfa8;opacity:.9}
|
|
62
|
+
.mp-btn{border:0;background:none;padding:2px 3px;color:#ffe4c7;font:inherit;font-size:11.5px;font-weight:650;cursor:pointer;white-space:nowrap;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px;text-decoration-color:#ffd9a84d;opacity:.92}
|
|
63
|
+
.mp-btn:hover:not(:disabled){color:#fff8ee;text-decoration-color:#ffd9a8;opacity:1}
|
|
64
|
+
.mp-btn:disabled{opacity:.5;cursor:default}
|
|
65
|
+
.mp-loading{display:flex;align-items:center;gap:7px;color:#ffe7c2;font-size:12px;padding:6px 0}
|
|
66
|
+
.mp-spin{width:12px;height:12px;border-radius:50%;border:2px solid #ffd9a83d;border-top-color:#f6cf9a;animation:mp-rot .8s linear infinite}
|
|
67
|
+
.mp-empty{margin:0 0 8px;color:#ffe7c2;font-size:12.5px}
|
|
68
|
+
.mp-warn{margin:6px 0 0;font-size:10px;color:#ffd0d0}
|
|
69
|
+
.mp-badge{position:absolute;z-index:3;top:-7px;right:-7px;min-width:17px;height:17px;border-radius:9px;background:#f6cf9a;color:#6b1206;font-size:10px;font-weight:800;line-height:17px;text-align:center;padding:0 4px;box-shadow:0 4px 10px #2b040466}
|
|
70
|
+
.mp-menu{position:absolute;z-index:9;left:0;top:100%;min-width:96px;border:1px solid #8f1616;border-radius:8px;background:#5c0f0f;box-shadow:0 10px 24px #00000055;padding:4px;animation:mp-in .12s ease-out}
|
|
71
|
+
.mp-menu button{display:block;width:100%;border:0;background:none;color:#ffe4c7;font:inherit;font-size:11.5px;text-align:left;padding:5px 8px;border-radius:5px;cursor:pointer;white-space:nowrap}
|
|
72
|
+
.mp-menu button:hover{background:#7a1616;color:#fff8ee}
|
|
73
|
+
@keyframes mp-in{from{opacity:0;transform:translateY(4px) scale(.985)}to{opacity:1;transform:none}}
|
|
74
|
+
@keyframes mp-rot{to{transform:rotate(360deg)}}
|
|
75
|
+
@keyframes mp-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
|
|
76
|
+
@media (prefers-reduced-motion: reduce){
|
|
77
|
+
body *{animation:none!important;transition:none!important}
|
|
78
|
+
.mp-pet:hover{transform:none}
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<div id="stage">
|
|
84
|
+
<div class="mp-anchor" id="anchor">
|
|
85
|
+
<div class="mp-pet" id="pet" role="button" tabindex="0" aria-label="鲸鱼娘小毛"></div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<script>
|
|
89
|
+
'use strict';
|
|
90
|
+
|
|
91
|
+
// --- 原生桥 ----------------------------------------------------------------
|
|
92
|
+
const native = window.webkit?.messageHandlers?.pet;
|
|
93
|
+
const send = (msg) => { try { native?.postMessage(msg); } catch { /* 桥断了就静默 */ } };
|
|
94
|
+
|
|
95
|
+
// --- 状态 ------------------------------------------------------------------
|
|
96
|
+
let env = Object.assign({ origin: 'http://127.0.0.1:3081' }, window.__petEnv || null);
|
|
97
|
+
let state = null; // 最近一次宿主快照
|
|
98
|
+
let offline = false; // 原生层最近一次轮询失败
|
|
99
|
+
let busy = false; // 本端触发的「再督促」进行中
|
|
100
|
+
let open = false, pinned = null;
|
|
101
|
+
let seen = null, autoFloor = null;
|
|
102
|
+
const closedIds = new Set();
|
|
103
|
+
const marked = new Set();
|
|
104
|
+
let spriteKey = '';
|
|
105
|
+
let cardKey = '';
|
|
106
|
+
let ctxOpen = false;
|
|
107
|
+
|
|
108
|
+
const AXIS_COLOR = { 客户:'#f7d9a4', 项目:'#f0b478', 组织:'#e9c887', 现金流:'#e8ac52', 决策:'#efd9b6', 自律:'#d7a76b' };
|
|
109
|
+
const PET_TAG = document.getElementById('pet');
|
|
110
|
+
|
|
111
|
+
/** v1 气泡(quote/thought/insight/nag/action)兼容成形。 */
|
|
112
|
+
function shape(bubble) {
|
|
113
|
+
if (!bubble) return null;
|
|
114
|
+
if (bubble.cite && typeof bubble.cite.text === 'string') return bubble;
|
|
115
|
+
return {
|
|
116
|
+
...bubble,
|
|
117
|
+
template: bubble.quote ? 'quote' : 'summary',
|
|
118
|
+
cite: { kind: bubble.quote ? 'quote' : 'summary', label: bubble.quote ? '原文' : '小毛概括', text: bubble.quote || bubble.thought || '' },
|
|
119
|
+
axis: bubble.axis || '项目',
|
|
120
|
+
pua: bubble.pua || bubble.nag || '',
|
|
121
|
+
lesson: bubble.lesson || bubble.insight || bubble.action || '',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mmss = (delta) => {
|
|
126
|
+
if (!Number.isFinite(delta)) return '';
|
|
127
|
+
const total = Math.max(0, Math.round(delta / 1000));
|
|
128
|
+
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60);
|
|
129
|
+
return h > 0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m`;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function currentSprite() {
|
|
133
|
+
const map = state?.pet?.sprites || {};
|
|
134
|
+
if (state?.running || busy) return map.read ? 'read' : 'idle';
|
|
135
|
+
if (open && current() && current().mode !== 'offline') return map.talk ? 'talk' : 'idle';
|
|
136
|
+
return 'idle';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 精灵图 data URL 表(原生层拉好注入;WebKit 的 ATS 会拦 file:// 页面直挂的 http 图)。 */
|
|
140
|
+
let petSprites = null;
|
|
141
|
+
|
|
142
|
+
const latest = () => shape(state?.current);
|
|
143
|
+
const history = () => (state?.bubbles || []).map(shape).filter(Boolean);
|
|
144
|
+
function current() {
|
|
145
|
+
const pin = pinned && history().find((b) => b.id === pinned);
|
|
146
|
+
return pin || latest() || history()[0] || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- 渲染:宠物本体(一次性搭好,之后只改动态位) ----------------------------
|
|
150
|
+
const FALLBACK_SVG = `<svg class="mp-fallback" viewBox="0 0 104 112" role="img" aria-label="鲸鱼娘占位图">
|
|
151
|
+
<ellipse cx="52" cy="62" rx="34" ry="30" fill="#2f6fd0"/><ellipse cx="43" cy="82" rx="22" ry="14" fill="#dbeafe"/>
|
|
152
|
+
<circle cx="41" cy="55" r="5" fill="#0b1220"/><circle cx="63" cy="55" r="5" fill="#0b1220"/>
|
|
153
|
+
<circle cx="43" cy="53" r="1.6" fill="#fff"/><circle cx="65" cy="53" r="1.6" fill="#fff"/>
|
|
154
|
+
<path d="M46 68 q6 5 12 0" stroke="#0b1220" stroke-width="2" fill="none" stroke-linecap="round"/>
|
|
155
|
+
<path d="M52 24 C 74 14, 92 26, 90 44 C 82 34, 68 34, 52 40 Z" fill="#2f6fd0"/>
|
|
156
|
+
<path d="M78 6 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4 z" fill="#ef4444"/></svg>`;
|
|
157
|
+
|
|
158
|
+
PET_TAG.innerHTML = `
|
|
159
|
+
<span id="pet-visual"></span>
|
|
160
|
+
<span class="mp-badge" id="badge" aria-hidden="true" hidden></span>
|
|
161
|
+
<span class="mp-tag"><i class="mp-dot" id="dot"></i><span id="tag-label">待命中</span></span>`;
|
|
162
|
+
const petVisual = document.getElementById('pet-visual');
|
|
163
|
+
const badge = document.getElementById('badge');
|
|
164
|
+
const dot = document.getElementById('dot');
|
|
165
|
+
const tagLabel = document.getElementById('tag-label');
|
|
166
|
+
|
|
167
|
+
function renderPetVisual() {
|
|
168
|
+
const name = currentSprite();
|
|
169
|
+
const src = petSprites?.[name] || null;
|
|
170
|
+
const key = src ? name : 'fallback';
|
|
171
|
+
if (key === spriteKey && petVisual.firstChild) {
|
|
172
|
+
// 只同步说话动画开关
|
|
173
|
+
const img = petVisual.querySelector('.mp-img');
|
|
174
|
+
const cur = current();
|
|
175
|
+
if (img) img.dataset.talking = open && cur && cur.mode !== 'offline' ? 'on' : 'off';
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
spriteKey = key;
|
|
179
|
+
if (!src) { petVisual.innerHTML = FALLBACK_SVG; reportRects(); return; }
|
|
180
|
+
petVisual.innerHTML = `<img class="mp-img" alt="鲸鱼娘·小毛" draggable="false">`;
|
|
181
|
+
const img = petVisual.querySelector('.mp-img');
|
|
182
|
+
img.addEventListener('load', reportRects);
|
|
183
|
+
img.src = src;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- 渲染:红皮书卡片(key 变了才重建) --------------------------------------
|
|
187
|
+
const anchor = document.getElementById('anchor');
|
|
188
|
+
let cardEl = null, menuEl = null;
|
|
189
|
+
|
|
190
|
+
function esc(text) {
|
|
191
|
+
return String(text ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderCard() {
|
|
195
|
+
const cur = current();
|
|
196
|
+
const hist = history();
|
|
197
|
+
const idx = cur ? hist.findIndex((b) => b.id === cur.id) : -1;
|
|
198
|
+
const older = idx >= 0 && idx < hist.length - 1;
|
|
199
|
+
const newer = idx > 0;
|
|
200
|
+
const running = !!(state?.running || busy);
|
|
201
|
+
const key = `${open}|${cur?.id}|${running}|${offline}|${state?.lastError?.message || ''}`;
|
|
202
|
+
if (key === cardKey && cardEl) return;
|
|
203
|
+
cardKey = key;
|
|
204
|
+
if (cardEl) { cardEl.remove(); cardEl = null; }
|
|
205
|
+
if (!open) { reportRects(); return; }
|
|
206
|
+
|
|
207
|
+
const bodyInner = running
|
|
208
|
+
? `<div class="mp-loading"><i class="mp-spin"></i>小毛正在翻毛选…</div>`
|
|
209
|
+
: cur ? `
|
|
210
|
+
<blockquote class="mp-cite" data-kind="${esc(cur.cite?.kind || 'quote')}"><i class="mp-rule"></i>
|
|
211
|
+
<div class="mp-cite-main"><p>${cur.cite?.kind === 'summary' ? esc(cur.cite.text) : `「${esc(cur.cite.text)}」`}</p></div>
|
|
212
|
+
</blockquote>
|
|
213
|
+
<p class="mp-meta"><b>${esc(cur.cite?.label || '原文')}</b>${cur.year ? ` · ${esc(cur.year)}` : ''}</p>
|
|
214
|
+
${cur.pua ? `<div class="mp-pua"><span aria-hidden="true">“</span><span>${esc(cur.pua)}</span></div>` : ''}
|
|
215
|
+
<p class="mp-lesson">${cur.axis ? `<span class="mp-axis" style="background:${AXIS_COLOR[cur.axis] || '#64748b'}" title="启示落点:${esc(cur.axis)}">${esc(cur.axis)}</span>` : ''}<b>启示 </b>${esc(cur.lesson)}</p>
|
|
216
|
+
<div class="mp-foot">
|
|
217
|
+
<span class="mp-src">${cur.sourceUrl ? `<a href="${esc(cur.sourceUrl)}" target="_blank" rel="noreferrer noopener">中文马克思主义文库</a>` : '内置语料'}${cur.mode === 'offline' ? ' <span class="mp-offline">· 离线摘录</span>' : ''}</span>
|
|
218
|
+
<button class="mp-btn" data-act="dismiss" aria-label="收起这条督促">收起</button>
|
|
219
|
+
<button class="mp-btn" data-act="generate" data-primary="on" aria-label="再督促我一次">再督促</button>
|
|
220
|
+
</div>`
|
|
221
|
+
: `<p class="mp-empty">${running ? '小毛正在翻第一篇毛选…' : '小毛还没读过毛选。'}</p>
|
|
222
|
+
<button class="mp-btn" data-act="generate" data-primary="on" aria-label="让小毛现在读一篇">现在读一篇</button>`;
|
|
223
|
+
|
|
224
|
+
const warn = state?.lastError
|
|
225
|
+
? `<p class="mp-warn">上次生成没成功:${esc(String(state.lastError.message || '')).slice(0, 90)}</p>` : '';
|
|
226
|
+
const offlineWarn = offline ? `<p class="mp-warn">连接中断,重试中…</p>` : '';
|
|
227
|
+
|
|
228
|
+
const bubble = document.createElement('div');
|
|
229
|
+
bubble.className = 'mp-bubble';
|
|
230
|
+
bubble.innerHTML = `
|
|
231
|
+
<i class="mp-tail" aria-hidden="true"></i>
|
|
232
|
+
<section class="mp-card" aria-label="小毛的毛选督促">
|
|
233
|
+
<div class="mp-head" id="card-head">
|
|
234
|
+
<span class="mp-state">${cur ? `《${esc(cur.title)}》` : '鲸鱼娘·小毛'}</span>
|
|
235
|
+
${hist.length > 1 ? `<button class="mp-nav" data-act="nav-newer" ${newer ? '' : 'disabled'} title="更新的一条" aria-label="更新的一条">‹</button>` : ''}
|
|
236
|
+
${hist.length > 1 ? `<button class="mp-nav" data-act="nav-older" ${older ? '' : 'disabled'} title="更早的一条" aria-label="更早的一条">›</button>` : ''}
|
|
237
|
+
<button class="mp-close" data-act="close" aria-label="收起气泡">×</button>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="mp-body">${bodyInner}${warn}${offlineWarn}</div>
|
|
240
|
+
</section>`;
|
|
241
|
+
anchor.appendChild(bubble);
|
|
242
|
+
cardEl = bubble;
|
|
243
|
+
|
|
244
|
+
bubble.addEventListener('click', (event) => {
|
|
245
|
+
const btn = event.target.closest('[data-act]');
|
|
246
|
+
if (!btn) return;
|
|
247
|
+
const act = btn.dataset.act;
|
|
248
|
+
if (act === 'close') { open = false; pinned = null; renderCard(); }
|
|
249
|
+
else if (act === 'nav-newer' && newer) { pinned = hist[idx - 1].id; cardKey = ''; renderCard(); }
|
|
250
|
+
else if (act === 'nav-older' && older) { pinned = hist[idx + 1].id; cardKey = ''; renderCard(); }
|
|
251
|
+
else if (act === 'dismiss') { if (cur) closedIds.add(cur.id); open = false; pinned = null; send({ type:'action', action:'dismiss', id: cur?.id || '' }); renderCard(); }
|
|
252
|
+
else if (act === 'generate' && !running) { busy = true; send({ type:'action', action:'generate' }); cardKey = ''; renderCard(); }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
reportRects();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- 渲染:徽标 / 标签 / 右键菜单 -------------------------------------------
|
|
259
|
+
function renderMeta() {
|
|
260
|
+
const unread = Number(state?.unread || 0);
|
|
261
|
+
badge.hidden = !(unread > 0);
|
|
262
|
+
if (unread > 0) badge.textContent = unread > 9 ? '9+' : String(unread);
|
|
263
|
+
dot.dataset.off = offline ? 'off' : 'on';
|
|
264
|
+
const countdown = state?.nextRunAt ? Date.parse(state.nextRunAt) - Date.now() : NaN;
|
|
265
|
+
tagLabel.textContent = (state?.running || busy) ? '翻书中'
|
|
266
|
+
: Number.isFinite(countdown) && countdown > 0 ? `${mmss(countdown)} 后再读` : '待命中';
|
|
267
|
+
const cur = current();
|
|
268
|
+
const visual = petVisual.querySelector('.mp-img');
|
|
269
|
+
if (visual) visual.dataset.talking = open && cur && cur.mode !== 'offline' ? 'on' : 'off';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderMenu() {
|
|
273
|
+
if (menuEl) { menuEl.remove(); menuEl = null; }
|
|
274
|
+
if (!ctxOpen) { reportRects(); return; }
|
|
275
|
+
menuEl = document.createElement('div');
|
|
276
|
+
menuEl.className = 'mp-menu';
|
|
277
|
+
menuEl.innerHTML = `<button data-mact="reset">复位位置</button><button data-mact="quit">退出小毛</button>`;
|
|
278
|
+
anchor.appendChild(menuEl);
|
|
279
|
+
menuEl.addEventListener('click', (event) => {
|
|
280
|
+
const btn = event.target.closest('[data-mact]');
|
|
281
|
+
if (!btn) return;
|
|
282
|
+
ctxOpen = false; menuEl.remove(); menuEl = null;
|
|
283
|
+
send({ type:'config', action: btn.dataset.mact });
|
|
284
|
+
reportRects();
|
|
285
|
+
});
|
|
286
|
+
reportRects();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderAll() { renderPetVisual(); renderCard(); renderMeta(); if (!open) reportRects(); }
|
|
290
|
+
|
|
291
|
+
// --- 输入区上报(原生层据此做点击穿透,等价 dsh-tauri 的 input region) -------
|
|
292
|
+
function reportRects() {
|
|
293
|
+
const rects = [];
|
|
294
|
+
const push = (el) => {
|
|
295
|
+
if (!el) return;
|
|
296
|
+
const r = el.getBoundingClientRect();
|
|
297
|
+
if (r.width <= 0 || r.height <= 0) return;
|
|
298
|
+
rects.push({ x: r.left - 6, y: r.top - 6, w: r.width + 12, h: r.height + 12 });
|
|
299
|
+
};
|
|
300
|
+
push(anchor);
|
|
301
|
+
push(cardEl);
|
|
302
|
+
push(menuEl);
|
|
303
|
+
send({ type:'input', rects });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
new ResizeObserver(reportRects).observe(anchor);
|
|
307
|
+
|
|
308
|
+
// --- 打开卡片 = 这条已读(与 web 宠物同语义) --------------------------------
|
|
309
|
+
function markRead(cur) {
|
|
310
|
+
if (!open || !cur || cur.read) return;
|
|
311
|
+
if (marked.has(cur.id)) return;
|
|
312
|
+
marked.add(cur.id);
|
|
313
|
+
window.setTimeout(() => {
|
|
314
|
+
send({ type:'action', action:'dismiss', id: cur.id });
|
|
315
|
+
if (state) {
|
|
316
|
+
state.unread = Math.max(0, (state.unread || 0) - (state.current?.id === cur.id ? 1 : 0));
|
|
317
|
+
state.bubbles = (state.bubbles || []).map((b) => (b.id === cur.id ? { ...b, read: true } : b));
|
|
318
|
+
renderMeta();
|
|
319
|
+
}
|
|
320
|
+
}, 600);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- 交互:点击开合 / 拖动 / 键盘 --------------------------------------------
|
|
324
|
+
let drag = null;
|
|
325
|
+
|
|
326
|
+
anchor.addEventListener('pointerdown', (event) => {
|
|
327
|
+
if (event.button !== 0) return;
|
|
328
|
+
if (event.target.closest('.mp-menu') || event.target.closest('.mp-btn') || event.target.closest('.mp-nav') || event.target.closest('.mp-close')) return;
|
|
329
|
+
drag = { sx: event.screenX, sy: event.screenY, moved: false };
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
window.addEventListener('pointermove', (event) => {
|
|
334
|
+
if (!drag) return;
|
|
335
|
+
const dx = event.screenX - drag.sx, dy = event.screenY - drag.sy;
|
|
336
|
+
if (!drag.moved && Math.abs(dx) + Math.abs(dy) > 4) {
|
|
337
|
+
drag.moved = true;
|
|
338
|
+
send({ type:'dragstart' }); // 原生层记住当前窗口位置,之后按差值绝对定位
|
|
339
|
+
}
|
|
340
|
+
if (drag.moved) send({ type:'dragto', dx, dy });
|
|
341
|
+
}, { passive: true });
|
|
342
|
+
|
|
343
|
+
window.addEventListener('pointerup', (event) => {
|
|
344
|
+
if (!drag) return;
|
|
345
|
+
const moved = drag.moved;
|
|
346
|
+
drag = null;
|
|
347
|
+
if (moved) { send({ type:'dragend' }); return; }
|
|
348
|
+
// 原地松手 = 点击:点宠物开合,点卡片头部不动
|
|
349
|
+
const onPet = !event.target.closest('.mp-card');
|
|
350
|
+
if (onPet) {
|
|
351
|
+
if (open) { open = false; pinned = null; }
|
|
352
|
+
else { pinned = latest()?.id || null; open = true; markRead(current()); }
|
|
353
|
+
cardKey = '';
|
|
354
|
+
renderCard(); renderMeta();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
window.addEventListener('keydown', (event) => {
|
|
359
|
+
if (event.key === 'Escape' && open) { open = false; pinned = null; ctxOpen = false; cardKey = ''; renderCard(); renderMenu(); }
|
|
360
|
+
if ((event.key === 'Enter' || event.key === ' ') && event.target === PET_TAG) {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
open = !open; if (open) markRead(current()); cardKey = ''; renderCard();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
anchor.addEventListener('contextmenu', (event) => {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
ctxOpen = !ctxOpen;
|
|
369
|
+
renderMenu();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
document.addEventListener('click', (event) => {
|
|
373
|
+
if (ctxOpen && !event.target.closest('.mp-menu')) { ctxOpen = false; renderMenu(); }
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// --- 原生层入口 --------------------------------------------------------------
|
|
377
|
+
window.__petReady = () => { reportRects(); };
|
|
378
|
+
window.__petSprites = (map) => {
|
|
379
|
+
petSprites = map || {};
|
|
380
|
+
spriteKey = '';
|
|
381
|
+
renderPetVisual();
|
|
382
|
+
reportRects();
|
|
383
|
+
};
|
|
384
|
+
window.__petState = (json) => {
|
|
385
|
+
offline = false;
|
|
386
|
+
state = json;
|
|
387
|
+
busy = false; const fresh = latest();
|
|
388
|
+
if (fresh) {
|
|
389
|
+
const at = Date.parse(fresh.at || '') || Date.now();
|
|
390
|
+
if (autoFloor === null) autoFloor = at;
|
|
391
|
+
if (seen !== fresh.id) {
|
|
392
|
+
seen = fresh.id;
|
|
393
|
+
if (at > autoFloor && !closedIds.has(fresh.id)) {
|
|
394
|
+
autoFloor = at;
|
|
395
|
+
pinned = fresh.id;
|
|
396
|
+
open = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
spriteKey = ''; // sprite 选择可能随 running/open 变化
|
|
401
|
+
cardKey = '';
|
|
402
|
+
renderAll();
|
|
403
|
+
};
|
|
404
|
+
window.__petOffline = () => { offline = true; busy = false; renderMeta(); renderCard(); };
|
|
405
|
+
window.__petActionDone = () => { busy = false; cardKey = ''; renderCard(); renderMeta(); };
|
|
406
|
+
|
|
407
|
+
// 时钟:倒计时文案每秒刷新
|
|
408
|
+
window.setInterval(renderMeta, 1000);
|
|
409
|
+
|
|
410
|
+
// 起手画一只(还没有数据时是占位鲸鱼 + 待命中)
|
|
411
|
+
renderAll();
|
|
412
|
+
send({ type:'ready' });
|
|
413
|
+
</script>
|
|
414
|
+
</body>
|
|
415
|
+
</html>
|
package/docs/RELEASE.md
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
| 项 | 现状(2026-11-01 核对) |
|
|
9
9
|
|---|---|
|
|
10
10
|
| 包名 | `dsh-plugin-maoxuan-pet`(unscoped,npm 上未占用) |
|
|
11
|
-
| 版本 | `0.
|
|
11
|
+
| 版本 | `0.3.0` |
|
|
12
12
|
| 代码许可 | MIT(`LICENSE`);语料与立绘的非 MIT 声明见 `NOTICE` |
|
|
13
|
-
| 打包清单 | `npm pack --dry-run` →
|
|
13
|
+
| 打包清单 | `npm pack --dry-run` → 64 files / 1.3 MB(含 33 篇语料 + desktop/ 5 个壳源码文件;`desktop/build/` 产物靠 files 逐文件点名排除,勿改回整目录收录) |
|
|
14
14
|
| GitHub 账号 | `gh auth status` → `yuna78`(scopes 含 `repo`) |
|
|
15
|
-
| npm 凭据 |
|
|
15
|
+
| npm 凭据 | `npm whoami` → `yuna78`(2026-11-01 复核有效);发布需 2FA 一次性验证码,`npm publish --otp=<6位码>` |
|
|
16
16
|
| 市场收录机制 | awesome-dsh-plugin 索引 **npm 包**(`versions[].source.type = npm`),并用 `package.json` 的 `repository.url` 关联 GitHub 仓;**没有投稿表单**,发布后由爬虫收录 |
|
|
17
17
|
|
|
18
18
|
## 1. 发布前自检(每次都跑)
|
|
@@ -93,7 +93,7 @@ cd .. && node build-registry.mjs --write # 重算 registry.json / README 表
|
|
|
93
93
|
node -e "const r=require('./registry.json');console.log(r.plugins.find(p=>p.name==='dsh-plugin-maoxuan-pet'))"
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
期望字段:`openSource: true`、`npm: "0.2.
|
|
96
|
+
期望字段:`openSource: true`、`npm: "0.2.3"`、`market: { title: "yuna78/dsh-maoxuan-pet", version: "0.2.3" }`。
|
|
97
97
|
|
|
98
98
|
## 5. 版本号规则
|
|
99
99
|
|
package/index.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* node_modules without resolving any DSH-internal module.
|
|
17
17
|
*/
|
|
18
18
|
import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
19
20
|
import { spawn } from 'node:child_process';
|
|
20
21
|
import { dirname, join } from 'node:path';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
@@ -79,6 +80,11 @@ export function normaliseConfig(config) {
|
|
|
79
80
|
dailyLimit: int(raw.dailyLimit, 24, 0, 500),
|
|
80
81
|
headless: raw.headless !== false,
|
|
81
82
|
dshBin: typeof raw.dshBin === 'string' && raw.dshBin.trim() ? raw.dshBin.trim() : 'dsh',
|
|
83
|
+
// The child keeps the dedicated one-shot `headless` profile (its app is the
|
|
84
|
+
// only one that accepts `--json`); the *model* config is bridged from the
|
|
85
|
+
// host profile at spawn time via a `--patch` overlay, so anything the GUI
|
|
86
|
+
// can use — DeepSeek official, a relay, any provider — works with zero
|
|
87
|
+
// per-machine setup. See syncHeadlessOverlay / extractLlmPatchEntries.
|
|
82
88
|
profile: typeof raw.profile === 'string' && raw.profile.trim() ? raw.profile.trim() : 'headless',
|
|
83
89
|
timeoutMs: int(raw.timeoutMs, 180000, 10000, 900000),
|
|
84
90
|
businessContext: typeof raw.businessContext === 'string' ? raw.businessContext.trim() : '',
|
|
@@ -86,6 +92,144 @@ export function normaliseConfig(config) {
|
|
|
86
92
|
};
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
/** Translate a raw child-process failure into something a user can act on. */
|
|
96
|
+
export function friendlyHeadlessError(message) {
|
|
97
|
+
if (/MISSING_CREDENTIAL/i.test(message)) {
|
|
98
|
+
return `${clip(message, 160)}——修复:打开 Web 界面的 Models 页保存一个可用模型的 API Key(插件默认跟随宿主 profile,宿主能用它就能用);或在插件配置里显式指定 profile / dshBin。`;
|
|
99
|
+
}
|
|
100
|
+
if (/无法启动 headless|ENOENT/.test(message)) {
|
|
101
|
+
return `${clip(message, 160)}——多数是 PATH 里没有 dsh 命令,可在插件配置里把 dshBin 指到 dsh 的绝对路径。`;
|
|
102
|
+
}
|
|
103
|
+
return message;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- host-profile → headless child: llm config bridge (v0.2.3) ---------------
|
|
107
|
+
// The hourly child runs `dsh --profile headless`, whose bundled patch carries no
|
|
108
|
+
// llm provider — so on a fresh install it always died with MISSING_CREDENTIAL
|
|
109
|
+
// unless the user happened to export a global DEEPSEEK_API_KEY. The host
|
|
110
|
+
// process *does* have a working model stack (that is what the GUI renders with):
|
|
111
|
+
// its profile patch declares `agent-default-model` and the provider plugins.
|
|
112
|
+
// These two files are plain YAML; bridging is a verbatim copy of the llm
|
|
113
|
+
// entries into a `--patch` overlay handed to the child at spawn time.
|
|
114
|
+
|
|
115
|
+
/** The overlay file handed to every headless child (idempotent, human-inspectable). */
|
|
116
|
+
export function headlessOverlayPath(env = process.env) {
|
|
117
|
+
return join(resolveDshHome(env), 'storages', 'dsh-maoxuan-pet.headless-overlay.yml');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Where the host profile's own patch layer lives, or null if unknown. */
|
|
121
|
+
export function hostPatchPath(env = process.env) {
|
|
122
|
+
const dir = env.DSH_PROFILE_DIR?.trim();
|
|
123
|
+
if (dir) return join(dir, 'cordis.patch.yml');
|
|
124
|
+
const home = resolveDshHome(env);
|
|
125
|
+
const profile = env.DSH_PROFILE?.trim();
|
|
126
|
+
if (profile) return join(home, 'profiles', profile, 'cordis.patch.yml');
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Copy the llm-related top-level entries out of a profile patch, verbatim.
|
|
132
|
+
* Only Node builtins are allowed in this package, so "parsing" is slicing the
|
|
133
|
+
* top-level `- ` blocks by column-0 boundary lines; profile patches are
|
|
134
|
+
* machine-written simple YAML, and the output is applied by dsh itself, exactly
|
|
135
|
+
* like the original layer. Anything not matching is left out untouched.
|
|
136
|
+
*/
|
|
137
|
+
export function extractLlmPatchEntries(patchText) {
|
|
138
|
+
const lines = String(patchText ?? '').split('\n');
|
|
139
|
+
const starts = [];
|
|
140
|
+
lines.forEach((line, index) => {
|
|
141
|
+
if (/^- /.test(line)) starts.push(index);
|
|
142
|
+
});
|
|
143
|
+
return starts
|
|
144
|
+
.map((start, i) => lines.slice(start, starts[i + 1] ?? lines.length).join('\n').replace(/\s+$/, ''))
|
|
145
|
+
.filter((block) => /- id: agent-default-model\b/.test(block)
|
|
146
|
+
|| /^\s*name:\s+["']?@deepseek-ai\/dsh-llm-pi-ai["']?\s*$/m.test(block)
|
|
147
|
+
|| /^\s*name:\s+["']?@deepseek-ai\/dsh-llm-deepseek-api-key["']?\s*$/m.test(block));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Write the overlay if its content changed; return the path, or '' when the
|
|
152
|
+
* host patch had no llm entries (then an existing overlay is kept — a stale
|
|
153
|
+
* bridge still beats a child with no model config at all).
|
|
154
|
+
*/
|
|
155
|
+
export async function syncHeadlessOverlay(env = process.env) {
|
|
156
|
+
const target = headlessOverlayPath(env);
|
|
157
|
+
let text = '';
|
|
158
|
+
const source = hostPatchPath(env);
|
|
159
|
+
if (source) {
|
|
160
|
+
try {
|
|
161
|
+
const entries = extractLlmPatchEntries(await readFile(source, 'utf8'));
|
|
162
|
+
if (entries.length) {
|
|
163
|
+
text = [
|
|
164
|
+
'# Auto-generated by dsh-plugin-maoxuan-pet (v0.2.3).',
|
|
165
|
+
'# The host profile\'s llm entries, bridged verbatim so the hourly',
|
|
166
|
+
'# headless child uses the same model stack as the GUI. Safe to delete:',
|
|
167
|
+
'# the plugin rewrites it on every generation attempt.',
|
|
168
|
+
...entries,
|
|
169
|
+
'',
|
|
170
|
+
].join('\n');
|
|
171
|
+
}
|
|
172
|
+
} catch { /* unreadable host patch → no bridge this run */ }
|
|
173
|
+
}
|
|
174
|
+
let existing = '';
|
|
175
|
+
try { existing = await readFile(target, 'utf8'); } catch { /* absent */ }
|
|
176
|
+
if (existing === text) return text ? target : '';
|
|
177
|
+
await mkdir(dirname(target), { recursive: true });
|
|
178
|
+
const tmp = `${target}.tmp`;
|
|
179
|
+
await writeFile(tmp, text, 'utf8');
|
|
180
|
+
await rename(tmp, target);
|
|
181
|
+
return text ? target : '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read the machine-local credential refs file the web Models page writes
|
|
186
|
+
* (`$DSH_HOME/.credentials.yaml`, only the top-level `refs:` block; the file
|
|
187
|
+
* belongs to the user and is remounted by the harness anyway). Returns a map
|
|
188
|
+
* name → value, {} when absent/unreadable — callers treat any error as
|
|
189
|
+
* "no bridge" and fall back to the honest offline excerpt.
|
|
190
|
+
*/
|
|
191
|
+
export function readCredentialRefs(env = process.env) {
|
|
192
|
+
try {
|
|
193
|
+
const file = join(resolveDshHome(env), '.credentials.yaml');
|
|
194
|
+
const text = readFileSync(file, 'utf8');
|
|
195
|
+
const refs = {};
|
|
196
|
+
let inRefs = false;
|
|
197
|
+
for (const line of text.split('\n')) {
|
|
198
|
+
if (/^refs:/.test(line)) { inRefs = true; continue; }
|
|
199
|
+
if (inRefs && /^[A-Za-z0-9_]+:/.test(line)) break; // next top-level block
|
|
200
|
+
const match = inRefs && /^ ([A-Za-z0-9_]+): (.+)$/.exec(line);
|
|
201
|
+
if (match) refs[match[1]] = match[2].trim();
|
|
202
|
+
}
|
|
203
|
+
return refs;
|
|
204
|
+
} catch {
|
|
205
|
+
return {};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The child env for one generation: the host env plus every `apiKeyEnv` the
|
|
211
|
+
* bridged overlay names but the child env does not have, sourced from the
|
|
212
|
+
* credential refs (the same store the GUI reads). Injecting only missing names
|
|
213
|
+
* keeps explicit user-provided env values authoritative; anything unknown is
|
|
214
|
+
* simply not injected, so the child fails with the honest MISSING_CREDENTIAL.
|
|
215
|
+
*/
|
|
216
|
+
export function childHeadlessEnv(overlayText, childEnv = process.env) {
|
|
217
|
+
const env = { ...childEnv, DSH_MAOXUAN_PET_CHILD: '1' };
|
|
218
|
+
const names = new Set(
|
|
219
|
+
(String(overlayText ?? '').match(/^\s*apiKeyEnv:\s*([A-Za-z0-9_]+)\s*$/gm) ?? [])
|
|
220
|
+
.map((line) => /^ apiKeyEnv:/.test(line) ? line.trim().replace(/^apiKeyEnv:\s*/, '') : line.replace(/^\s*apiKeyEnv:\s*/, '').trim())
|
|
221
|
+
.map((name) => name.trim()),
|
|
222
|
+
);
|
|
223
|
+
if (!names.size) return env;
|
|
224
|
+
const refs = readCredentialRefs(childEnv);
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
if (env[name]) continue;
|
|
227
|
+
const value = refs[name];
|
|
228
|
+
if (value) env[name] = value;
|
|
229
|
+
}
|
|
230
|
+
return env;
|
|
231
|
+
}
|
|
232
|
+
|
|
89
233
|
/** Resolve DSH home the same way the harness does. */
|
|
90
234
|
export function resolveDshHome(env = process.env) {
|
|
91
235
|
if (env.DSH_HOME && env.DSH_HOME.trim()) return env.DSH_HOME.trim();
|
|
@@ -668,13 +812,24 @@ export function apply(ctx, config) {
|
|
|
668
812
|
};
|
|
669
813
|
|
|
670
814
|
/** Run one headless dsh conversation and return the final text, or throw. */
|
|
671
|
-
const runHeadless = (prompt) => new Promise((resolvePromise, reject) => {
|
|
672
|
-
|
|
815
|
+
const runHeadless = (prompt) => new Promise(async (resolvePromise, reject) => {
|
|
816
|
+
// Bridge the host profile's llm config into the one-shot child (idempotent;
|
|
817
|
+
// '' when nothing to bridge — see syncHeadlessOverlay). Must not kill the
|
|
818
|
+
// generation if it fails for any reason: worst case the child errors with
|
|
819
|
+
// the old MISSING_CREDENTIAL and the usual offline fallback kicks in.
|
|
820
|
+
await syncHeadlessOverlay().catch(() => '');
|
|
821
|
+
let overlayText = '';
|
|
822
|
+
try {
|
|
823
|
+
overlayText = await readFile(headlessOverlayPath(), 'utf8');
|
|
824
|
+
} catch { /* no overlay available this run */ }
|
|
825
|
+
const args = ['--profile', cfg.profile];
|
|
826
|
+
if (overlayText.trim()) args.push('--patch', headlessOverlayPath());
|
|
827
|
+
args.push('--json', prompt);
|
|
673
828
|
let proc;
|
|
674
829
|
try {
|
|
675
830
|
proc = spawn(cfg.dshBin, args, {
|
|
676
831
|
cwd: homedir(),
|
|
677
|
-
env:
|
|
832
|
+
env: childHeadlessEnv(overlayText),
|
|
678
833
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
679
834
|
});
|
|
680
835
|
} catch (error) {
|
|
@@ -810,7 +965,7 @@ export function apply(ctx, config) {
|
|
|
810
965
|
if (generated) produced = { mode: 'headless', ...generated };
|
|
811
966
|
else failure = `模型输出无法解析为约定 JSON(${clip(answer, 120)})`;
|
|
812
967
|
} catch (error) {
|
|
813
|
-
failure = String(error?.message ?? error);
|
|
968
|
+
failure = friendlyHeadlessError(String(error?.message ?? error));
|
|
814
969
|
}
|
|
815
970
|
}
|
|
816
971
|
|