dsh-screen-beyond 0.0.0-stage → 0.5.7
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/LICENSE +21 -0
- package/README.md +201 -3
- package/cordis.patch.yml +6 -0
- package/examples/CREDITS.md +17 -0
- package/examples/dafeiyu-ai-avatar.png +0 -0
- package/examples/dafeiyu-ai-candidate-2.png +0 -0
- package/examples/dafeiyu-avatar-cc-by-nc-sa.png +0 -0
- package/examples/dafeiyu-avatar-preview.png +0 -0
- package/examples/dafeiyu-character-card.json +24 -0
- package/lib/client.js +235 -0
- package/lib/index.js +149 -0
- package/package.json +47 -3
- package/runtime/chat.html +215 -0
- package/runtime/config.example.json +93 -0
- package/runtime/env/README.md +14 -0
- package/runtime/env/agent_ssh.py +73 -0
- package/runtime/env/installers/ADBKeyboard.apk +0 -0
- package/runtime/env/phone_ctl.py +244 -0
- package/runtime/env/space_ctl.py +163 -0
- package/runtime/env/space_ensure.py +65 -0
- package/runtime/env/start_screen.ps1 +16 -0
- package/runtime/env/start_space.ps1 +33 -0
- package/runtime/env/stop_screen.ps1 +10 -0
- package/runtime/example-character.json +24 -0
- package/runtime/map.html +784 -0
- package/runtime/memory.mjs +205 -0
- package/runtime/model.html +363 -0
- package/runtime/models/chat-mway331n.png +0 -0
- package/runtime/models/dafeiyu-chibi.gltf +1 -0
- package/runtime/models/demo-town.gltf +1 -0
- package/runtime/models/demo-town2.gltf +1 -0
- package/runtime/models/duck.glb +0 -0
- package/runtime/models/map-map-mwb087zu-mwb0880c.png +0 -0
- package/runtime/models/map-map-mwb0bfsb-mwb0bft6.png +0 -0
- package/runtime/models/map-map-mwb0c9iw-mwb0c9j2.png +0 -0
- package/runtime/models/map-map-mwb0d8cc-mwb0d8d2.png +0 -0
- package/runtime/models/map-map-mwb0fm53-mwb0fm59.png +0 -0
- package/runtime/persona.example.md +34 -0
- package/runtime/pet/default-avatar.png +0 -0
- package/runtime/pet/pet.example.json +49 -0
- package/runtime/pet/pet.ps1 +532 -0
- package/runtime/screen.html +125 -0
- package/runtime/server.mjs +2659 -0
- package/runtime/settings.html +1015 -0
- package/runtime/stage.html +195 -0
- package/runtime/theme.css +210 -0
- package/runtime/tutorial.md +116 -0
- package/runtime/vendor/addons/loaders/GLTFLoader.js +4663 -0
- package/runtime/vendor/addons/utils/BufferGeometryUtils.js +1375 -0
- package/runtime/vendor/three.module.min.js +6 -0
- package/runtime/wx.js +164 -0
- package/scripts/backup.ps1 +79 -0
- package/start-pure-chat.cmd +26 -0
- package/start-pure-chat.sh +4 -0
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>屏幕彼端 · 设置</title>
|
|
3
|
+
<style>
|
|
4
|
+
:root{color-scheme:dark}
|
|
5
|
+
*{box-sizing:border-box}
|
|
6
|
+
body{margin:0;background:#0b0f14;color:#d7dee8;font:14px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}
|
|
7
|
+
.app{display:flex;height:100vh}
|
|
8
|
+
.side{width:200px;background:#0e131a;border-right:1px solid #1b2530;display:flex;flex-direction:column}
|
|
9
|
+
.brand{display:flex;align-items:center;gap:10px;padding:16px 14px;border-bottom:1px solid #1b2530}
|
|
10
|
+
.ava{width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#22c55e);display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:15px;background-size:cover;background-position:center}
|
|
11
|
+
.nm{font-weight:600;font-size:15px}
|
|
12
|
+
.st{font-size:11px;color:#5fbf7f}
|
|
13
|
+
.nav{padding:10px 8px;display:flex;flex-direction:column;gap:2px}
|
|
14
|
+
.nav a{display:flex;align-items:center;gap:9px;padding:8px 11px;border-radius:8px;color:#93a1b1;text-decoration:none;font-size:13px;cursor:pointer}
|
|
15
|
+
.nav a:hover{background:#141b23;color:#d7dee8}
|
|
16
|
+
.nav a.on{background:#16202b;color:#e8eef6;box-shadow:inset 2px 0 0 #3b82f6}
|
|
17
|
+
.sidefoot{margin-top:auto;padding:12px 14px;font-size:11px;color:#55637a;border-top:1px solid #1b2530}
|
|
18
|
+
.main{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
19
|
+
.top{display:flex;align-items:center;gap:12px;padding:13px 20px;border-bottom:1px solid #1b2530;background:#0d1319}
|
|
20
|
+
.top h1{font-size:16px;margin:0;font-weight:600}
|
|
21
|
+
.top .sub{font-size:12px;color:#66768c}
|
|
22
|
+
.spacer{flex:1}
|
|
23
|
+
.btn{background:#1b2430;color:#cfdae8;border:1px solid #2a3745;border-radius:8px;padding:7px 14px;font:inherit;cursor:pointer;font-size:13px}
|
|
24
|
+
.btn.primary{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
25
|
+
.btn.danger{background:#3a1d1d;border-color:#5a2a2a;color:#ff9c9c}
|
|
26
|
+
.btn.ghost{background:transparent}
|
|
27
|
+
.btn:hover{filter:brightness(1.12)}
|
|
28
|
+
.btn.sm{padding:4px 9px;font-size:12px}
|
|
29
|
+
.body{flex:1;overflow:auto;padding:20px 22px 40px}
|
|
30
|
+
.wrap{max-width:1060px;display:flex;gap:18px;align-items:flex-start}
|
|
31
|
+
.col{flex:1;min-width:0;display:flex;flex-direction:column;gap:16px}
|
|
32
|
+
.col.side2{flex:0 0 300px}
|
|
33
|
+
.card{background:#101720;border:1px solid #1c2733;border-radius:12px;padding:15px 16px}
|
|
34
|
+
.card h2{font-size:13px;margin:0 0 12px;color:#93a6bd;font-weight:600}
|
|
35
|
+
.card h2 .tag{font-size:10px;color:#5f6d81;margin-left:8px;font-weight:400}
|
|
36
|
+
.row{display:flex;gap:12px;align-items:center;margin:9px 0}
|
|
37
|
+
.row label{width:88px;flex:0 0 auto;color:#8496ab;font-size:12.5px}
|
|
38
|
+
input[type=text],input[type=number],textarea,select{background:#0b1118;border:1px solid #26333f;color:#d7dee8;border-radius:8px;padding:8px 10px;font:inherit;font-size:13px;outline:none;width:100%}
|
|
39
|
+
input:focus,textarea:focus{border-color:#2563eb}
|
|
40
|
+
textarea{resize:vertical;line-height:1.65}
|
|
41
|
+
textarea.big{min-height:190px}
|
|
42
|
+
.hint{font-size:11px;color:#5a6878;margin-top:5px}
|
|
43
|
+
.chip{background:#131c26;border:1px solid #223041;color:#9fb0c3;border-radius:999px;padding:3px 10px;font-size:12px;cursor:pointer}
|
|
44
|
+
.chip.on{background:#16304f;border-color:#2f6bd8;color:#cfe0f7}
|
|
45
|
+
.fact{border:1px solid #1c2733;border-radius:10px;padding:11px 13px;margin-bottom:9px;background:#0d141c}
|
|
46
|
+
.fact .t{font-size:13px;color:#cfdae8}
|
|
47
|
+
.fact .meta{display:flex;align-items:center;gap:9px;margin-top:7px;font-size:11px;color:#65748a;flex-wrap:wrap}
|
|
48
|
+
.bar{height:5px;background:#1a2431;border-radius:999px;overflow:hidden;width:130px}
|
|
49
|
+
.bar i{display:block;height:100%;background:linear-gradient(90deg,#2563eb,#22c55e)}
|
|
50
|
+
.pill{font-size:10.5px;padding:1px 8px;border-radius:999px;border:1px solid #2a3745;color:#93a1b1}
|
|
51
|
+
.pill.core{border-color:#2f6bd8;color:#8fc1ff;background:#142238}
|
|
52
|
+
.pill.faint{border-color:#4a3a2a;color:#d8a86a}
|
|
53
|
+
.example{background:#0b1118;border-left:2px solid #2563eb;padding:7px 11px;border-radius:4px;color:#9fb0c3;font-size:12.5px;margin:6px 0}
|
|
54
|
+
.stat{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
|
55
|
+
.stat .s{background:#0d141c;border:1px solid #1c2733;border-radius:10px;padding:11px 13px}
|
|
56
|
+
.stat .s .n{font-size:19px;font-weight:600;color:#e8eef6}
|
|
57
|
+
.stat .s .l{font-size:11px;color:#6b7a8e}
|
|
58
|
+
.small{font-size:12px;color:#7d8fa5}
|
|
59
|
+
.slider{display:flex;align-items:center;gap:8px;flex:1}
|
|
60
|
+
input[type=range]{flex:1;accent-color:#2563eb}
|
|
61
|
+
.hidden{display:none}
|
|
62
|
+
.mode-banner{background:linear-gradient(135deg,#101c2e,#0e1725);border:1px solid #2f6bd8;border-radius:12px;padding:14px 16px;margin-bottom:16px;box-shadow:0 0 0 1px rgba(47,107,216,.15) inset}
|
|
63
|
+
#toast{position:fixed;right:20px;bottom:20px;background:#1f6feb;color:#fff;padding:9px 16px;border-radius:8px;opacity:0;transition:.2s;z-index:9}
|
|
64
|
+
.avatar{width:64px;height:64px;border-radius:50%;background:#16202b;display:flex;align-items:center;justify-content:center;font-size:22px;color:#9fb0c3;background-size:cover;background-position:center}
|
|
65
|
+
.flex{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
66
|
+
.wrap{width:100%;flex-wrap:wrap}
|
|
67
|
+
.col.side2{flex:1 1 300px;min-width:280px;max-width:360px}
|
|
68
|
+
.card{min-width:0;overflow:hidden}
|
|
69
|
+
.row{flex-wrap:wrap;row-gap:8px}
|
|
70
|
+
.row > input, .row > select, .row > textarea{max-width:100%}
|
|
71
|
+
.fact .meta{flex-wrap:wrap;row-gap:6px}
|
|
72
|
+
.fact .t{overflow-wrap:anywhere}
|
|
73
|
+
@media (max-width:900px){ .wrap{flex-direction:column} .col.side2{flex:1 1 auto;width:100%;max-width:none} }
|
|
74
|
+
</style>
|
|
75
|
+
<link rel="stylesheet" href="/theme.css"></head>
|
|
76
|
+
<body><div class="app">
|
|
77
|
+
<aside class="side">
|
|
78
|
+
<div class="brand"><div class="ava" id="navAva">?</div><div><div class="nm" id="navName">角色</div><div class="st">● 在线</div></div></div>
|
|
79
|
+
<nav class="nav">
|
|
80
|
+
<a href="#role" data-tab="role"><span>🎭</span>角色</a>
|
|
81
|
+
<a href="#life" data-tab="life"><span>🕐</span>生活</a>
|
|
82
|
+
<a href="#memory" data-tab="memory"><span>🧠</span>记忆</a>
|
|
83
|
+
<a href="#env" data-tab="env"><span>🖥️</span>环境</a>
|
|
84
|
+
<a href="#model" data-tab="model"><span>⚙️</span>模型</a>
|
|
85
|
+
<a href="#data" data-tab="data"><span>💾</span>数据</a>
|
|
86
|
+
<a href="#tutorial" data-tab="tutorial"><span>📖</span>教程</a>
|
|
87
|
+
</nav>
|
|
88
|
+
<div class="sidefoot">v0.5.7 · <span id="sideMode">…</span><br><a href="/screen" target="_blank" style="color:#3b82f6;text-decoration:none;margin-right:10px">看屏幕</a><a href="/" style="color:#3b82f6;text-decoration:none">回到聊天</a></div>
|
|
89
|
+
</aside>
|
|
90
|
+
|
|
91
|
+
<div class="main">
|
|
92
|
+
<div class="top">
|
|
93
|
+
<h1 id="tabTitle">角色</h1><span class="sub" id="tabSub"></span>
|
|
94
|
+
<div class="spacer"></div>
|
|
95
|
+
<span id="topAction"></span>
|
|
96
|
+
</div>
|
|
97
|
+
<div id="modeHost" style="padding:0 22px"></div>
|
|
98
|
+
<div class="body" id="body"></div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div id="toast"></div>
|
|
102
|
+
|
|
103
|
+
<script>
|
|
104
|
+
const $ = (s) => document.querySelector(s);
|
|
105
|
+
let PERSONA = null, LIFE = null, MEM = null, CHARS = null;
|
|
106
|
+
let currentTab = 'role';
|
|
107
|
+
const TABS = {
|
|
108
|
+
role: { title: '角色', sub: '人设卡决定她是谁、怎么说话' },
|
|
109
|
+
life: { title: '生活', sub: '她的作息、什么时候做什么、空闲时干什么' },
|
|
110
|
+
memory: { title: '记忆', sub: '她会淡忘,也会越提越深' },
|
|
111
|
+
env: { title: '环境', sub: '她的电脑/屏幕/手机怎么连' },
|
|
112
|
+
model: { title: '模型', sub: '用哪个模型、预算多少' },
|
|
113
|
+
data: { title: '数据', sub: '存哪里、怎么备份' },
|
|
114
|
+
tutorial: { title: '教程', sub: '怎么给她搭一台自己的电脑' },
|
|
115
|
+
};
|
|
116
|
+
function toast(t){ const el=$('#toast'); el.textContent=t; el.style.opacity=1; setTimeout(()=>el.style.opacity=0,1800); }
|
|
117
|
+
async function api(path, opts){ const r = await fetch(path, opts); if(!r.ok) throw new Error('HTTP '+r.status); return await r.json(); }
|
|
118
|
+
async function saveJson(path, obj){ return await api(path, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(obj) }); }
|
|
119
|
+
function esc(s){ return String(s??'').replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
120
|
+
|
|
121
|
+
async function loadPersona(){ PERSONA = await api('/api/persona'); renderNavAva(); }
|
|
122
|
+
async function setSelfStart(v){
|
|
123
|
+
try { await api('/api/env', { method: 'POST', body: JSON.stringify({ selfStart: v }) }); toast(v === 'allow' ? '已允许她自己开机' : '已限制她自己开机'); }
|
|
124
|
+
catch (e) { toast('保存失败'); }
|
|
125
|
+
renderEnv();
|
|
126
|
+
}
|
|
127
|
+
async function envStartAll(){
|
|
128
|
+
try { const r = await api('/api/env/start-all', { method: 'POST' }); toast(r.message || '正在启动'); }
|
|
129
|
+
catch(e){ toast('启动失败'); }
|
|
130
|
+
setTimeout(() => { renderEnv(); }, 3000);
|
|
131
|
+
}
|
|
132
|
+
async function envPower(kind, action){
|
|
133
|
+
const label = kind === 'space' ? '她的屏幕' : '她的手机';
|
|
134
|
+
if (action === 'stop' && !confirm('关掉' + label + '?关掉后她就不能看/用这个环境了。')) return;
|
|
135
|
+
try {
|
|
136
|
+
const r = await api('/api/' + kind + '/' + action, { method: 'POST' });
|
|
137
|
+
toast(r.message || (action === 'stop' ? '已关闭' : '启动中…'));
|
|
138
|
+
} catch (e) { toast('操作失败'); }
|
|
139
|
+
setTimeout(() => { renderEnv(); }, 1200);
|
|
140
|
+
}
|
|
141
|
+
async function runEnvCheck(){
|
|
142
|
+
const box = document.getElementById('envCheckBox');
|
|
143
|
+
if (!box) return;
|
|
144
|
+
box.textContent = '检查中…';
|
|
145
|
+
try {
|
|
146
|
+
const r = await api('/api/env/check');
|
|
147
|
+
let html = '';
|
|
148
|
+
for (const c of (r.checks || [])) html += (c.ok ? '✅ ' : '❌ ') + esc(c.label) + ':' + esc(c.detail) + '<br>';
|
|
149
|
+
if (r.hints && r.hints.length) html += '<div style="margin-top:8px;color:#e0b37a">建议:<br>' + r.hints.map(h => '· ' + esc(h)).join('<br>') + '</div>';
|
|
150
|
+
box.innerHTML = html || '没有检查项';
|
|
151
|
+
} catch (e) { box.textContent = '自检失败:' + e.message; }
|
|
152
|
+
}
|
|
153
|
+
async function installAdbKeyboard(){
|
|
154
|
+
toast('正在安装 ADBKeyboard…');
|
|
155
|
+
try {
|
|
156
|
+
const r = await api('/api/env/install-adbkeyboard', { method: 'POST' });
|
|
157
|
+
toast(r.ok ? '安装成功' : '安装失败');
|
|
158
|
+
if (r.steps) console.log('ADBKeyboard:', r.steps.join(' | '));
|
|
159
|
+
runEnvCheck();
|
|
160
|
+
} catch (e) { toast('安装失败:先确认手机已启动'); }
|
|
161
|
+
}
|
|
162
|
+
async function petRefresh(){
|
|
163
|
+
const box = document.getElementById('petBox');
|
|
164
|
+
if (!box) return;
|
|
165
|
+
try {
|
|
166
|
+
const r = await api('/api/pet');
|
|
167
|
+
const p = r.pet || {};
|
|
168
|
+
box.innerHTML = '状态:' + (r.running ? '🟢 运行中' : '⚪ 未开启') + ' · 形象:' + esc(p.image || 'avatar') + ' · 大小 ' + esc(p.size || 180) + 'px';
|
|
169
|
+
} catch (e) { box.textContent = '读取失败:' + e.message; }
|
|
170
|
+
}
|
|
171
|
+
async function petToggle(on){
|
|
172
|
+
try { await saveJson('/api/pet', { enabled: on }); toast(on ? '桌宠已开启' : '桌宠已关闭'); petRefresh(); } catch (e) { toast('操作失败'); }
|
|
173
|
+
}
|
|
174
|
+
async function petRestart(){
|
|
175
|
+
try { await api('/api/pet/stop', { method: 'POST' }); await api('/api/pet/start', { method: 'POST' }); toast('桌宠已重启'); petRefresh(); } catch (e) { toast('重启失败'); }
|
|
176
|
+
}
|
|
177
|
+
async function petUploadImage(){
|
|
178
|
+
const el = document.getElementById('petImg');
|
|
179
|
+
const f = el && el.files && el.files[0];
|
|
180
|
+
if (!f) { toast('先选一张图片'); return; }
|
|
181
|
+
const rd = new FileReader();
|
|
182
|
+
rd.onload = async () => {
|
|
183
|
+
try {
|
|
184
|
+
const r = await saveJson('/api/pet/image', { dataUrl: rd.result });
|
|
185
|
+
toast(r.ok ? '形象已更新' : '上传失败');
|
|
186
|
+
if (r.ok) await petRestart();
|
|
187
|
+
petRefresh();
|
|
188
|
+
} catch (e) { toast('上传失败'); }
|
|
189
|
+
};
|
|
190
|
+
rd.readAsDataURL(f);
|
|
191
|
+
}
|
|
192
|
+
async function saveApiKey(){
|
|
193
|
+
const el = document.getElementById('apiKeyInput');
|
|
194
|
+
const key = (el && el.value || '').trim();
|
|
195
|
+
if (!key) { toast('先填 API Key'); return; }
|
|
196
|
+
try {
|
|
197
|
+
await saveJson('/api/model/key', { key });
|
|
198
|
+
if (el) el.value = '';
|
|
199
|
+
toast('API Key 已保存');
|
|
200
|
+
} catch (e) { toast('保存失败'); }
|
|
201
|
+
}
|
|
202
|
+
async function setNoParens(on){
|
|
203
|
+
try { await saveJson('/api/chat-style', { noParens: !!on }); toast(on ? '已开启无括号模式' : '已关闭无括号模式'); } catch (e) { toast('保存失败'); }
|
|
204
|
+
}
|
|
205
|
+
async function switchChar(id){
|
|
206
|
+
const r = await saveJson('/api/characters', { action: 'switch', id });
|
|
207
|
+
if (r.ok) { await loadPersona(); await loadChars(); toast('已切换到 ' + (r.character ? r.character.name : id)); go(currentTab); }
|
|
208
|
+
else toast(r.error || '切换失败');
|
|
209
|
+
}
|
|
210
|
+
async function newChar(){
|
|
211
|
+
const name = prompt('新角色叫什么?', '新角色');
|
|
212
|
+
if (!name || !name.trim()) return;
|
|
213
|
+
const r = await saveJson('/api/characters', { action: 'new', name: name.trim() });
|
|
214
|
+
if (r.ok) { await loadPersona(); await loadChars(); toast('已创建'); go('role'); }
|
|
215
|
+
else toast(r.error || '创建失败');
|
|
216
|
+
}
|
|
217
|
+
async function delChar(id){
|
|
218
|
+
if (!confirm('确定删除这个角色?她的记忆/日记/聊天都会一起删掉。')) return;
|
|
219
|
+
const r = await saveJson('/api/characters', { action: 'delete', id });
|
|
220
|
+
if (r.ok) { await loadPersona(); await loadChars(); toast('已删除'); go('role'); }
|
|
221
|
+
else toast(r.error || '删除失败');
|
|
222
|
+
}
|
|
223
|
+
async function loadChars(){ try { CHARS = await api('/api/characters'); } catch(e){ CHARS = null; } }
|
|
224
|
+
async function loadLife(){ LIFE = (await api('/api/life')).life || {}; }
|
|
225
|
+
async function loadMem(){ MEM = await api('/api/memory'); }
|
|
226
|
+
function renderNavAva(){
|
|
227
|
+
if(!PERSONA) return;
|
|
228
|
+
const a = $('#navAva');
|
|
229
|
+
const av = PERSONA.meta.avatar;
|
|
230
|
+
if(av){ a.style.backgroundImage = 'url(' + av + ')'; a.textContent = ''; }
|
|
231
|
+
else { a.style.backgroundImage=''; a.textContent = (PERSONA.meta.name||'?').slice(0,1); }
|
|
232
|
+
$('#navName').textContent = PERSONA.meta.name || '角色';
|
|
233
|
+
const sm = document.getElementById('sideMode'); if (sm) sm.textContent = ((window._mode||{}).effective === 'chat' ? '纯聊天' : '完整版');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ================= 角色 =================
|
|
237
|
+
function renderRole(){
|
|
238
|
+
const p = PERSONA, m = p.meta;
|
|
239
|
+
const traits = m.traits || {};
|
|
240
|
+
const traitNames = Object.keys(traits);
|
|
241
|
+
$('#topAction').innerHTML = '<button class="btn" onclick="importRole()">导入角色卡</button> <button class="btn" onclick="exportRole()">导出</button> <button class="btn primary" onclick="savePersona()">保存</button>';
|
|
242
|
+
const chars = (CHARS && CHARS.characters) || [{ id: 'default', name: m.name, avatar: m.avatar }];
|
|
243
|
+
const activeId = (CHARS && CHARS.active) || 'default';
|
|
244
|
+
const charChips = chars.map(c => '<button class="btn ' + (c.id === activeId ? 'primary' : 'ghost') + '" style="display:flex;align-items:center;gap:8px" onclick="switchChar(\'' + c.id + '\')">'
|
|
245
|
+
+ '<span class="avatar" style="width:22px;height:22px;font-size:11px;' + (c.avatar ? 'background-image:url(' + c.avatar + ');background-size:cover' : '') + '">' + (c.avatar ? '' : esc((c.name || '?').slice(0, 1))) + '</span>'
|
|
246
|
+
+ esc(c.name) + '</button>').join(' ');
|
|
247
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col">'
|
|
248
|
+
+ '<div class="card"><h2>角色列表 <span class="tag">多角色:每个角色有自己的记忆、日记和聊天记录</span></h2>'
|
|
249
|
+
+ '<div class="flex" style="gap:8px;flex-wrap:wrap">' + charChips + '</div>'
|
|
250
|
+
+ '<div class="flex" style="margin-top:10px">'
|
|
251
|
+
+ '<button class="btn ghost" onclick="newChar()">+ 新建角色</button>'
|
|
252
|
+
+ (activeId !== 'default' && chars.length > 1 ? '<button class="btn danger" onclick="delChar(\'' + activeId + '\')">删除当前角色</button>' : '')
|
|
253
|
+
+ '</div><div class="hint">切换角色会切换人设、记忆、日记和聊天记录;运行中的聊天页刷新后跟着变</div></div>'
|
|
254
|
+
+ '<div class="card"><h2>基本信息</h2>'
|
|
255
|
+
+ '<div class="row" style="align-items:flex-start"><label style="width:60px">头像</label><div class="flex">'
|
|
256
|
+
+ '<div class="avatar" id="avatarBox" style="background-image:'+(m.avatar?'url('+m.avatar+')':'none')+'">'+(m.avatar?'':esc((m.name||'芝').slice(0,1)))+'</div>'
|
|
257
|
+
+ '<div><button class="btn sm" onclick="document.getElementById(\'avatarFile\').click()">上传图片</button> <button class="btn sm ghost" onclick="clearAvatar()">用默认</button>'
|
|
258
|
+
+ '<div class="hint">png / jpg / webp</div><input type="file" id="avatarFile" accept="image/*" style="display:none" onchange="uploadAvatar(this)"></div></div></div>'
|
|
259
|
+
+ '<div class="row"><label>名字</label><input type="text" id="pName" value="'+esc(m.name)+'"></div>'
|
|
260
|
+
+ '<div class="row"><label>怎么称呼你</label><input type="text" id="pUser" value="'+esc(m.userCall)+'"></div>'
|
|
261
|
+
+ '<div class="row"><label>关系</label><input type="text" id="pRel" value="'+esc(m.relation)+'"></div>'
|
|
262
|
+
+ '<div class="row"><label>年龄</label><input type="number" id="pAge" value="'+(m.age||'')+'" placeholder="选填,如 17" style="width:100px"></div>'
|
|
263
|
+
+ '<div class="row"><label>有驾照</label><input type="checkbox" id="pLicense" '+(m.license?'checked':'')+' style="width:auto"></div>'
|
|
264
|
+
+ '<div class="row"><label>模型类型</label><select id="pModelType" style="width:160px">'
|
|
265
|
+
+ '<option value="none"'+(m.model&&m.model.type==='none'||!m.model?' selected':'')+'>没有(用头像)</option>'
|
|
266
|
+
+ '<option value="sprite"'+(m.model&&m.model.type==='sprite'?' selected':'')+'>2D 精灵图</option>'
|
|
267
|
+
+ '<option value="live2d"'+(m.model&&m.model.type==='live2d'?' selected':'')+'>Live2D</option>'
|
|
268
|
+
+ '<option value="3d"'+(m.model&&m.model.type==='3d'?' selected':'')+'>3D 模型</option>'
|
|
269
|
+
+ '</select></div>'
|
|
270
|
+
+ '<div class="row"><label>模型地址</label><input type="text" id="pModelUrl" value="'+esc((m.model&&m.model.url)||'')+'" placeholder="模型文件 URL(可留空)"></div>'
|
|
271
|
+
+ '<div class="row"><label>模型缩放</label><input type="number" id="pModelScale" step="0.1" value="'+((m.model&&m.model.scale)||1)+'" style="width:90px"></div>'
|
|
272
|
+
+ '<div class="row"><label>出行偏好</label><select id="pPref" style="width:160px">'
|
|
273
|
+
+ '<option value="auto"'+(m.travelPref==='auto'||!m.travelPref?' selected':'')+'>随便 / 看情况</option>'
|
|
274
|
+
+ '<option value="cheap"'+(m.travelPref==='cheap'?' selected':'')+'>省钱优先</option>'
|
|
275
|
+
+ '<option value="fast"'+(m.travelPref==='fast'?' selected':'')+'>赶时间优先</option>'
|
|
276
|
+
+ '</select></div>'
|
|
277
|
+
+ '</div>'
|
|
278
|
+
+ '<div class="card"><h2>人设正文 <span class="tag">会全部注入,建议 800~1500 字</span></h2>'
|
|
279
|
+
+ '<textarea class="big" id="pBody">'+esc(p.body)+'</textarea>'
|
|
280
|
+
+ '<div class="hint">保存后下一条消息即生效,无需重启</div></div>'
|
|
281
|
+
+ '<div class="card"><h2>说话风格示例</h2>'
|
|
282
|
+
+ (m.examples||[]).map((e,i)=>'<div class="example">“'+esc(e)+'” <span style="float:right;cursor:pointer" onclick="delExample('+i+')">✕</span></div>').join('')
|
|
283
|
+
+ '<div class="flex"><input type="text" id="newExample" placeholder="加一句她会说的话…" style="flex:1"><button class="btn sm" onclick="addExample()">+ 添加示例</button></div></div>'
|
|
284
|
+
+ '</div><div class="col side2">'
|
|
285
|
+
+ '<div class="card"><h2>效果预览</h2><div id="previewBox"></div>'
|
|
286
|
+
+ '<div class="flex" style="margin-top:8px"><input type="text" id="previewIn" placeholder="试一句,比如:今天好累" style="flex:1"><button class="btn sm" onclick="preview()">试</button></div>'
|
|
287
|
+
+ '<div class="hint">用当前人设实时生成(消耗少量 token)</div></div>'
|
|
288
|
+
+ '<div class="card"><h2>性格开关 <span class="tag">软约束,写进人设尾部</span></h2>'
|
|
289
|
+
+ traitNames.map(k=>'<div class="row"><label>'+esc(k)+'</label><div class="slider"><input type="range" min="0" max="10" value="'+(traits[k]??5)+'" id="trait_'+esc(k)+'"></div><span class="small">'+(traits[k]??5)+'</span></div>').join('')
|
|
290
|
+
+ '<div class="flex" style="margin-top:6px"><input type="text" id="newTrait" placeholder="新开关名称" style="width:140px"><button class="btn sm" onclick="addTrait()">+ 添加</button></div></div>'
|
|
291
|
+
+ '<div class="card"><h2>多角色</h2><div class="small">当前:'+esc(m.name)+'</div><div class="hint" style="margin-top:6px">每个角色都有独立的人设、记忆、日记和聊天记录;点上方角色卡切换。</div></div>'
|
|
292
|
+
+ '</div></div>';
|
|
293
|
+
}
|
|
294
|
+
function collectPersona(){
|
|
295
|
+
const traits = {};
|
|
296
|
+
document.querySelectorAll('input[id^=trait_]').forEach(el=>{ traits[el.id.slice(6)] = Number(el.value); });
|
|
297
|
+
const ageVal = Number($('#pAge').value);
|
|
298
|
+
return {
|
|
299
|
+
meta: {
|
|
300
|
+
name: $('#pName').value, userCall: $('#pUser').value, relation: $('#pRel').value,
|
|
301
|
+
avatar: PERSONA.meta.avatar || '', examples: PERSONA.meta.examples || [], traits,
|
|
302
|
+
age: ageVal > 0 ? ageVal : null,
|
|
303
|
+
license: $('#pLicense').checked,
|
|
304
|
+
travelPref: $('#pPref').value || 'auto',
|
|
305
|
+
model: { type: $('#pModelType').value || 'none', url: $('#pModelUrl').value.trim(), scale: Number($('#pModelScale').value) || 1 },
|
|
306
|
+
},
|
|
307
|
+
body: $('#pBody').value,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async function savePersona(){
|
|
311
|
+
const p = collectPersona();
|
|
312
|
+
const r = await saveJson('/api/persona', p);
|
|
313
|
+
if(r.ok){ PERSONA = { meta: r.meta, body: r.body }; renderNavAva(); toast('已保存'); }
|
|
314
|
+
}
|
|
315
|
+
function addExample(){
|
|
316
|
+
const v = $('#newExample').value.trim(); if(!v) return;
|
|
317
|
+
if(!PERSONA.meta.examples) PERSONA.meta.examples=[];
|
|
318
|
+
PERSONA.meta.examples.push(v); renderRole(); toast('已添加,记得保存');
|
|
319
|
+
}
|
|
320
|
+
function delExample(i){ PERSONA.meta.examples.splice(i,1); renderRole(); }
|
|
321
|
+
function addTrait(){
|
|
322
|
+
const v = $('#newTrait').value.trim(); if(!v) return;
|
|
323
|
+
PERSONA.meta.traits = PERSONA.meta.traits || {};
|
|
324
|
+
PERSONA.meta.traits[v] = 5; renderRole(); toast('已添加,记得保存');
|
|
325
|
+
}
|
|
326
|
+
async function preview(){
|
|
327
|
+
const text = $('#previewIn').value.trim() || '在吗';
|
|
328
|
+
$('#previewBox').innerHTML = '<div class="small">生成中…</div>';
|
|
329
|
+
const r = await saveJson('/api/preview', { text });
|
|
330
|
+
$('#previewBox').innerHTML = '<div class="example">你:'+esc(text)+'</div><div class="example" style="background:#101a24">'+esc(PERSONA.meta.name)+':'+esc(r.text||'(没生成出来)')+'</div>';
|
|
331
|
+
}
|
|
332
|
+
async function uploadAvatar(input){
|
|
333
|
+
const f = input.files[0]; if(!f) return;
|
|
334
|
+
if(f.size > 3*1024*1024){ toast('图片太大(<3MB)'); return; }
|
|
335
|
+
const reader = new FileReader();
|
|
336
|
+
reader.onload = async () => { await saveJson('/api/avatar', { dataUrl: reader.result }); await loadPersona(); renderRole(); toast('头像已更新'); };
|
|
337
|
+
reader.readAsDataURL(f);
|
|
338
|
+
}
|
|
339
|
+
async function clearAvatar(){ await saveJson('/api/avatar', { dataUrl: '' }); await loadPersona(); renderRole(); }
|
|
340
|
+
function exportRole(){
|
|
341
|
+
const blob = new Blob([JSON.stringify(collectPersona(), null, 2)], {type:'application/json'});
|
|
342
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (PERSONA.meta.name||'persona') + '-角色卡.json'; a.click();
|
|
343
|
+
}
|
|
344
|
+
function importRole(){
|
|
345
|
+
const inp = document.createElement('input'); inp.type='file'; inp.accept='.json';
|
|
346
|
+
inp.onchange = async () => {
|
|
347
|
+
const f = inp.files[0]; if(!f) return;
|
|
348
|
+
const text = await f.text();
|
|
349
|
+
try {
|
|
350
|
+
const j = JSON.parse(text);
|
|
351
|
+
const isNew = confirm('导入角色卡:\n\n确定 = 新建一个角色\n取消 = 覆盖当前角色');
|
|
352
|
+
if (isNew) {
|
|
353
|
+
const nm = (j.meta && j.meta.name) || '导入的角色';
|
|
354
|
+
const created = await saveJson('/api/characters', { action: 'new', name: nm });
|
|
355
|
+
if (created && created.ok) { await loadChars(); await loadPersona(); }
|
|
356
|
+
}
|
|
357
|
+
if (j.body) await saveJson('/api/persona', j);
|
|
358
|
+
await loadPersona(); renderRole(); toast('导入成功');
|
|
359
|
+
} catch(e){ toast('导入失败:不是有效角色卡'); }
|
|
360
|
+
};
|
|
361
|
+
inp.click();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ================= 生活 =================
|
|
365
|
+
function renderLife(){
|
|
366
|
+
const L = LIFE || {};
|
|
367
|
+
const schedules = L.schedules || [];
|
|
368
|
+
const activities = L.activities || [];
|
|
369
|
+
$('#topAction').innerHTML = '<button class="btn primary" onclick="saveLife()">保存</button>';
|
|
370
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col">'
|
|
371
|
+
+ '<div class="card"><h2>作息 <span class="tag">每天固定的时间点/时段,可增删</span></h2><div id="scheduleList">'
|
|
372
|
+
+ schedules.map((s,i)=>scheduleRow(s,i)).join('')
|
|
373
|
+
+ '</div><div class="flex" style="margin-top:8px"><input type="text" id="sTime" placeholder="时间,如 31:00 或 21:00-22:00" style="width:150px">'
|
|
374
|
+
+ '<input type="text" id="sLabel" placeholder="做什么(自由填写)" style="flex:1"><button class="btn sm" onclick="addSchedule()">+ 添加作息</button></div>'
|
|
375
|
+
+ '<div class="row"><label>安静时段</label><input type="text" id="quiet" value="'+esc((L.quietStart??2)+':00 - '+(L.quietEnd??7)+':00')+'" disabled><span class="hint">在下面"心跳"里改</span></div>'
|
|
376
|
+
+ '</div>'
|
|
377
|
+
+ '<div class="card"><h2>活动池 <span class="tag">空闲时按权重抽;可限定只在某个时段做</span></h2><div id="activityList">'
|
|
378
|
+
+ activities.map((a,i)=>activityRow(a,i)).join('')
|
|
379
|
+
+ '</div><div class="flex" style="margin-top:8px"><input type="text" id="aName" placeholder="活动名,如 刷 B 站" style="flex:1"><button class="btn sm" onclick="addActivity()">+ 添加活动</button></div>'
|
|
380
|
+
+ '<div class="hint">固定时段的活动只在那个时段被抽到;"空闲随时"任何空档都可能做</div></div>'
|
|
381
|
+
+ '</div><div class="col side2">'
|
|
382
|
+
+ '<div class="card"><h2>💬 主动消息 <span class="tag">她会自己找你说话</span></h2>'
|
|
383
|
+
+ '<div class="row"><label>开启</label><input type="checkbox" id="proEnabled" style="width:auto" ' + (L.proactiveEnabled === true ? 'checked' : '') + '><span class="small">开启后她偶尔会主动发消息</span></div>'
|
|
384
|
+
+ '<div class="row"><label>间隔</label><input type="number" id="proMin" value="' + (L.proactiveMinMin ?? 30) + '" style="width:70px"><span class="small">到</span>'
|
|
385
|
+
+ '<input type="number" id="proMax" value="' + (L.proactiveMaxMin ?? 90) + '" style="width:70px"><span class="small">分钟(随机)</span></div>'
|
|
386
|
+
+ '<div class="hint">只在非安静时段、你没在聊的时候发;不报体力数值;一句话。</div></div>'
|
|
387
|
+
+ '<div class="card"><h2>🐾 桌宠 <span class="tag">把她放到桌面上</span></h2>'
|
|
388
|
+
+ '<div id="petBox" class="small">加载中…</div>'
|
|
389
|
+
+ '<div class="flex" style="margin-top:8px"><button class="btn primary sm" onclick="petToggle(true)">开启桌宠</button><button class="btn sm" onclick="petToggle(false)">关闭</button><button class="btn sm" onclick="petRestart()">重启</button></div>'
|
|
390
|
+
+ '<div class="row" style="margin-top:8px"><label>换形象</label><input type="file" id="petImg" accept="image/png,image/jpeg,image/webp" style="flex:1"><button class="btn sm" onclick="petUploadImage()">应用</button></div>'
|
|
391
|
+
+ '<div class="hint">默认用她的头像。自定义摸头/戳一戳反应:编辑数据目录下 pet.json(image / size / gestures / buttons,结构见 README)。</div></div>'
|
|
392
|
+
+ '<div class="card"><h2>心跳</h2>'
|
|
393
|
+
+ '<div class="row"><label>空闲心跳</label><input type="number" id="hbMin" value="'+(L.heartbeatMin??45)+'" style="width:80px"><span class="small">分钟</span></div>'
|
|
394
|
+
+ '<div class="row"><label>主动找你</label><div class="slider"><input type="range" id="proactive" min="0" max="10" value="'+(L.proactive??3)+'"></div></div>'
|
|
395
|
+
+ '<div class="row"><label>智能安排</label><input type="checkbox" id="smartLife" '+(L.smartLife?'checked':'')+' style="width:auto"></div>'
|
|
396
|
+
+ '<div class="hint">开启后:按天气/体力/记忆自动微调作息和活动池(下雨少出门、累了少安排)</div>'
|
|
397
|
+
+ '<div class="row"><label>启用生活</label><input type="checkbox" id="lifeEnabled" '+(L.enabled!==false?'checked':'')+' style="width:auto"></div>'
|
|
398
|
+
+ '<div class="row"><label>安静开始</label><input type="number" id="qStart" value="'+(L.quietStart??2)+'" style="width:70px"><span class="small">时</span></div>'
|
|
399
|
+
+ '<div class="row"><label>安静结束</label><input type="number" id="qEnd" value="'+(L.quietEnd??7)+'" style="width:70px"><span class="small">时</span></div>'
|
|
400
|
+
+ '</div>'
|
|
401
|
+
+ '<div class="card"><h2>日记</h2>'
|
|
402
|
+
+ '<div class="row"><label>写日记时间</label><input type="number" id="diaryHour" value="'+(L.diaryHour??22)+'" style="width:70px"><span class="small">时</span></div>'
|
|
403
|
+
+ '<div class="row"><label>自动写</label><input type="checkbox" id="diaryAuto" '+(L.diaryAuto!==false?'checked':'')+' style="width:auto"></div>'
|
|
404
|
+
+ '<button class="btn ghost" style="width:100%;margin-top:8px" onclick="genDiary()">现在写今天的日记</button></div>'
|
|
405
|
+
+ '<div class="card"><h2>经济 <span class="tag">她自己的虚拟钱包</span></h2>'
|
|
406
|
+
+ '<div class="row"><label>启用经济</label><input type="checkbox" id="ecoEnabled" style="width:auto"></div>'
|
|
407
|
+
+ '<div class="row"><label>初始余额</label><input type="number" id="ecoInitial" style="width:90px"><span class="small">元(新角色/重置时用)</span></div>'
|
|
408
|
+
+ '<div class="row"><label>每日零花钱</label><input type="number" id="ecoDaily" style="width:90px"><span class="small">元/天</span></div>'
|
|
409
|
+
+ '<div class="row"><label>工作/时薪</label><input type="number" id="ecoWage" style="width:90px"><span class="small">元/小时(上班/工作作息时)</span></div>'
|
|
410
|
+
+ '<div class="row"><label>吃饭开销</label><input type="number" id="ecoMeal" style="width:90px"><span class="small">元/顿</span></div>'
|
|
411
|
+
+ '<div class="row"><label>娱乐开销</label><input type="number" id="ecoEnt" style="width:90px"><span class="small">元/次</span></div>'
|
|
412
|
+
+ '<div class="row"><label>货币类型</label><select id="ecoMoneyType" style="width:200px">'
|
|
413
|
+
+ '<option value="virtual">虚拟货币(自己设数值)</option>'
|
|
414
|
+
+ '<option value="token">Token 实时代币(剩余 token)</option>'
|
|
415
|
+
+ '</select></div>'
|
|
416
|
+
+ '<div class="row"><label>Token 预算</label><input type="number" id="ecoTokenBudget" style="width:120px"><span class="small">tokens</span></div>'
|
|
417
|
+
+ '<div class="row"><label>1 元 = </label><input type="number" id="ecoPerYuan" style="width:90px"><span class="small">tokens</span></div>'
|
|
418
|
+
+ '<div class="row"><label>当前</label><span class="small" id="ecoWallet">读取中…</span><button class="btn ghost" onclick="saveEconomyUi()">保存经济</button></div>'
|
|
419
|
+
+ '<div class="hint">交通/吃饭/娱乐都花她的钱;她可以用 spend_money 工具自己买电影票、游戏等,钱不够会被拒绝</div></div>'
|
|
420
|
+
+ '<div class="card"><h2>天气表现 <span class="tag">可上传图片/GIF/视频</span></h2><div id="weatherFxList"><div class="small">读取中…</div></div>'
|
|
421
|
+
+ '<div class="hint">不传就用内置 canvas 粒子;上传后该天气在地图/舞台/3D 上换成你的素材</div></div>'
|
|
422
|
+
+ '<div class="card"><h2>现在</h2><div class="small" id="lifeNow">读取中…</div><button class="btn ghost" style="width:100%;margin-top:8px" onclick="lifeTick()">立刻叫醒她</button></div>'
|
|
423
|
+
+ '</div></div>';
|
|
424
|
+
loadLifeNow();
|
|
425
|
+
loadWeatherFx();
|
|
426
|
+
loadEconomyUi();
|
|
427
|
+
}
|
|
428
|
+
async function loadEconomyUi(){
|
|
429
|
+
try {
|
|
430
|
+
const r = await api('/api/economy');
|
|
431
|
+
const e = r.economy || {}, w = r.wallet || {};
|
|
432
|
+
const setV = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
|
433
|
+
setV('ecoInitial', e.initialBalance); setV('ecoDaily', e.dailyIncome); setV('ecoWage', e.wagePerHour);
|
|
434
|
+
setV('ecoMeal', e.mealCost); setV('ecoEnt', e.entertainmentCost);
|
|
435
|
+
setV('ecoTokenBudget', e.tokenBudget); setV('ecoPerYuan', e.tokensPerYuan);
|
|
436
|
+
const en = document.getElementById('ecoEnabled'); if (en) en.checked = e.enabled !== false;
|
|
437
|
+
const mt = document.getElementById('ecoMoneyType'); if (mt) mt.value = e.moneyType === 'token' ? 'token' : 'virtual';
|
|
438
|
+
const wt = document.getElementById('ecoWallet');
|
|
439
|
+
if (wt) wt.textContent = '余额 ' + (w.spendable !== undefined ? w.spendable : '-') + ' 元 · 剩余 token ' + Number(w.remainingTokens || 0).toLocaleString() + '(已用 ' + Number(w.usedTokens || 0).toLocaleString() + ')' + (w.tokenMode ? ' · 当前用 token 当钱' : '');
|
|
440
|
+
} catch (err) {}
|
|
441
|
+
}
|
|
442
|
+
async function saveEconomyUi(){
|
|
443
|
+
const body = {
|
|
444
|
+
enabled: document.getElementById('ecoEnabled').checked,
|
|
445
|
+
initialBalance: Number(document.getElementById('ecoInitial').value),
|
|
446
|
+
dailyIncome: Number(document.getElementById('ecoDaily').value),
|
|
447
|
+
wagePerHour: Number(document.getElementById('ecoWage').value),
|
|
448
|
+
mealCost: Number(document.getElementById('ecoMeal').value),
|
|
449
|
+
entertainmentCost: Number(document.getElementById('ecoEnt').value),
|
|
450
|
+
moneyType: document.getElementById('ecoMoneyType').value,
|
|
451
|
+
tokenBudget: Number(document.getElementById('ecoTokenBudget').value),
|
|
452
|
+
tokensPerYuan: Number(document.getElementById('ecoPerYuan').value),
|
|
453
|
+
};
|
|
454
|
+
const r = await saveJson('/api/economy', body);
|
|
455
|
+
if (r.ok) { toast('经济设置已保存'); loadEconomyUi(); }
|
|
456
|
+
}
|
|
457
|
+
const WX_ROWS = [
|
|
458
|
+
['sunny', '☀️ 晴天'], ['cloudy', '☁️ 多云'], ['rain', '🌧️ 下雨'], ['snow', '❄️ 下雪'],
|
|
459
|
+
['hot', '🥵 热'], ['cold', '🧣 冷'], ['windy', '💨 大风'],
|
|
460
|
+
];
|
|
461
|
+
let WX_FX = {};
|
|
462
|
+
async function loadWeatherFx(){
|
|
463
|
+
try {
|
|
464
|
+
const r = await api('/api/weatherfx');
|
|
465
|
+
WX_FX = r.fx || {};
|
|
466
|
+
const host = document.getElementById('weatherFxList');
|
|
467
|
+
if (!host) return;
|
|
468
|
+
host.innerHTML = WX_ROWS.map(([k, label]) => {
|
|
469
|
+
const cfg = WX_FX[k];
|
|
470
|
+
return '<div class="row" style="margin:4px 0"><label style="flex:0 0 84px">' + label + '</label>'
|
|
471
|
+
+ '<span class="small" style="flex:1;color:' + (cfg ? '#8fc1ff' : '#7d8fa5') + '">' + (cfg ? '自定义' : '内置效果') + '</span>'
|
|
472
|
+
+ '<button class="btn ghost" onclick="document.getElementById(\'wxfx-' + k + '\').click()">上传</button>'
|
|
473
|
+
+ '<input type="file" id="wxfx-' + k + '" accept="image/*,video/mp4,video/webm" style="display:none" onchange="wxUpload(\'' + k + '\', this)">'
|
|
474
|
+
+ (cfg ? '<button class="btn ghost" onclick="wxClear(\'' + k + '\')">恢复内置</button>' : '')
|
|
475
|
+
+ '</div>';
|
|
476
|
+
}).join('');
|
|
477
|
+
} catch (e) {
|
|
478
|
+
const host = document.getElementById('weatherFxList');
|
|
479
|
+
if (host) host.innerHTML = '<div class="small">读取失败:' + esc(e.message) + '</div>';
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function wxUpload(kind, input){
|
|
483
|
+
const f = input.files && input.files[0];
|
|
484
|
+
if (!f) return;
|
|
485
|
+
if (f.size > 20 * 1024 * 1024) { toast('文件太大(<20MB)'); return; }
|
|
486
|
+
const reader = new FileReader();
|
|
487
|
+
reader.onload = async () => {
|
|
488
|
+
const r = await saveJson('/api/weatherfx/upload', { kind, dataUrl: reader.result });
|
|
489
|
+
if (r.ok) { toast('已应用'); loadWeatherFx(); }
|
|
490
|
+
};
|
|
491
|
+
reader.readAsDataURL(f);
|
|
492
|
+
}
|
|
493
|
+
async function wxClear(kind){
|
|
494
|
+
const r = await saveJson('/api/weatherfx', { kind, clear: true });
|
|
495
|
+
if (r.ok) { toast('已恢复内置效果'); loadWeatherFx(); }
|
|
496
|
+
}
|
|
497
|
+
function scheduleRow(s,i){
|
|
498
|
+
return '<div class="fact"><div class="t">'+esc(s.time)+' '+esc(s.label)+'</div>'
|
|
499
|
+
+ '<div class="meta"><span class="pill">'+esc(s.days||'每天')+'</span><span style="margin-left:auto">'
|
|
500
|
+
+ '<button class="btn sm ghost" onclick="editSchedule('+i+')">编辑</button> <button class="btn sm danger" onclick="delSchedule('+i+')">删除</button></span></div></div>';
|
|
501
|
+
}
|
|
502
|
+
function activityRow(a,i){
|
|
503
|
+
const inWindow = !!a.window;
|
|
504
|
+
return '<div class="fact"><div class="t">'+esc(a.name)+'</div>'
|
|
505
|
+
+ '<div class="meta">'
|
|
506
|
+
+ '<span class="chip'+(inWindow?'':' on')+'" onclick="toggleActivityWindow('+i+')">'+(inWindow?esc(a.window):'空闲随时')+'</span>'
|
|
507
|
+
+ '<span>权重</span><div class="bar"><i style="width:'+Math.min(100,Number(a.weight)||10)+'%"></i></div><span>'+Number(a.weight||10)+'%</span>'
|
|
508
|
+
+ '<span class="pill">'+(a.minMin||5)+'~'+(a.maxMin||20)+' 分钟</span>'
|
|
509
|
+
+ '<span style="margin-left:auto"><button class="btn sm ghost" onclick="editActivity('+i+')">编辑</button> <button class="btn sm danger" onclick="delActivity('+i+')">删除</button></span>'
|
|
510
|
+
+ '</div></div>';
|
|
511
|
+
}
|
|
512
|
+
function addSchedule(){
|
|
513
|
+
const time = $('#sTime').value.trim(), label = $('#sLabel').value.trim();
|
|
514
|
+
if(!time || !label){ toast('时间、内容都要填'); return; }
|
|
515
|
+
LIFE.schedules = LIFE.schedules || [];
|
|
516
|
+
LIFE.schedules.push({ time, label, days: '每天' });
|
|
517
|
+
renderLife();
|
|
518
|
+
}
|
|
519
|
+
function delSchedule(i){ LIFE.schedules.splice(i,1); renderLife(); }
|
|
520
|
+
function editSchedule(i){
|
|
521
|
+
const s = LIFE.schedules[i];
|
|
522
|
+
const time = prompt('时间(如 07:20 或 21:00-22:00)', s.time); if(time===null) return;
|
|
523
|
+
const label = prompt('做什么', s.label); if(label===null) return;
|
|
524
|
+
const days = prompt('频率(每天/工作日/周末)', s.days||'每天') || '每天';
|
|
525
|
+
LIFE.schedules[i] = { time, label, days }; renderLife();
|
|
526
|
+
}
|
|
527
|
+
function addActivity(){
|
|
528
|
+
const name = $('#aName').value.trim(); if(!name){ toast('填活动名'); return; }
|
|
529
|
+
LIFE.activities = LIFE.activities || [];
|
|
530
|
+
LIFE.activities.push({ name, window: '', weight: 20, minMin: 5, maxMin: 20 });
|
|
531
|
+
renderLife();
|
|
532
|
+
}
|
|
533
|
+
function delActivity(i){ LIFE.activities.splice(i,1); renderLife(); }
|
|
534
|
+
function editActivity(i){
|
|
535
|
+
const a = LIFE.activities[i];
|
|
536
|
+
const name = prompt('活动名', a.name); if(name===null) return;
|
|
537
|
+
const weight = prompt('权重(数字,越大越常做)', String(a.weight||20)); if(weight===null) return;
|
|
538
|
+
const minMin = prompt('最短几分钟', String(a.minMin||5)); if(minMin===null) return;
|
|
539
|
+
const maxMin = prompt('最长几分钟', String(a.maxMin||20)); if(maxMin===null) return;
|
|
540
|
+
const win = prompt('限定时间段(留空=空闲随时),如 22:00-01:30', a.window||'');
|
|
541
|
+
LIFE.activities[i] = { name, window: (win||'').trim(), weight: Number(weight)||10, minMin: Number(minMin)||5, maxMin: Number(maxMin)||20 };
|
|
542
|
+
renderLife();
|
|
543
|
+
}
|
|
544
|
+
function toggleActivityWindow(i){
|
|
545
|
+
const a = LIFE.activities[i];
|
|
546
|
+
const win = prompt('限定时间段(留空=空闲随时),如 22:00-01:30', a.window||'');
|
|
547
|
+
if(win===null) return;
|
|
548
|
+
a.window = win.trim(); renderLife();
|
|
549
|
+
}
|
|
550
|
+
async function saveLife(){
|
|
551
|
+
LIFE.heartbeatMin = Number($('#hbMin').value)||45;
|
|
552
|
+
LIFE.proactive = Number($('#proactive').value)||0;
|
|
553
|
+
LIFE.enabled = $('#lifeEnabled').checked;
|
|
554
|
+
LIFE.smartLife = $('#smartLife').checked;
|
|
555
|
+
LIFE.quietStart = Number($('#qStart').value)||2;
|
|
556
|
+
LIFE.quietEnd = Number($('#qEnd').value)||7;
|
|
557
|
+
LIFE.diaryHour = Number($('#diaryHour').value)||22;
|
|
558
|
+
LIFE.diaryAuto = $('#diaryAuto').checked;
|
|
559
|
+
LIFE.proactiveEnabled = $('#proEnabled') ? $('#proEnabled').checked : false;
|
|
560
|
+
LIFE.proactiveMinMin = Number(($('#proMin')||{}).value)||30;
|
|
561
|
+
LIFE.proactiveMaxMin = Number(($('#proMax')||{}).value)||90;
|
|
562
|
+
const r = await saveJson('/api/life', LIFE);
|
|
563
|
+
if(r.ok){ LIFE = r.life; toast('已保存'); }
|
|
564
|
+
}
|
|
565
|
+
async function loadLifeNow(){
|
|
566
|
+
try {
|
|
567
|
+
const st = await api('/api/status');
|
|
568
|
+
$('#lifeNow').innerHTML = '记忆 '+(st.memory?st.memory.live:0)+' 条 · 今天日记 '+(st.diaryToday?'已写':'还没写');
|
|
569
|
+
} catch(e){ $('#lifeNow').textContent = '读取失败'; }
|
|
570
|
+
}
|
|
571
|
+
async function genDiary(){
|
|
572
|
+
const r = await saveJson('/api/diary/generate', {});
|
|
573
|
+
toast(r.ok ? '日记写好了' : '写失败');
|
|
574
|
+
if(r.ok) alert('今天的日记:\n\n' + (r.text||''));
|
|
575
|
+
}
|
|
576
|
+
async function lifeTick(){ await saveJson('/api/life-tick', {}); toast('已叫她'); loadLifeNow(); }
|
|
577
|
+
|
|
578
|
+
// ================= 记忆 =================
|
|
579
|
+
// ================= 地图 =================
|
|
580
|
+
let MAPDATA = null;
|
|
581
|
+
let MAPPLACES = [];
|
|
582
|
+
let MAPSCHEDULES = [];
|
|
583
|
+
async function renderMap(){
|
|
584
|
+
$('#topAction').innerHTML = '<button class="btn primary" onclick="saveMap()">保存地图</button>';
|
|
585
|
+
let data;
|
|
586
|
+
try { data = await api('/api/map'); } catch(e) { $('#body').innerHTML = '<div class="small">地图加载失败:'+esc(e.message)+'</div>'; return; }
|
|
587
|
+
MAPDATA = data.map; MAPPLACES = data.map.places.map(p => ({...p})); MAPSCHEDULES = data.schedules || [];
|
|
588
|
+
window._mapCurrent = data.current;
|
|
589
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col">'
|
|
590
|
+
+ '<div class="card"><h2>小鱼镇 · 地图 <span class="tag">拖动图钉调整位置;点「保存地图」生效</span></h2>'
|
|
591
|
+
+ '<div id="mapWrap" style="position:relative;width:100%;border-radius:12px;overflow:hidden;border:1px solid #26333f;background:#0d1319">'
|
|
592
|
+
+ mapSvg()
|
|
593
|
+
+ '<div id="mapPins" style="position:absolute;inset:0"></div>'
|
|
594
|
+
+ '</div>'
|
|
595
|
+
+ '<div class="hint">提示:位置就是她在镇上的坐标;作息里安排了地点后,她自己会在合适的时间出现在合适的地方。</div></div>'
|
|
596
|
+
+ '<div class="card"><h2>当前 <span class="tag">根据作息自动判断</span></h2>'
|
|
597
|
+
+ '<div class="flex" style="gap:14px;align-items:center">'
|
|
598
|
+
+ '<div style="font-size:36px">'+esc((data.current&&data.current.icon)||'📍')+'</div>'
|
|
599
|
+
+ '<div><div style="font-weight:600;font-size:16px">'+esc((data.current&&data.current.name)||'家')+'</div>'
|
|
600
|
+
+ '<div class="small">'+esc((data.current&&data.current.desc)||'')+(data.current&&data.current.reason?' · 正在:'+esc(data.current.reason):'')+'</div></div></div></div>'
|
|
601
|
+
+ '</div><div class="col side2">'
|
|
602
|
+
+ '<div class="card"><h2>地点列表</h2><div id="placeList"></div>'
|
|
603
|
+
+ '<div class="flex" style="margin-top:8px"><input type="text" id="newPlaceName" placeholder="新地点名字" style="flex:1"><button class="btn sm" onclick="addPlace()">+ 添加</button></div></div>'
|
|
604
|
+
+ '<div class="card"><h2>行程地点</h2><div class="small" style="margin-bottom:8px">把作息对应到地点,她到点就会在那里</div><div id="schedList"></div></div>'
|
|
605
|
+
+ '</div></div>';
|
|
606
|
+
renderPlaceList(); renderSchedList(); renderPins();
|
|
607
|
+
}
|
|
608
|
+
function mapSvg(){
|
|
609
|
+
return '<svg viewBox="0 0 1000 640" style="display:block;width:100%" xmlns="http://www.w3.org/2000/svg">'
|
|
610
|
+
+ '<defs>'
|
|
611
|
+
+ '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#0e1a2b"/><stop offset="100%" stop-color="#122334"/></linearGradient>'
|
|
612
|
+
+ '<linearGradient id="grass" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#16302b"/><stop offset="100%" stop-color="#1d3a33"/></linearGradient>'
|
|
613
|
+
+ '</defs>'
|
|
614
|
+
+ '<rect width="1000" height="640" fill="url(#sky)"/>'
|
|
615
|
+
+ '<rect y="70" width="1000" height="570" fill="url(#grass)"/>'
|
|
616
|
+
+ '<ellipse cx="800" cy="470" rx="150" ry="80" fill="#10304a" stroke="#1d5377" stroke-width="4"/>'
|
|
617
|
+
+ '<ellipse cx="780" cy="450" rx="90" ry="40" fill="#174266" opacity=".7"/>'
|
|
618
|
+
+ '<path d="M-20 300 C 180 240, 320 330, 520 300 C 720 270, 820 330, 1020 290" fill="none" stroke="#2a3a44" stroke-width="46" stroke-linecap="round"/>'
|
|
619
|
+
+ '<path d="M-20 300 C 180 240, 320 330, 520 300 C 720 270, 820 330, 1020 290" fill="none" stroke="#3a4f5c" stroke-width="8" stroke-dasharray="26 22" opacity=".7"/>'
|
|
620
|
+
+ '<path d="M500 320 C 505 400, 470 520, 505 640" fill="none" stroke="#2a3a44" stroke-width="34" stroke-linecap="round"/>'
|
|
621
|
+
+ '<path d="M500 320 C 505 400, 470 520, 505 640" fill="none" stroke="#3a4f5c" stroke-width="6" stroke-dasharray="18 16" opacity=".7"/>'
|
|
622
|
+
+ [[80,420],[180,520],[420,150],[500,520],[880,260],[900,520],[640,540],[300,560]].map(([x,y]) =>
|
|
623
|
+
'<g><circle cx="'+x+'" cy="'+y+'" r="16" fill="#1e4637" stroke="#2d6b52" stroke-width="3"/><circle cx="'+x+'" cy="'+y+'" r="6" fill="#2d6b52"/></g>').join('')
|
|
624
|
+
+ '<text x="30" y="48" font-size="22" fill="#7f9aad" font-family="Microsoft YaHei, sans-serif">小鱼镇 · 生活区</text>'
|
|
625
|
+
+ '</svg>';
|
|
626
|
+
}
|
|
627
|
+
function renderPins(){
|
|
628
|
+
const wrap = document.getElementById('mapPins');
|
|
629
|
+
if (!wrap) return;
|
|
630
|
+
wrap.innerHTML = '';
|
|
631
|
+
MAPPLACES.forEach((p, i) => {
|
|
632
|
+
const el = document.createElement('div');
|
|
633
|
+
el.style.cssText = 'position:absolute;left:'+(p.x*100)+'%;top:'+(p.y*100)+'%;transform:translate(-50%,-100%);cursor:grab;user-select:none;text-align:center;filter:drop-shadow(0 3px 4px rgba(0,0,0,.5))';
|
|
634
|
+
el.innerHTML = '<div style="font-size:22px">'+esc(p.icon)+'</div>'
|
|
635
|
+
+ '<div style="font-size:11px;color:#d7e3ee;background:#14202c;padding:1px 6px;border-radius:8px;border:1px solid #2a3a4a;white-space:nowrap">'+esc(p.name)+'</div>'
|
|
636
|
+
+ '<div style="width:2px;height:8px;background:#2a3a4a;margin:0 auto"></div>';
|
|
637
|
+
el.addEventListener('pointerdown', (ev) => {
|
|
638
|
+
ev.preventDefault();
|
|
639
|
+
const box = document.getElementById('mapWrap').getBoundingClientRect();
|
|
640
|
+
const move = (e) => {
|
|
641
|
+
const x = Math.max(0.05, Math.min(0.95, (e.clientX - box.left) / box.width));
|
|
642
|
+
const y = Math.max(0.08, Math.min(0.92, (e.clientY - box.top) / box.height));
|
|
643
|
+
p.x = x; p.y = y;
|
|
644
|
+
el.style.left = (x*100)+'%'; el.style.top = (y*100)+'%';
|
|
645
|
+
};
|
|
646
|
+
const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); };
|
|
647
|
+
document.addEventListener('pointermove', move);
|
|
648
|
+
document.addEventListener('pointerup', up);
|
|
649
|
+
});
|
|
650
|
+
wrap.appendChild(el);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
function renderPlaceList(){
|
|
654
|
+
const host = document.getElementById('placeList');
|
|
655
|
+
if (!host) return;
|
|
656
|
+
host.innerHTML = MAPPLACES.map((p, i) => '<div class="fact" style="padding:8px 10px"><div class="t" style="display:flex;gap:6px;align-items:center">'
|
|
657
|
+
+ '<input type="text" value="'+esc(p.icon)+'" oninput="MAPPLACES['+i+'].icon=this.value" style="width:44px;text-align:center;padding:3px 4px">'
|
|
658
|
+
+ '<input type="text" value="'+esc(p.name)+'" oninput="MAPPLACES['+i+'].name=this.value" style="flex:1;padding:3px 6px">'
|
|
659
|
+
+ '<button class="btn sm danger" onclick="delPlace('+i+')">✕</button></div>'
|
|
660
|
+
+ '<div class="hint" style="margin-top:4px"><input type="text" value="'+esc(p.desc||'')+'" placeholder="一句话描述" oninput="MAPPLACES['+i+'].desc=this.value" style="width:100%;padding:3px 6px;font-size:12px"></div></div>').join('');
|
|
661
|
+
}
|
|
662
|
+
function renderSchedList(){
|
|
663
|
+
const host = document.getElementById('schedList');
|
|
664
|
+
if (!host) return;
|
|
665
|
+
if (!MAPSCHEDULES.length) { host.innerHTML = '<div class="small">还没有作息,去「生活」页加几条</div>'; return; }
|
|
666
|
+
host.innerHTML = MAPSCHEDULES.map((s, i) => '<div class="row" style="margin:6px 0"><span class="small" style="flex:1">'+esc(s.time)+' '+esc(s.label)+'</span>'
|
|
667
|
+
+ '<select style="width:110px;padding:3px 6px" onchange="MAPSCHEDULES['+i+'].place=this.value; this.style.color=\"#cfe0f7\"">'
|
|
668
|
+
+ MAPPLACES.map(p => '<option value="'+esc(p.id)+'"'+(s.place===p.id?' selected':'')+'>'+esc(p.icon+' '+p.name)+'</option>').join('')
|
|
669
|
+
+ '</select></div>').join('');
|
|
670
|
+
}
|
|
671
|
+
function addPlace(){
|
|
672
|
+
const inp = document.getElementById('newPlaceName');
|
|
673
|
+
const name = (inp && inp.value || '').trim();
|
|
674
|
+
if (!name) return;
|
|
675
|
+
MAPPLACES.push({ id: 'p-' + Date.now().toString(36), name, icon: '📍', x: 0.5, y: 0.5, desc: '' });
|
|
676
|
+
if (inp) inp.value = '';
|
|
677
|
+
renderPlaceList(); renderSchedList(); renderPins();
|
|
678
|
+
}
|
|
679
|
+
function delPlace(i){
|
|
680
|
+
MAPPLACES.splice(i, 1);
|
|
681
|
+
renderPlaceList(); renderSchedList(); renderPins();
|
|
682
|
+
}
|
|
683
|
+
async function saveMap(){
|
|
684
|
+
const r = await saveJson('/api/map', { name: MAPDATA.name, places: MAPPLACES, schedules: MAPSCHEDULES.map((s, i) => ({ index: s.index !== undefined ? s.index : i, place: s.place })) });
|
|
685
|
+
if (r.ok) { toast('地图已保存'); window._mapCurrent = r.current; renderMap(); }
|
|
686
|
+
}
|
|
687
|
+
async function renderMemory(){
|
|
688
|
+
MEM = await api('/api/memory');
|
|
689
|
+
const st = MEM.stats || {};
|
|
690
|
+
const facts = MEM.facts || [];
|
|
691
|
+
const core = facts.filter(f=>f.pinned||(f.strength||0)>=0.85);
|
|
692
|
+
const normal = facts.filter(f=>!core.includes(f));
|
|
693
|
+
const params = MEM.params || {};
|
|
694
|
+
$('#topAction').innerHTML = '<button class="btn" onclick="renderMemory(true)">刷新</button> <button class="btn primary" onclick="saveMemParams()">保存参数</button>';
|
|
695
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col">'
|
|
696
|
+
+ '<div class="stat">'
|
|
697
|
+
+ '<div class="s"><div class="n">'+(st.live??0)+'</div><div class="l">活着的记忆</div></div>'
|
|
698
|
+
+ '<div class="s"><div class="n">'+(st.core??0)+'</div><div class="l">核心(不遗忘)</div></div>'
|
|
699
|
+
+ '<div class="s"><div class="n">'+(st.archived??0)+'</div><div class="l">已模糊归档</div></div>'
|
|
700
|
+
+ '<div class="s"><div class="n">'+(MEM.diaryCount??0)+'</div><div class="l">日记篇数</div></div>'
|
|
701
|
+
+ '</div>'
|
|
702
|
+
+ '<div class="card"><h2>核心记忆 <span class="tag">永远记得</span></h2>'
|
|
703
|
+
+ (core.length ? core.map(factRow).join('') : '<div class="small">还没有核心记忆。重要的事可以置顶。</div>')
|
|
704
|
+
+ '</div>'
|
|
705
|
+
+ '<div class="card"><h2>普通记忆 <span class="tag">按半衰期淡忘,提到会加深</span></h2>'
|
|
706
|
+
+ (normal.length ? normal.map(factRow).join('') : '<div class="small">暂无</div>')
|
|
707
|
+
+ '</div></div><div class="col side2">'
|
|
708
|
+
+ '<div class="card"><h2>遗忘参数</h2>'
|
|
709
|
+
+ '<div class="row"><label>半衰期</label><input type="number" id="mHalf" value="'+(params.halfLifeDays??5)+'" style="width:80px"><span class="small">天</span></div>'
|
|
710
|
+
+ '<div class="row"><label>遗忘阈值</label><input type="number" id="mThr" value="'+(params.forgetThreshold??0.12)+'" style="width:80px"></div>'
|
|
711
|
+
+ '<div class="row"><label>提到强化</label><input type="number" id="mBoost" value="'+(params.reinforceBoost??0.22)+'" style="width:80px"></div>'
|
|
712
|
+
+ '<div class="row"><label>每轮注入</label><input type="number" id="mMax" value="'+(params.maxInjected??8)+'" style="width:80px"><span class="small">条</span></div>'
|
|
713
|
+
+ '<div class="hint">半衰期越短忘得越快;核心记忆不受影响</div></div>'
|
|
714
|
+
+ '<div class="card"><h2>管理记忆</h2><div class="small">批量删除 · 恢复归档 · 导入导出</div>'
|
|
715
|
+
+ '<button class="btn ghost" style="width:100%;margin-top:8px" onclick="manageArchived()">查看归档('+(st.archived??0)+')</button>'
|
|
716
|
+
+ '<button class="btn ghost" style="width:100%;margin-top:8px" onclick="exportMemory()">导出记忆</button></div>'
|
|
717
|
+
+ '<div class="card"><h2>日记</h2><div id="diaryBox" class="small">加载中…</div>'
|
|
718
|
+
+ '<button class="btn ghost" style="width:100%;margin-top:8px" onclick="showDiaryList()">打开日记列表</button>'
|
|
719
|
+
+ '<button class="btn ghost" style="width:100%;margin-top:8px" onclick="showDiaryToHer()">把今天的日记给她看</button></div>'
|
|
720
|
+
+ '</div></div>';
|
|
721
|
+
loadDiary();
|
|
722
|
+
}
|
|
723
|
+
function factRow(f){
|
|
724
|
+
const s = Number(f.strength||0);
|
|
725
|
+
const cls = f.pinned||s>=0.85 ? 'core' : (s<0.2?'faint':'');
|
|
726
|
+
const label = f.pinned||s>=0.85 ? '核心' : (s<0.2?'快忘了':'');
|
|
727
|
+
return '<div class="fact"><div class="t">'+esc(f.text)+'</div><div class="meta">'
|
|
728
|
+
+ (label?'<span class="pill '+cls+'">'+label+'</span>':'')
|
|
729
|
+
+ '<span>清晰度</span><div class="bar"><i style="width:'+Math.round(s*100)+'%"></i></div><span>'+Math.round(s*100)+'%</span>'
|
|
730
|
+
+ '<span>提到 '+(f.hitCount||1)+' 次</span>'
|
|
731
|
+
+ '<span style="margin-left:auto">'
|
|
732
|
+
+ '<button class="btn sm ghost" onclick="pinFact('+f.id+')">'+(f.pinned?'取消置顶':'置顶')+'</button> '
|
|
733
|
+
+ '<button class="btn sm danger" onclick="delFact('+f.id+')">删除</button></span>'
|
|
734
|
+
+ '</div></div>';
|
|
735
|
+
}
|
|
736
|
+
async function pinFact(id){ await saveJson('/api/memory/pin', {id}); renderMemory(true); }
|
|
737
|
+
async function delFact(id){ if(!confirm('确定删掉这条记忆?')) return; await saveJson('/api/memory/delete', {id}); renderMemory(true); }
|
|
738
|
+
async function saveMemParams(){
|
|
739
|
+
const params = { halfLifeDays: Number($('#mHalf').value), forgetThreshold: Number($('#mThr').value), reinforceBoost: Number($('#mBoost').value), maxInjected: Number($('#mMax').value) };
|
|
740
|
+
const r = await saveJson('/api/memory/params', params);
|
|
741
|
+
if(r.ok){ toast('参数已保存'); }
|
|
742
|
+
}
|
|
743
|
+
async function loadDiary(){
|
|
744
|
+
const r = await api('/api/diary');
|
|
745
|
+
const dates = r.dates || [];
|
|
746
|
+
$('#diaryBox').innerHTML = dates.length ? '共 '+dates.length+' 篇,最近:'+dates[0] : '还没有日记';
|
|
747
|
+
}
|
|
748
|
+
function showDiaryList(){
|
|
749
|
+
const dates = (MEM && MEM.diaryDates) || [];
|
|
750
|
+
api('/api/diary').then(r=>{
|
|
751
|
+
const list = (r.dates||[]).join('\n');
|
|
752
|
+
const d = prompt('输入日期查看日记(YYYY-MM-DD):', (r.dates||[])[0] || '');
|
|
753
|
+
if(!d) return;
|
|
754
|
+
api('/api/diary?date='+encodeURIComponent(d)).then(x=>alert(x.text || '没有这篇日记'));
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
async function showDiaryToHer(){
|
|
758
|
+
const r = await saveJson('/api/diary/show', {});
|
|
759
|
+
toast(r.ok ? '已给她看了' : (r.error||'失败'));
|
|
760
|
+
}
|
|
761
|
+
function exportMemory(){
|
|
762
|
+
api('/api/memory').then(m=>{
|
|
763
|
+
const blob = new Blob([JSON.stringify(m, null, 2)], {type:'application/json'});
|
|
764
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download='memory-export.json'; a.click();
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
function manageArchived(){
|
|
768
|
+
api('/api/memory').then(m=>{
|
|
769
|
+
const arch = m.archived||[];
|
|
770
|
+
if(!arch.length){ alert('归档里是空的'); return; }
|
|
771
|
+
alert('归档记忆:\n\n' + arch.map(f=>'· '+f.text).join('\n') + '\n\n(恢复功能将在记忆管理器页面里做)');
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ================= 环境/模型/数据 =================
|
|
776
|
+
async function renderEnv(){
|
|
777
|
+
$('#topAction').innerHTML = '<button class="btn primary" onclick="saveEnv()">保存</button>';
|
|
778
|
+
const inChat = ((window._mode||{}).effective === 'chat');
|
|
779
|
+
let env = { pc: 'gui', space: 'gui', phone: 'gui', confirmDanger: true };
|
|
780
|
+
let res = { space: false, phone: false, mem: [] };
|
|
781
|
+
try { const envResp = await api('/api/env'); env = envResp.env || env; res = envResp.resources || res; } catch(e){}
|
|
782
|
+
window._res = res;
|
|
783
|
+
window._env = env;
|
|
784
|
+
const targets = [
|
|
785
|
+
{ k: 'pc', icon: '🖥️', name: '用户的电脑', desc: 'Windows 桌面。她可以截图看屏幕、用鼠标键盘点东西。' },
|
|
786
|
+
{ k: 'space', icon: '💻', name: '她自己的屏幕', desc: 'Linux 虚拟机。她自己的桌面,随便折腾。' },
|
|
787
|
+
{ k: 'phone', icon: '📱', name: '她自己的手机', desc: 'Android 16 模拟器(只验证过 AVD DSH_Phone)。截图、点击、滑动、输入。' },
|
|
788
|
+
];
|
|
789
|
+
const levels = [
|
|
790
|
+
{ v: 'off', label: '关闭', cls: '' },
|
|
791
|
+
{ v: 'gui', label: '仅鼠标键盘', cls: 'ok' },
|
|
792
|
+
{ v: 'code', label: '代码控制(危险)', cls: 'warn' },
|
|
793
|
+
];
|
|
794
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col">'
|
|
795
|
+
+ (inChat ? '<div class="card" style="border-color:#2f6bd8;background:#0f1825"><h2 style="color:#8fc1ff">当前是纯聊天模式</h2><div class="small">这些环境工具不会被启用。想让她有屏幕/手机,切到上方「完整版」。</div></div>' : '')
|
|
796
|
+
+ '<div class="card" style="border-color:#5a3a2a;background:#1a1410"><h2 style="color:#e0b37a">⚠️ 权限分两级</h2>'
|
|
797
|
+
+ '<div class="small" style="color:#c9a97a">「仅鼠标键盘」= 像人一样看屏幕、点鼠标、打字,安全;「代码控制」= 直接执行命令、读写文件,强大但危险,只在你信任时开启。</div></div>'
|
|
798
|
+
+ '<div class="card"><h2>🧪 环境自检 <span class="tag">完整版进不去先点这里</span></h2>'
|
|
799
|
+
+ '<div class="flex"><button class="btn primary sm" onclick="runEnvCheck()">开始自检</button><button class="btn sm" onclick="installAdbKeyboard()">安装 ADBKeyboard</button></div>'
|
|
800
|
+
+ '<div id="envCheckBox" class="small" style="margin-top:10px;line-height:1.8">还没检查。点「开始自检」看看缺什么。</div></div>'
|
|
801
|
+
+ targets.map(tg => '<div class="card"><h2>'+tg.icon+' '+tg.name+' <span class="tag">'+tg.k+'</span></h2>'
|
|
802
|
+
+ '<div class="small" style="margin-bottom:10px">'+tg.desc+'</div>'
|
|
803
|
+
+ '<div class="flex">'
|
|
804
|
+
+ levels.map(lv => '<button class="btn '+(env[tg.k]===lv.v?(lv.v==='code'?'danger':'primary'):'ghost')+'" onclick="setPerm(\''+tg.k+'\',\''+lv.v+'\')">'+lv.label+'</button>').join('')
|
|
805
|
+
+ '</div>'
|
|
806
|
+
+ '<div class="hint">当前:'+({off:'关闭',gui:'仅鼠标键盘',code:'代码控制'}[env[tg.k]]||'仅鼠标键盘')+'</div>'
|
|
807
|
+
+ '</div>').join('')
|
|
808
|
+
+ '</div><div class="col side2">'
|
|
809
|
+
+ '<div class="card"><h2>⚡ 环境开关 <span class="tag">不用时关掉省内存</span></h2>'
|
|
810
|
+
+ '<div class="row"><label>她的屏幕</label><span class="small" style="flex:1">' + (res.space ? '🟢 运行中' : '⚪ 已关闭') + '</span>'
|
|
811
|
+
+ (res.space
|
|
812
|
+
? '<button class="btn danger sm" onclick="envPower(\'space\',\'stop\')">关闭</button>'
|
|
813
|
+
: '<button class="btn primary sm" onclick="envPower(\'space\',\'start\')">启动</button>') + '</div>'
|
|
814
|
+
+ '<div class="row"><button class="btn primary sm" onclick="envStartAll()">一键启动整个环境</button><span class="hint" style="flex:1;margin-left:8px">依次启动屏幕和手机(内存不够会跳过)</span></div>'
|
|
815
|
+
+ '<div class="row"><label>她的手机</label><span class="small" style="flex:1">' + (res.phone ? '🟢 运行中' : '⚪ 已关闭') + '</span>'
|
|
816
|
+
+ (res.phone
|
|
817
|
+
? '<button class="btn danger sm" onclick="envPower(\'phone\',\'stop\')">关闭</button>'
|
|
818
|
+
: '<button class="btn primary sm" onclick="envPower(\'phone\',\'start\')">启动</button>') + '</div>'
|
|
819
|
+
+ '<div class="row" style="margin-top:8px"><label>她自己能开机</label><span class="small" style="flex:1">' + (env.selfStart === 'allow' ? '🟢 允许她自己启动屏幕/手机' : '⚪ 不允许(默认,省内存)') + '</span>'
|
|
820
|
+
+ '<button class="btn ' + (env.selfStart === 'allow' ? 'ghost' : 'primary') + ' sm" onclick="setSelfStart(\'' + (env.selfStart === 'allow' ? 'deny' : 'allow') + '\')">' + (env.selfStart === 'allow' ? '改成不允许' : '允许她自己开') + '</button></div>'
|
|
821
|
+
+ '<div class="hint">不允许:屏幕/手机没开时她不会自己启动,只能你手动开;允许:她可以自己启动(会占内存,只有你不在电脑前时才建议开)。</div>'
|
|
822
|
+
+ '<div class="hint">屏幕虚拟机开着约占 <b>2~6 GB</b> 内存,手机模拟器约占 <b>2~4 GB</b>;长时间不用建议关掉,需要时再点启动(约 1 分钟)。</div>'
|
|
823
|
+
+ (res.mem && res.mem.length ? '<div class="hint">当前占用:' + res.mem.join(' · ') + '</div>' : '')
|
|
824
|
+
+ '</div>'
|
|
825
|
+
+ '<div class="card"><h2>安全选项</h2>'
|
|
826
|
+
+ '<div class="row"><label>危险操作确认</label><input type="checkbox" id="confirmDanger" style="width:auto" '+(env.confirmDanger!==false?'checked':'')+'></div>'
|
|
827
|
+
+ '<div class="hint">开启后,删除/卸载/关机/转账/发消息等操作她会先问你</div></div>'
|
|
828
|
+
+ '<div class="card"><h2>说明</h2>'
|
|
829
|
+
+ '<div class="small">· 代码控制是按目标分别开的:可以只给她的屏幕开,不给你的电脑开。</div>'
|
|
830
|
+
+ '<div class="small" style="margin-top:6px">· 开了代码控制后,她的工具里会多一个 code 工具;关掉后立即消失。</div>'
|
|
831
|
+
+ '<div class="small" style="margin-top:6px">· 人设里也会告诉她当前权限,她不会越权。</div></div>'
|
|
832
|
+
+ '</div></div>';
|
|
833
|
+
}
|
|
834
|
+
function setPerm(k, v){ window._env[k] = v; renderEnv(); }
|
|
835
|
+
async function saveEnv(){
|
|
836
|
+
const env = {
|
|
837
|
+
pc: window._env.pc, space: window._env.space, phone: window._env.phone,
|
|
838
|
+
confirmDanger: document.getElementById('confirmDanger').checked,
|
|
839
|
+
};
|
|
840
|
+
const r = await saveJson('/api/env', env);
|
|
841
|
+
if(r.ok){ toast('权限已保存'); renderEnv(); }
|
|
842
|
+
}
|
|
843
|
+
async function renderModel(){
|
|
844
|
+
$('#topAction').innerHTML = '<button class="btn primary" onclick="saveModel()">保存</button>';
|
|
845
|
+
let m = { name: 'deepseek-flash', reasoningEffort: 'low', temperature: 0.9, maxTokens: 900 };
|
|
846
|
+
let usage = null;
|
|
847
|
+
try { const r = await api('/api/model'); m = r.model || m; usage = r.usage; } catch(e){}
|
|
848
|
+
let CTX = { historyMode: 'full', memoryInject: 8 };
|
|
849
|
+
try { CTX = (await api('/api/context')).context || CTX; } catch(e){}
|
|
850
|
+
let SETUP = null;
|
|
851
|
+
try { SETUP = (await api('/api/status')).setup || null; } catch(e){}
|
|
852
|
+
let STYLE = { noParens: false };
|
|
853
|
+
try { STYLE = (await api('/api/chat-style')).style || STYLE; } catch(e){}
|
|
854
|
+
window._style = STYLE;
|
|
855
|
+
let SAVING = { dynamicTools: false, compressImage: false, batchActions: false };
|
|
856
|
+
try { SAVING = (await api('/api/token-saving')).saving || SAVING; } catch(e){}
|
|
857
|
+
window._saving = SAVING;
|
|
858
|
+
window._ctx = CTX;
|
|
859
|
+
window._model = m;
|
|
860
|
+
const levels = [
|
|
861
|
+
{ v: 'low', t: '低', d: '日常聊天够用,最省 token(推荐)' },
|
|
862
|
+
{ v: 'medium', t: '中', d: '稍复杂一点的对话' },
|
|
863
|
+
{ v: 'high', t: '高', d: '需要想清楚的问题、多步操作' },
|
|
864
|
+
{ v: 'max', t: '最高', d: '专业编程、复杂逻辑,最费 token' },
|
|
865
|
+
];
|
|
866
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col">'
|
|
867
|
+
+ '<div class="card"><h2>推理等级 <span class="tag">只影响本插件,不影响 DSH 和其它会话</span></h2>'
|
|
868
|
+
+ '<div class="flex" style="gap:10px">'
|
|
869
|
+
+ levels.map(l => '<button class="btn '+(m.reasoningEffort===l.v?'primary':'ghost')+'" style="padding:10px 16px" onclick="setEffort(\''+l.v+'\')">'+l.t+'</button>').join('')
|
|
870
|
+
+ '</div>'
|
|
871
|
+
+ '<div class="hint" style="margin-top:10px">当前:'+({low:'低',medium:'中',high:'高',max:'最高'}[m.reasoningEffort]||'低')+' —— '+((levels.find(l=>l.v===m.reasoningEffort)||levels[0]).d)+'</div>'
|
|
872
|
+
+ '<div class="hint">一般聊天用「低」即可;要她帮你专业编程/排查问题时再调高。推理越高越慢、越费 token。</div>'
|
|
873
|
+
+ '</div>'
|
|
874
|
+
+ '<div class="card"><h2>模型参数</h2>'
|
|
875
|
+
+ '<div class="row"><label>模型</label><input type="text" id="mName" value="'+esc(m.name)+'"></div>'
|
|
876
|
+
+ '<div class="row"><label>温度</label><input type="number" id="mTemp" step="0.1" min="0" max="1.5" value="'+m.temperature+'" style="width:100px"><span class="small">越高越随机(0.9 左右自然)</span></div>'
|
|
877
|
+
+ '<div class="row"><label>单轮输出上限</label><input type="number" id="mMaxTok" min="200" max="8000" value="'+m.maxTokens+'" style="width:120px"><span class="small">tokens</span></div>'
|
|
878
|
+
+ '<div class="row"><label>工具循环上限</label><input type="number" id="mSteps" min="4" max="60" value="'+(m.maxSteps||20)+'" style="width:90px"><span class="small">步(她一次最多能连续调几次工具)</span></div>'
|
|
879
|
+
+ '</div>'
|
|
880
|
+
+ '<div class="card"><h2>DeepSeek API Key <span class="tag">独立运行 / 没装 DSH 时填这里</span></h2>'
|
|
881
|
+
+ '<div class="row"><label>API Key</label><input type="password" id="apiKeyInput" placeholder="sk-...(只保存在本机数据目录)" style="flex:1"><button class="btn sm primary" onclick="saveApiKey()">保存</button></div>'
|
|
882
|
+
+ '<div class="hint">当前:'+(SETUP && SETUP.apiKeyOk ? '已配置' : '未配置')+' · 纯聊天独立运行需要;DSH 环境下会自动读取 DSH 的 Key,通常不用填。</div></div>'
|
|
883
|
+
+ '<div class="card"><h2>模型兼容性</h2><div class="small">只验证过 DeepSeek 模型(默认 deepseek-flash)。换其它 OpenAI 兼容模型时,工具循环、reasoning_content 回传可能不兼容,这部分不做适配。</div></div>'
|
|
884
|
+
+ '<div class="card"><h2>聊天风格 <span class="tag">会影响她所有说话</span></h2>'
|
|
885
|
+
+ '<div class="row"><label>无括号模式</label><input type="checkbox" id="noParens" style="width:auto" '+(STYLE.noParens?'checked':'')+' onchange="setNoParens(this.checked)"><span class="small">开启后她不用括号表示动作/表情,只直接说话</span></div></div>'
|
|
886
|
+
+ '<div class="card"><h2>🧠 省 token 开关 <span class="tag">按需打开,随时关</span></h2>'
|
|
887
|
+
+ '<div class="row"><label>按需给工具</label><span class="small" style="flex:1">闲聊时不带屏幕/手机/代码工具,少带一大段工具说明;要操作时关键词会自动带上</span><button class="btn '+(SAVING.dynamicTools?'primary':'ghost')+'" onclick="setSaving("dynamicTools")">'+(SAVING.dynamicTools?'已开启':'已关闭')+'</button></div>'
|
|
888
|
+
+ '<div class="row"><label>压缩工具结果</label><span class="small" style="flex:1">截图自动缩小到 720 内、长输出截断;看屏幕更省 token</span><button class="btn '+(SAVING.compressImage?'primary':'ghost')+'" onclick="setSaving("compressImage")">'+(SAVING.compressImage?'已开启':'已关闭')+'</button></div>'
|
|
889
|
+
+ '<div class="row"><label>批量动作</label><span class="small" style="flex:1">连续点击/输入一次提交,少来回;点两下 9,350→4,845(-48%)。危险操作别开</span><button class="btn '+(SAVING.batchActions?'primary':'ghost')+'" onclick="setSaving("batchActions")">'+(SAVING.batchActions?'已开启':'已关闭')+'</button></div>'
|
|
890
|
+
+ '<div class="hint">实测(deepseek-flash):只开「按需给工具」,闲聊 1,962 → 1,137(省约 42%);再开「压缩工具结果」,看屏幕 4,766 → 3,618(省约 24%);开「批量动作」后,看屏+点两下 9,350 → 4,845(省约 48%)。</div>'
|
|
891
|
+
+ '</div>'
|
|
892
|
+
+ '<div class="card"><h2>上下文档位 <span class="tag">省 token 的力度,随时切换</span></h2>'
|
|
893
|
+
+ '<div class="small" style="margin-bottom:6px">聊天历史</div>'
|
|
894
|
+
+ '<div class="flex">'
|
|
895
|
+
+ [['full','完整 10 条'],['compact','精简 6 条 + 摘要'],['minimal','极简 4 条 + 摘要']].map(o => '<button class="btn '+(CTX.historyMode===o[0]?'primary':'ghost')+'" onclick="setCtx("historyMode","'+o[0]+'")">'+o[1]+'</button>').join('')
|
|
896
|
+
+ '</div>'
|
|
897
|
+
+ '<div class="small" style="margin:12px 0 6px">每轮注入记忆</div>'
|
|
898
|
+
+ '<div class="flex">'
|
|
899
|
+
+ [4,6,8].map(n => '<button class="btn '+(Number(CTX.memoryInject)===n?'primary':'ghost')+'" onclick="setCtx("memoryInject",'+n+')">'+n+' 条</button>').join('')
|
|
900
|
+
+ '</div>'
|
|
901
|
+
+ '<div class="hint" style="margin-top:10px">精简/极简会额外花一次很便宜的调用来生成"刚才聊到…"摘要;记忆条数越少越省,但经历感会淡一点。</div>'
|
|
902
|
+
+ '</div>'
|
|
903
|
+
+ '</div><div class="col side2">'
|
|
904
|
+
+ '<div class="card"><h2>最近用量</h2><div class="small">'+(usage?('输入 '+usage.prompt_tokens+' / 输出 '+usage.completion_tokens+'(缓存命中 '+((usage.prompt_cache_hit_tokens)||0)+')'):'暂无')+'</div></div>'
|
|
905
|
+
+ '<div class="card"><h2>说明</h2><div class="small">· 记忆提取、日记、预览这些后台调用固定用「低」,不占你的推理等级设置</div>'
|
|
906
|
+
+ '<div class="small" style="margin-top:6px">· 修改后下一条消息生效</div></div>'
|
|
907
|
+
+ '</div></div>';
|
|
908
|
+
}
|
|
909
|
+
function setEffort(v){ window._model.reasoningEffort = v; renderModel(); }
|
|
910
|
+
async function setCtx(k, v){ window._ctx[k] = v; const r = await saveJson('/api/context', window._ctx); if(r.ok) renderModel(); }
|
|
911
|
+
async function setSaving(key){
|
|
912
|
+
const cur = window._saving || {};
|
|
913
|
+
const body = {}; body[key] = !cur[key];
|
|
914
|
+
try { const r = await api('/api/token-saving', { method: 'POST', body: JSON.stringify(body) }); if(r && r.ok) toast('已切换'); } catch(e){ toast('保存失败'); }
|
|
915
|
+
renderModel();
|
|
916
|
+
}
|
|
917
|
+
async function saveModel(){
|
|
918
|
+
const body = {
|
|
919
|
+
name: document.getElementById('mName').value,
|
|
920
|
+
reasoningEffort: window._model.reasoningEffort,
|
|
921
|
+
temperature: Number(document.getElementById('mTemp').value),
|
|
922
|
+
maxTokens: Number(document.getElementById('mMaxTok').value),
|
|
923
|
+
maxSteps: Number(document.getElementById('mSteps').value),
|
|
924
|
+
};
|
|
925
|
+
const r = await saveJson('/api/model', body);
|
|
926
|
+
if(r.ok){ toast('模型设置已保存'); renderModel(); }
|
|
927
|
+
}
|
|
928
|
+
async function renderTutorial(){
|
|
929
|
+
$('#topAction').innerHTML = '<button class="btn" onclick="location.hash=\'env\'">回到环境</button>';
|
|
930
|
+
let text = '';
|
|
931
|
+
try { text = (await api('/api/tutorial')).text || ''; } catch(e){ text = '教程加载失败:' + e.message; }
|
|
932
|
+
const html = text.split('\n').map(line => {
|
|
933
|
+
if (line.startsWith('# ')) return '<h2 style="font-size:18px;margin:0 0 12px">' + esc(line.slice(2)) + '</h2>';
|
|
934
|
+
if (line.startsWith('## ')) return '<div style="font-size:14px;font-weight:600;color:#9fb3c8;margin:18px 0 8px">' + esc(line.slice(3)) + '</div>';
|
|
935
|
+
if (line.startsWith('> ')) return '<div class="example">' + esc(line.slice(2)) + '</div>';
|
|
936
|
+
if (line.startsWith('- ')) return '<div style="padding-left:14px;color:#c4d0de">• ' + esc(line.slice(2)) + '</div>';
|
|
937
|
+
if (!line.trim()) return '<div style="height:6px"></div>';
|
|
938
|
+
return '<div style="color:#c4d0de">' + esc(line) + '</div>';
|
|
939
|
+
}).join('');
|
|
940
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col"><div class="card" style="line-height:1.8">' + html + '</div></div>'
|
|
941
|
+
+ '<div class="col side2"><div class="card"><h2>状态</h2><div class="small">当前模式:'+(((window._mode||{}).effective==='full')?'完整版':'纯聊天版')+'</div>'
|
|
942
|
+
+ '<button class="btn ghost" style="width:100%;margin-top:8px" onclick="location.hash=\'env\'">去环境页检测</button></div>'
|
|
943
|
+
+ '<div class="card"><h2>说明</h2><div class="small">装好教程里的环境后回到插件,横幅会自动变成「完整版」,不用手动切。</div></div></div></div>';
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function doBackup(){
|
|
947
|
+
try { const r = await api('/api/backup', { method: 'POST' }); toast(r.message || '正在备份'); }
|
|
948
|
+
catch(e){ toast('备份失败'); }
|
|
949
|
+
}
|
|
950
|
+
async function renderData(){
|
|
951
|
+
$('#topAction').innerHTML = '';
|
|
952
|
+
let dataRoot = '%USERPROFILE%\\.dsh-companion';
|
|
953
|
+
try { const st = await api('/api/status'); dataRoot = (st.setup && st.setup.dataRoot) || dataRoot; } catch(e){}
|
|
954
|
+
const dp = (p) => dataRoot.replace(/[\\\/]+$/, '') + '\\' + p;
|
|
955
|
+
$('#body').innerHTML = '<div class="wrap"><div class="col">'
|
|
956
|
+
+ '<div class="card"><h2>数据目录</h2>'
|
|
957
|
+
+ '<div class="row"><label>数据根目录</label><input type="text" value="'+esc(dataRoot)+'" disabled></div>'
|
|
958
|
+
+ '<div class="row"><label>人设</label><input type="text" value="'+esc(dp('persona.md'))+'" disabled></div>'
|
|
959
|
+
+ '<div class="row"><label>记忆/日记/聊天</label><input type="text" value="'+esc(dp('data'))+'" disabled></div>'
|
|
960
|
+
+ '</div>'
|
|
961
|
+
+ '<div class="card"><h2>备份</h2><div class="small">导出把数据目录打包成 zip;导入覆盖恢复。系统每天凌晨 4 点后会自动备份一次。</div>'
|
|
962
|
+
+ '<div class="row"><button class="btn primary" onclick="doBackup()">立即备份</button><span class="hint" style="flex:1;margin-left:8px">备份到数据目录下的 backups</span></div><button class="btn ghost" style="margin-top:8px">导出备份</button></div>'
|
|
963
|
+
+ '</div><div class="col side2"><div class="card"><h2>版本</h2><div class="small">v0.5.7 · 本地运行时</div></div></div></div>';
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ================= router =================
|
|
967
|
+
async function go(tab){
|
|
968
|
+
currentTab = tab;
|
|
969
|
+
document.querySelectorAll('.nav a').forEach(a=>a.classList.toggle('on', a.dataset.tab===tab));
|
|
970
|
+
$('#tabTitle').textContent = TABS[tab].title;
|
|
971
|
+
$('#tabSub').textContent = TABS[tab].sub;
|
|
972
|
+
const body = $('#body'); body.innerHTML = '<div class="small">加载中…</div>';
|
|
973
|
+
if(!PERSONA){ try { await loadPersona(); renderNavAva(); } catch(e){} }
|
|
974
|
+
try {
|
|
975
|
+
if(tab==='role'){ if(!PERSONA) await loadPersona(); await loadChars(); renderRole(); }
|
|
976
|
+
else if(tab==='life'){ if(!LIFE) await loadLife(); renderLife(); setTimeout(()=>{ try{ petRefresh(); }catch(e){} }, 120); }
|
|
977
|
+
else if(tab==='memory'){ await renderMemory(); }
|
|
978
|
+
else if(tab==='env'){ await renderEnv(); }
|
|
979
|
+
else if(tab==='model'){ await renderModel(); }
|
|
980
|
+
else if(tab==='data'){ await renderData(); }
|
|
981
|
+
else if(tab==='tutorial'){ await renderTutorial(); }
|
|
982
|
+
} catch(e){ body.innerHTML = '<div class="small">加载失败:'+esc(e.message)+'</div>'; }
|
|
983
|
+
try { window._mode = await api('/api/mode'); } catch(e){ window._mode = window._mode || { override: 'auto', effective: 'full', detected: null }; }
|
|
984
|
+
renderModeBanner();
|
|
985
|
+
}
|
|
986
|
+
function renderModeBanner(){
|
|
987
|
+
const host = document.getElementById('modeHost');
|
|
988
|
+
if (!host) return;
|
|
989
|
+
const info = window._mode || {};
|
|
990
|
+
const eff = info.effective || 'full';
|
|
991
|
+
const ov = info.override || 'auto';
|
|
992
|
+
const d = info.detected || {};
|
|
993
|
+
const detectedTxt = d.ok
|
|
994
|
+
? '检测到虚拟屏幕 ✔' + (d.waydroid ? '(手机也在)' : '') + ' → 自动使用完整版'
|
|
995
|
+
: '未检测到虚拟屏幕 → 自动使用纯聊天版';
|
|
996
|
+
host.innerHTML = '<div class="mode-banner" style="margin-top:14px"><div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">'
|
|
997
|
+
+ '<div style="font-size:22px">'+(eff==='full'?'🖥️📱':'💬')+'</div>'
|
|
998
|
+
+ '<div style="flex:1;min-width:240px"><div style="font-weight:600;font-size:15px">当前模式:'+(eff==='full'?'完整版 · 她有自己的屏幕和手机':'纯聊天版 · 只有对话')+'</div>'
|
|
999
|
+
+ '<div class="small" style="margin-top:2px">'+detectedTxt+'</div>'
|
|
1000
|
+
+ '<div class="small" style="color:#55637a;margin-top:2px">覆盖设置:'+(ov==='auto'?'自动':(ov==='chat'?'强制纯聊天':'强制完整版'))+'</div></div>'
|
|
1001
|
+
+ '<div style="display:flex;gap:8px;flex-wrap:wrap">'
|
|
1002
|
+
+ '<button class="btn '+(ov==='auto'?'primary':'ghost')+'" onclick="setMode(\'auto\')">自动检测</button>'
|
|
1003
|
+
+ '<button class="btn '+(ov==='chat'?'primary':'ghost')+'" onclick="setMode(\'chat\')">纯聊天</button>'
|
|
1004
|
+
+ '<button class="btn '+(ov==='full'?'primary':'ghost')+'" onclick="setMode(\'full\')">完整版</button>'
|
|
1005
|
+
+ '<button class="btn ghost" onclick="location.hash=\'tutorial\'">📖 接入教程</button>'
|
|
1006
|
+
+ '</div></div></div>';
|
|
1007
|
+
}
|
|
1008
|
+
async function setMode(m){
|
|
1009
|
+
const r = await saveJson('/api/mode', { override: m });
|
|
1010
|
+
if(r.ok){ window._mode = { override: r.override, detected: r.detected, effective: r.effective }; toast(m==='auto'?'已设为自动检测':(m==='chat'?'已强制纯聊天':'已强制完整版')); go(currentTab); }
|
|
1011
|
+
}
|
|
1012
|
+
document.querySelectorAll('.nav a').forEach(a=>a.onclick = () => { location.hash = a.dataset.tab; });
|
|
1013
|
+
window.addEventListener('hashchange', () => go((location.hash||'#role').slice(1)));
|
|
1014
|
+
go((location.hash||'#role').slice(1));
|
|
1015
|
+
</script></body></html>
|