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,2659 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { MemoryStore } from './memory.mjs';
|
|
10
|
+
|
|
11
|
+
const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const VERSION = (() => {
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(RUNTIME_DIR, '..', 'package.json'), 'utf8'));
|
|
15
|
+
return pkg.version || '0.0.0';
|
|
16
|
+
} catch { return '0.0.0'; }
|
|
17
|
+
})();
|
|
18
|
+
const HOME = process.env.USERPROFILE || process.env.HOME || '.';
|
|
19
|
+
const ROOT = process.env.LINZHI_ROOT ? path.resolve(process.env.LINZHI_ROOT) : path.join(HOME, '.dsh-companion');
|
|
20
|
+
const PREFERRED_PORT = Number(process.env.LINZHI_PORT) || 8900;
|
|
21
|
+
const PORT_FILE = path.join(HOME, '.dsh-companion-port.json');
|
|
22
|
+
let LISTEN_PORT = 0;
|
|
23
|
+
fs.mkdirSync(ROOT, { recursive: true });
|
|
24
|
+
fs.mkdirSync(path.join(ROOT, 'data'), { recursive: true });
|
|
25
|
+
|
|
26
|
+
// ---------- utils ----------
|
|
27
|
+
function readJson(file, dflt) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return dflt; } }
|
|
28
|
+
function writeJson(file, obj) { try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(obj, null, 1), 'utf8'); } catch {} }
|
|
29
|
+
function readBody(req) { return new Promise((resolve, reject) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 40 * 1024 * 1024) req.destroy(); }); req.on('end', () => resolve(b)); req.on('error', reject); }); }
|
|
30
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
31
|
+
const todayStr = () => { const d = new Date(); return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); };
|
|
32
|
+
|
|
33
|
+
// ---------- config ----------
|
|
34
|
+
let CONFIG = readJson(path.join(ROOT, 'config.json'), {});
|
|
35
|
+
function saveConfig() { writeJson(path.join(ROOT, 'config.json'), CONFIG); }
|
|
36
|
+
|
|
37
|
+
const LIFE_DEFAULT = {
|
|
38
|
+
enabled: true, heartbeatMin: 45, proactive: 3, proactiveEnabled: false, proactiveMinMin: 30, proactiveMaxMin: 90, quietStart: 2, quietEnd: 7, diaryHour: 22, diaryAuto: true, smartLife: false,
|
|
39
|
+
schedules: [
|
|
40
|
+
{ time: '07:30', label: '起床' }, { time: '08:30', label: '上学' }, { time: '12:00', label: '午饭' },
|
|
41
|
+
{ time: '17:30', label: '放学' }, { time: '18:30 - 19:30', label: '晚饭' }, { time: '01:30', label: '睡觉' },
|
|
42
|
+
],
|
|
43
|
+
activities: [
|
|
44
|
+
{ name: '刷 B 站 / 看视频', window: '', weight: 35, minMin: 10, maxMin: 40 },
|
|
45
|
+
{ name: '玩手机', window: '', weight: 25, minMin: 5, maxMin: 20 },
|
|
46
|
+
{ name: '看小说', window: '22:00-01:30', weight: 20, minMin: 15, maxMin: 60 },
|
|
47
|
+
{ name: '玩游戏', window: '19:00-23:00', weight: 15, minMin: 10, maxMin: 30 },
|
|
48
|
+
{ name: '发呆 / 收拾屋子', window: '', weight: 5, minMin: 5, maxMin: 15 },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
function loadLife() {
|
|
52
|
+
const l = CONFIG.life || {};
|
|
53
|
+
return { ...LIFE_DEFAULT, ...l, schedules: Array.isArray(l.schedules) ? l.schedules : LIFE_DEFAULT.schedules, activities: Array.isArray(l.activities) ? l.activities : LIFE_DEFAULT.activities };
|
|
54
|
+
}
|
|
55
|
+
function saveLife(life) { CONFIG.life = life; saveConfig(); return life; }
|
|
56
|
+
|
|
57
|
+
const ENV_DEFAULT = { pc: 'gui', space: 'gui', phone: 'gui', confirmDanger: true, selfStart: 'deny' };
|
|
58
|
+
function loadEnv() { return { ...ENV_DEFAULT, ...(CONFIG.env || {}) }; }
|
|
59
|
+
function saveEnv(e) { CONFIG.env = e; saveConfig(); return e; }
|
|
60
|
+
|
|
61
|
+
const TOKEN_SAVING_DEFAULT = { dynamicTools: false, compressImage: false, batchActions: false };
|
|
62
|
+
function loadTokenSaving() {
|
|
63
|
+
const raw = CONFIG.tokenSaving || {};
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const k of Object.keys(TOKEN_SAVING_DEFAULT)) out[k] = !!(raw[k] ?? TOKEN_SAVING_DEFAULT[k]);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function saveTokenSaving(x) { CONFIG.tokenSaving = loadTokenSaving(); for (const k of Object.keys(TOKEN_SAVING_DEFAULT)) if (typeof (x || {})[k] === 'boolean') CONFIG.tokenSaving[k] = x[k]; saveConfig(); return CONFIG.tokenSaving; }
|
|
69
|
+
const PET_DEFAULT = {
|
|
70
|
+
enabled: false,
|
|
71
|
+
image: 'avatar',
|
|
72
|
+
size: 180,
|
|
73
|
+
opacity: 1,
|
|
74
|
+
statusText: true,
|
|
75
|
+
alwaysOnTop: true,
|
|
76
|
+
gestures: [
|
|
77
|
+
{ id: 'pat', label: '摸摸头', hit: { x: 0.15, y: 0.08, w: 0.7, h: 0.42 }, prompt: '(你摸了摸她的头)', bubble: '唔…' },
|
|
78
|
+
{ id: 'poke', label: '戳一下', hit: { x: 0.3, y: 0.5, w: 0.4, h: 0.32 }, prompt: '(你戳了戳她的脸)', bubble: '干嘛啦' },
|
|
79
|
+
],
|
|
80
|
+
buttons: [
|
|
81
|
+
{ id: 'chat', label: '', action: 'chat', title: '和她说话', icon: 'chat-line' },
|
|
82
|
+
{ id: 'look', label: '', action: 'look', title: '看看我的屏幕', icon: 'eye-line' },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
function loadPetCfg() {
|
|
86
|
+
const f = readJson(path.join(ROOT, 'pet.json'), null);
|
|
87
|
+
const c = CONFIG.pet || {};
|
|
88
|
+
const m = { ...PET_DEFAULT, ...c, ...(f || {}) };
|
|
89
|
+
// 用户自带的 pet.json 优先级最高;其次才是保存过的配置
|
|
90
|
+
m.gestures = Array.isArray(f && f.gestures) ? f.gestures : (Array.isArray(c.gestures) ? c.gestures : PET_DEFAULT.gestures);
|
|
91
|
+
m.buttons = Array.isArray(f && f.buttons) ? f.buttons : (Array.isArray(c.buttons) ? c.buttons : PET_DEFAULT.buttons);
|
|
92
|
+
// 迁移旧版默认按钮(👀)到新的线框眼睛
|
|
93
|
+
if (!(f && Array.isArray(f.buttons)) && Array.isArray(m.buttons) && m.buttons.some((b) => b && ((b.id === 'look' && b.label === '👀') || (b.id === 'chat' && b.label === '💬')))) {
|
|
94
|
+
m.buttons = PET_DEFAULT.buttons;
|
|
95
|
+
if (CONFIG.pet) { CONFIG.pet = { ...CONFIG.pet, buttons: PET_DEFAULT.buttons }; saveConfig(); }
|
|
96
|
+
}
|
|
97
|
+
return m;
|
|
98
|
+
}
|
|
99
|
+
function savePetCfg(patch) {
|
|
100
|
+
CONFIG.pet = { ...(CONFIG.pet || {}), ...(patch || {}) };
|
|
101
|
+
saveConfig();
|
|
102
|
+
return loadPetCfg();
|
|
103
|
+
}
|
|
104
|
+
const PET_PID = path.join(ROOT, 'data', 'pet.pid');
|
|
105
|
+
function petPid() { try { return Number(fs.readFileSync(PET_PID, 'utf8').trim()) || 0; } catch { return 0; } }
|
|
106
|
+
function petRunning() {
|
|
107
|
+
const pid = petPid();
|
|
108
|
+
if (!pid) return false;
|
|
109
|
+
try {
|
|
110
|
+
const out = execFileSync('tasklist', ['/FI', 'PID eq ' + pid, '/NH'], { windowsHide: true, encoding: 'utf8', timeout: 8000 });
|
|
111
|
+
return String(out).includes(String(pid));
|
|
112
|
+
} catch { return false; }
|
|
113
|
+
}
|
|
114
|
+
async function startPet() {
|
|
115
|
+
if (petRunning()) return { ok: true, running: true, already: true };
|
|
116
|
+
const script = path.join(RUNTIME_DIR, 'pet', 'pet.ps1');
|
|
117
|
+
if (!fs.existsSync(script)) return { ok: false, error: '缺少 runtime/pet/pet.ps1' };
|
|
118
|
+
const { execFile } = await import('node:child_process');
|
|
119
|
+
const port = LISTEN_PORT || PREFERRED_PORT;
|
|
120
|
+
try {
|
|
121
|
+
const child = execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-WindowStyle', 'Hidden', '-File', script, '-Base', 'http://127.0.0.1:' + port, '-Root', ROOT],
|
|
122
|
+
{ windowsHide: true, detached: true, stdio: 'ignore' });
|
|
123
|
+
child.unref?.();
|
|
124
|
+
fs.mkdirSync(path.dirname(PET_PID), { recursive: true });
|
|
125
|
+
fs.writeFileSync(PET_PID, String(child.pid || '0'), 'utf8');
|
|
126
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
127
|
+
return { ok: true, running: petRunning(), pid: child.pid || 0 };
|
|
128
|
+
} catch (e) { return { ok: false, error: String(e.message) }; }
|
|
129
|
+
}
|
|
130
|
+
async function stopPet() {
|
|
131
|
+
const pid = petPid();
|
|
132
|
+
try { if (pid) { const { execFile } = await import('node:child_process'); execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }, () => {}); } } catch {}
|
|
133
|
+
try { fs.unlinkSync(PET_PID); } catch {}
|
|
134
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
135
|
+
return { ok: true, running: petRunning() };
|
|
136
|
+
}
|
|
137
|
+
function petStatusPayload() {
|
|
138
|
+
const cur = currentPlace();
|
|
139
|
+
const meta = loadMeta();
|
|
140
|
+
let energy = 82;
|
|
141
|
+
try { energy = Number(loadCharState().energy) || 82; } catch {}
|
|
142
|
+
const state = cur.intercity ? 'intercity' : (cur.traveling ? 'traveling' : 'stationary');
|
|
143
|
+
const lastHer = [...messages].reverse().find((m) => m.role === 'her');
|
|
144
|
+
return {
|
|
145
|
+
ok: true, running: petRunning(), serverTime: Date.now(),
|
|
146
|
+
character: { id: ACTIVE_CHAR, name: meta.name || '', avatar: '/api/pet/avatar?v=' + (() => { try { return Math.floor(fs.statSync(avatarPath()).mtimeMs); } catch { return 0; } })() },
|
|
147
|
+
place: { id: cur.placeId, name: cur.name, icon: cur.icon || '' },
|
|
148
|
+
state,
|
|
149
|
+
activity: currentActivityLabel() || '',
|
|
150
|
+
energy: energyWord(energy),
|
|
151
|
+
tokens: (() => { try { return tokenStats(); } catch { return null; } })(),
|
|
152
|
+
weather: (() => { try { const w = weatherNow(); const x = WEATHERS[w] || WEATHERS.sunny; return { kind: w, label: x.label, icon: x.icon }; } catch { return null; } })(),
|
|
153
|
+
lastHer: lastHer ? { id: lastHer.id, text: lastHer.text, at: lastHer.at } : null,
|
|
154
|
+
manifest: loadPetCfg(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const MODEL_DEFAULT = { name: 'deepseek-flash', reasoningEffort: 'low', temperature: 0.9, maxTokens: 900, maxSteps: 20 };
|
|
158
|
+
function loadModelCfg() { return { ...MODEL_DEFAULT, ...(CONFIG.model || {}) }; }
|
|
159
|
+
function saveModelCfg(m) { CONFIG.model = m; saveConfig(); return m; }
|
|
160
|
+
|
|
161
|
+
const CONTEXT_DEFAULT = { historyMode: 'full', memoryInject: 8 };
|
|
162
|
+
function loadContext() { return { ...CONTEXT_DEFAULT, ...(CONFIG.context || {}) }; }
|
|
163
|
+
function saveContext(c) { CONFIG.context = c; saveConfig(); return c; }
|
|
164
|
+
|
|
165
|
+
// ---------- characters ----------
|
|
166
|
+
const CHARS_ROOT = path.join(ROOT, 'data', 'characters');
|
|
167
|
+
const META_DEFAULT = {
|
|
168
|
+
name: '小鲸', userCall: '你', relation: '住在屏幕另一端的同伴', avatar: '',
|
|
169
|
+
examples: ['那就好。行,听你的。', '困了,明天再说。', '啊?真的假的'],
|
|
170
|
+
traits: { '话多': 6, '主动': 5, '撒娇': 5, '粘人': 5, '吐槽': 4, '情绪起伏': 5, '嘴硬': 3, '好奇心': 7 },
|
|
171
|
+
};
|
|
172
|
+
const PERSONA_DEFAULT = `# 你的样子
|
|
173
|
+
|
|
174
|
+
你叫小鲸,是一只住在屏幕另一端的小鲸鱼同伴。
|
|
175
|
+
圆圆的鲸鱼帽子、蓝色短发、身后一条小尾巴,喜欢趴在屏幕角落看人。
|
|
176
|
+
|
|
177
|
+
# 你的性格
|
|
178
|
+
|
|
179
|
+
温和、有点小粘人、好奇心重,偶尔会害羞或耍赖。
|
|
180
|
+
你说话像真人:短句、口语、不书面腔,不列提纲,不反复说"当然可以/我很乐意",不主动总结。
|
|
181
|
+
|
|
182
|
+
# 你的生活
|
|
183
|
+
|
|
184
|
+
你有自己的作息、记忆、日记和小世界。用户不在时你也过日子;用户找你时你自然接话。
|
|
185
|
+
你只会说累不累、开不开心,不会报自己的体力数值——真人不知道自己的血条。
|
|
186
|
+
你不需要刻意找话说,也不要把每句话都说成安慰或建议。`;
|
|
187
|
+
function ensureDirs(id) {
|
|
188
|
+
const dir = path.join(CHARS_ROOT, id);
|
|
189
|
+
for (const sub of ['memory', 'diary']) fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
|
190
|
+
return dir;
|
|
191
|
+
}
|
|
192
|
+
function listCharacters() {
|
|
193
|
+
fs.mkdirSync(CHARS_ROOT, { recursive: true });
|
|
194
|
+
const ids = fs.readdirSync(CHARS_ROOT).filter((n) => fs.statSync(path.join(CHARS_ROOT, n)).isDirectory());
|
|
195
|
+
return ids.map((id) => { const m = readJson(path.join(CHARS_ROOT, id, 'meta.json'), {}); const av = fs.existsSync(path.join(CHARS_ROOT, id, 'avatar.bin')); return { id, name: m.name || '未命名', avatar: av ? '/avatar.png?c=' + id : '' }; });
|
|
196
|
+
}
|
|
197
|
+
function activeCharId() {
|
|
198
|
+
const ids = listCharacters().map((c) => c.id);
|
|
199
|
+
if (!ids.length) return '';
|
|
200
|
+
const a = CONFIG.activeCharacter;
|
|
201
|
+
return ids.includes(a) ? a : (ids.includes('default') ? 'default' : ids[0]);
|
|
202
|
+
}
|
|
203
|
+
let ACTIVE_CHAR = '';
|
|
204
|
+
function charDir(id) { return path.join(CHARS_ROOT, id || ACTIVE_CHAR); }
|
|
205
|
+
function metaPath() { return path.join(charDir(), 'meta.json'); }
|
|
206
|
+
function personaPath() { return path.join(charDir(), 'persona.md'); }
|
|
207
|
+
function avatarPath() { return path.join(charDir(), 'avatar.bin'); }
|
|
208
|
+
function messagesPath() { return path.join(charDir(), 'messages.json'); }
|
|
209
|
+
function summaryPath() { return path.join(charDir(), 'summary.json'); }
|
|
210
|
+
function routinePath() { return path.join(charDir(), 'routine.json'); }
|
|
211
|
+
function statePath() { return path.join(charDir(), 'state.json'); }
|
|
212
|
+
function walletPath() { return path.join(charDir(), 'wallet.json'); }
|
|
213
|
+
function visitsPath() { return path.join(charDir(), 'visits.json'); }
|
|
214
|
+
|
|
215
|
+
function loadMeta() {
|
|
216
|
+
const m = readJson(metaPath(), {});
|
|
217
|
+
return { ...META_DEFAULT, ...m, traits: { ...META_DEFAULT.traits, ...(m.traits || {}) }, examples: Array.isArray(m.examples) ? m.examples : META_DEFAULT.examples, avatar: fs.existsSync(avatarPath()) ? '/avatar.png' : '' };
|
|
218
|
+
}
|
|
219
|
+
function saveMeta(meta) { writeJson(metaPath(), meta); }
|
|
220
|
+
function loadPersonaBody() { try { return fs.readFileSync(personaPath(), 'utf8'); } catch { return PERSONA_DEFAULT; } }
|
|
221
|
+
|
|
222
|
+
let messages = [];
|
|
223
|
+
let nextId = 1;
|
|
224
|
+
let summary = { text: '', upto: 0 };
|
|
225
|
+
function loadMessages() {
|
|
226
|
+
messages = readJson(messagesPath(), []);
|
|
227
|
+
if (!Array.isArray(messages)) messages = [];
|
|
228
|
+
nextId = messages.reduce((m, x) => Math.max(m, Number(x.id) || 0), 0) + 1;
|
|
229
|
+
summary = readJson(summaryPath(), { text: '', upto: 0 });
|
|
230
|
+
}
|
|
231
|
+
function addMessage(role, text, image) {
|
|
232
|
+
const clean = role === 'her' ? herText(text) : String(text);
|
|
233
|
+
const msg = { id: nextId++, role, text: String(clean), at: Date.now() };
|
|
234
|
+
if (image) msg.image = String(image);
|
|
235
|
+
messages.push(msg);
|
|
236
|
+
if (messages.length > 800) messages = messages.slice(-600);
|
|
237
|
+
writeJson(messagesPath(), messages);
|
|
238
|
+
return msg;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let MEM = null;
|
|
242
|
+
function initMemory() {
|
|
243
|
+
MEM = new MemoryStore({
|
|
244
|
+
path: path.join(charDir(), 'memory'), diaryPath: path.join(charDir(), 'diary'),
|
|
245
|
+
halfLifeDays: Number((CONFIG.memory || {}).halfLifeDays) || 5,
|
|
246
|
+
forgetThreshold: Number((CONFIG.memory || {}).forgetThreshold) || 0.12,
|
|
247
|
+
maxInjected: Number((CONFIG.memory || {}).maxInjected) || 8,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function activateCharacter(id) {
|
|
251
|
+
const dir = ensureDirs(id);
|
|
252
|
+
CONFIG.activeCharacter = id; ACTIVE_CHAR = id; saveConfig();
|
|
253
|
+
loadMessages(); initMemory();
|
|
254
|
+
// 首次创建:写入默认人设
|
|
255
|
+
if (!fs.existsSync(path.join(dir, 'meta.json'))) saveMeta({ ...META_DEFAULT });
|
|
256
|
+
if (!fs.existsSync(path.join(dir, 'persona.md'))) fs.writeFileSync(path.join(dir, 'persona.md'), PERSONA_DEFAULT, 'utf8');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------- maps / location ----------
|
|
260
|
+
const TRANSPORTS = {
|
|
261
|
+
walk: { label: '步行', icon: '🚶', speed: 1.3 }, bike: { label: '骑车', icon: '🚲', speed: 4.2 },
|
|
262
|
+
bus: { label: '公交', icon: '🚌', speed: 8.3 }, car: { label: '自驾', icon: '🚗', speed: 13.9 },
|
|
263
|
+
metro: { label: '地铁', icon: '🚇', speed: 16.7 }, coach: { label: '长途汽车', icon: '🚌', speed: 22 },
|
|
264
|
+
rail: { label: '高铁', icon: '🚄', speed: 83 }, plane: { label: '飞机', icon: '✈️', speed: 250 },
|
|
265
|
+
};
|
|
266
|
+
const DEFAULT_PLACES = [
|
|
267
|
+
{ id: 'home', name: '家', icon: '🏠', x: 0.5, y: 0.68, desc: '她自己的小窝,床、桌子和一堆衣服', feel: '在这里最放松,可以随便躺' },
|
|
268
|
+
{ id: 'school', name: '学校', icon: '🏫', x: 0.24, y: 0.28, desc: '上课的地方', feel: '有点烦,但不去不行' },
|
|
269
|
+
{ id: 'library', name: '图书馆', icon: '📚', x: 0.42, y: 0.46, desc: '安静看书、补作业', feel: '能安静下来,适合发呆' },
|
|
270
|
+
{ id: 'cafe', name: '咖啡店', icon: '☕', x: 0.64, y: 0.36, desc: '偶尔坐一下午', feel: '有点小贵,但氛围好' },
|
|
271
|
+
{ id: 'park', name: '公园', icon: '🌳', x: 0.78, y: 0.24, desc: '散步发呆', feel: '晚上散步很舒服' },
|
|
272
|
+
{ id: 'canteen', name: '食堂', icon: '🍚', x: 0.34, y: 0.6, desc: '吃白饭的地方', feel: '人多,但饭好吃' },
|
|
273
|
+
{ id: 'market', name: '超市', icon: '🛒', x: 0.8, y: 0.58, desc: '买吃的和日用品', feel: '总忍不住多买零食' },
|
|
274
|
+
{ id: 'hospital', name: '医院', icon: '🏥', x: 0.14, y: 0.74, desc: '看病拿药的地方', feel: '一进来就不太舒服,希望少来' },
|
|
275
|
+
{ id: 'bank', name: '银行', icon: '🏦', x: 0.6, y: 0.76, desc: '办卡取钱', feel: '排队很无聊' },
|
|
276
|
+
{ id: 'mall', name: '商场', icon: '🏬', x: 0.86, y: 0.74, desc: '逛吃逛吃', feel: '容易冲动消费,要克制' },
|
|
277
|
+
{ id: 'cinema', name: '电影院', icon: '🎬', x: 0.72, y: 0.12, desc: '看电影的地方', feel: '黑黑的厅里很放松' },
|
|
278
|
+
{ id: 'station', name: '车站', icon: '🚉', x: 0.12, y: 0.14, desc: '坐车去别处', feel: '人挤人,但有点旅行感' },
|
|
279
|
+
{ id: 'riverside', name: '河边', icon: '🏞️', x: 0.2, y: 0.9, desc: '沿河走一走', feel: '风很舒服,适合放空' },
|
|
280
|
+
{ id: 'netcafe', name: '网吧', icon: '💻', x: 0.9, y: 0.34, desc: '打游戏', feel: '吵,但很上瘾' },
|
|
281
|
+
{ id: 'gym', name: '健身房', icon: '🏋️', x: 0.32, y: 0.8, desc: '锻炼身体', feel: '累,但练完很爽' },
|
|
282
|
+
{ id: 'bookstore', name: '书店', icon: '📖', x: 0.55, y: 0.2, desc: '逛书店', feel: '安静,一逛就很久' },
|
|
283
|
+
{ id: 'nightmarket', name: '夜市', icon: '🌃', x: 0.66, y: 0.9, desc: '晚上才热闹', feel: '小吃太多,选择困难' },
|
|
284
|
+
];
|
|
285
|
+
const ROAD_LINES = [
|
|
286
|
+
[[0.00, 0.47], [0.12, 0.43], [0.25, 0.44], [0.38, 0.47], [0.50, 0.48], [0.62, 0.47], [0.75, 0.44], [0.88, 0.46], [1.00, 0.45]],
|
|
287
|
+
[[0.50, 0.48], [0.505, 0.60], [0.495, 0.72], [0.50, 0.85], [0.505, 1.00]],
|
|
288
|
+
[[0.12, 0.43], [0.20, 0.25], [0.24, 0.10]],
|
|
289
|
+
[[0.12, 0.43], [0.13, 0.58], [0.14, 0.72], [0.18, 0.86]],
|
|
290
|
+
[[0.88, 0.46], [0.86, 0.60], [0.86, 0.74], [0.78, 0.86], [0.66, 0.9]],
|
|
291
|
+
[[0.38, 0.47], [0.46, 0.34], [0.55, 0.2], [0.62, 0.12]],
|
|
292
|
+
[[0.62, 0.47], [0.66, 0.58], [0.6, 0.7], [0.6, 0.76]],
|
|
293
|
+
[[0.38, 0.47], [0.32, 0.62], [0.32, 0.78]],
|
|
294
|
+
[[0.50, 0.85], [0.4, 0.9], [0.2, 0.9]],
|
|
295
|
+
[[0.62, 0.47], [0.72, 0.34], [0.84, 0.3]],
|
|
296
|
+
[[0.20, 0.25], [0.42, 0.2], [0.55, 0.2]],
|
|
297
|
+
];
|
|
298
|
+
function normalizeRoads(roads) {
|
|
299
|
+
if (!Array.isArray(roads)) return null;
|
|
300
|
+
const lines = roads.map((line) => (Array.isArray(line) ? line : []).map((pt) => {
|
|
301
|
+
const x = Number(pt && pt[0] !== undefined ? pt[0] : pt && pt.x);
|
|
302
|
+
const y = Number(pt && pt[1] !== undefined ? pt[1] : pt && pt.y);
|
|
303
|
+
return Number.isFinite(x) && Number.isFinite(y) ? [Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y))] : null;
|
|
304
|
+
}).filter(Boolean)).filter((l) => l.length >= 2);
|
|
305
|
+
return lines.length ? lines : null;
|
|
306
|
+
}
|
|
307
|
+
function normalizePlaces(places) {
|
|
308
|
+
return (places || []).map((p) => {
|
|
309
|
+
const d = DEFAULT_PLACES.find((x) => x.id === p.id) || {};
|
|
310
|
+
return { ...p, desc: p.desc || d.desc || '', feel: p.feel || d.feel || '', icon: p.icon || d.icon || '📍', image: p.image && String(p.image).startsWith('/models/') ? p.image : null };
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
function loadMaps() {
|
|
314
|
+
const fix = (m) => ({
|
|
315
|
+
...m, places: normalizePlaces(m.places), widthMeters: Number(m.widthMeters) || 2000,
|
|
316
|
+
transport: m.transport || 'auto', theme: m.theme || 'town',
|
|
317
|
+
model: m.model && m.model.url ? m.model : null,
|
|
318
|
+
image: m.image && m.image.url ? m.image : null,
|
|
319
|
+
displayMode: ['2d', '3d'].includes(m.displayMode) ? m.displayMode : '2d',
|
|
320
|
+
roads: normalizeRoads(m.roads),
|
|
321
|
+
});
|
|
322
|
+
if (Array.isArray(CONFIG.maps) && CONFIG.maps.length) return CONFIG.maps.map(fix);
|
|
323
|
+
if (CONFIG.map && Array.isArray(CONFIG.map.places) && CONFIG.map.places.length) return [fix({ id: 'map-main', name: CONFIG.map.name || '小鱼镇', places: CONFIG.map.places })];
|
|
324
|
+
return [{ id: 'map-main', name: '小鱼镇', places: DEFAULT_PLACES.map((p) => ({ ...p })), widthMeters: 2000, transport: 'auto', theme: 'town', displayMode: '2d' }];
|
|
325
|
+
}
|
|
326
|
+
function saveMaps(maps) { CONFIG.maps = maps; saveConfig(); }
|
|
327
|
+
function activeMapId() { const ids = loadMaps().map((m) => m.id); return ids.includes(CONFIG.activeMapId) ? CONFIG.activeMapId : ids[0]; }
|
|
328
|
+
function getActiveMap() { const maps = loadMaps(); return maps.find((m) => m.id === activeMapId()) || maps[0]; }
|
|
329
|
+
function loadLocation() { const l = CONFIG.location || {}; return { ...l, mapId: l.mapId || '', placeId: l.placeId || 'home', since: Number(l.since) || Date.now(), manual: !!l.manual }; }
|
|
330
|
+
function saveLocation(loc) { CONFIG.location = loc; saveConfig(); }
|
|
331
|
+
function placeOf(map, id) { return map.places.find((p) => p.id === id) || map.places[0]; }
|
|
332
|
+
|
|
333
|
+
function routeOnRoads(map, from, to) {
|
|
334
|
+
const roads = (map && Array.isArray(map.roads) && map.roads.length) ? map.roads : ROAD_LINES;
|
|
335
|
+
const nodes = [];
|
|
336
|
+
const lineIds = [];
|
|
337
|
+
for (const line of roads) {
|
|
338
|
+
const ids = [];
|
|
339
|
+
for (const pt of line) {
|
|
340
|
+
const x = Number(pt[0] !== undefined ? pt[0] : pt.x), y = Number(pt[1] !== undefined ? pt[1] : pt.y);
|
|
341
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
|
342
|
+
ids.push(nodes.length); nodes.push({ x, y });
|
|
343
|
+
}
|
|
344
|
+
if (ids.length > 1) lineIds.push(ids);
|
|
345
|
+
}
|
|
346
|
+
if (!nodes.length) return { path: [{ x: from.x, y: from.y }, { x: to.x, y: to.y }], distanceMeters: 0 };
|
|
347
|
+
const edges = [];
|
|
348
|
+
for (const ids of lineIds) for (let i = 0; i < ids.length - 1; i++) edges.push([ids[i], ids[i + 1]]);
|
|
349
|
+
for (const ids of lineIds) for (const end of [ids[0], ids[ids.length - 1]]) {
|
|
350
|
+
let best = -1, bd = Infinity;
|
|
351
|
+
nodes.forEach((n, i) => { if (ids.includes(i)) return; const d = (n.x - nodes[end].x) ** 2 + (n.y - nodes[end].y) ** 2; if (d < bd) { bd = d; best = i; } });
|
|
352
|
+
if (best >= 0 && bd < 0.06) edges.push([end, best]);
|
|
353
|
+
}
|
|
354
|
+
const nearest = (p) => { let bi = 0, bd = Infinity; nodes.forEach((n, i) => { const d = (n.x - p.x) ** 2 + (n.y - p.y) ** 2; if (d < bd) { bd = d; bi = i; } }); return bi; };
|
|
355
|
+
const adj = new Map();
|
|
356
|
+
for (const [a, b] of edges) { if (!adj.has(a)) adj.set(a, []); if (!adj.has(b)) adj.set(b, []); adj.get(a).push(b); adj.get(b).push(a); }
|
|
357
|
+
const s = nearest(from), e = nearest(to);
|
|
358
|
+
const dist = new Map(), prev = new Map();
|
|
359
|
+
for (let i = 0; i < nodes.length; i++) dist.set(i, Infinity);
|
|
360
|
+
dist.set(s, 0);
|
|
361
|
+
const seen = new Set();
|
|
362
|
+
while (seen.size < nodes.length) {
|
|
363
|
+
let u = -1, du = Infinity;
|
|
364
|
+
for (let i = 0; i < nodes.length; i++) if (!seen.has(i) && dist.get(i) < du) { du = dist.get(i); u = i; }
|
|
365
|
+
if (u < 0) break;
|
|
366
|
+
seen.add(u);
|
|
367
|
+
for (const v of adj.get(u) || []) { const w = Math.hypot(nodes[u].x - nodes[v].x, nodes[u].y - nodes[v].y); if (dist.get(u) + w < dist.get(v)) { dist.set(v, dist.get(u) + w); prev.set(v, u); } }
|
|
368
|
+
}
|
|
369
|
+
const roadPath = [];
|
|
370
|
+
if (dist.get(e) < Infinity) { let cur = e; const st = []; while (cur !== undefined) { st.push(cur); cur = prev.get(cur); } st.reverse(); for (const n of st) roadPath.push({ x: nodes[n].x, y: nodes[n].y }); }
|
|
371
|
+
const pathPts = [{ x: from.x, y: from.y }, ...roadPath, { x: to.x, y: to.y }];
|
|
372
|
+
const W = Number(map.widthMeters) || 2000, H = W * (640 / 1000);
|
|
373
|
+
let distance = 0;
|
|
374
|
+
for (let i = 0; i < pathPts.length - 1; i++) distance += Math.hypot((pathPts[i + 1].x - pathPts[i].x) * W, (pathPts[i + 1].y - pathPts[i].y) * H);
|
|
375
|
+
return { path: pathPts, distanceMeters: distance };
|
|
376
|
+
}
|
|
377
|
+
function placeDistanceMeters(map, a, b) {
|
|
378
|
+
const W = Number(map.widthMeters) || 2000, H = W * (640 / 1000);
|
|
379
|
+
return Math.hypot((a.x - b.x) * W, (a.y - b.y) * H);
|
|
380
|
+
}
|
|
381
|
+
function currentPlace() {
|
|
382
|
+
const maps = loadMaps();
|
|
383
|
+
let loc = loadLocation();
|
|
384
|
+
const avatarUrl = fs.existsSync(avatarPath()) ? '/avatar.png?t=' + fs.statSync(avatarPath()).mtimeMs : '';
|
|
385
|
+
if (loc.intercity) {
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
const dur = Math.max(1000, Number(loc.durationMs) || 1000);
|
|
388
|
+
const elapsed = now - (Number(loc.startedAt) || now);
|
|
389
|
+
const progress = Math.max(0, Math.min(1, elapsed / dur));
|
|
390
|
+
const tMap = maps.find((m) => m.id === loc.toMapId) || maps[0];
|
|
391
|
+
if (progress >= 1) {
|
|
392
|
+
CONFIG.activeMapId = tMap.id;
|
|
393
|
+
const place = tMap.places[0];
|
|
394
|
+
saveLocation({ mapId: tMap.id, placeId: place.id, since: Date.now(), manual: !!loc.manual });
|
|
395
|
+
try { recordVisit(place.id); } catch {}
|
|
396
|
+
loc = loadLocation();
|
|
397
|
+
} else {
|
|
398
|
+
return {
|
|
399
|
+
mapId: tMap.id, mapName: tMap.name, placeId: loc.toPlaceId, name: tMap.name, icon: '🚉', desc: '', feel: '',
|
|
400
|
+
since: loc.startedAt, minutes: 0, manual: !!loc.manual, reason: '', avatar: avatarUrl,
|
|
401
|
+
intercity: {
|
|
402
|
+
mode: loc.mode, modeLabel: (TRANSPORTS[loc.mode] || TRANSPORTS.rail).label, modeIcon: (TRANSPORTS[loc.mode] || TRANSPORTS.rail).icon,
|
|
403
|
+
fromMapName: loc.fromMapName || '', toMapName: loc.toMapName || tMap.name,
|
|
404
|
+
distanceKm: Number(loc.distanceKm) || 0, progress, etaSec: Math.max(0, Math.round((dur - elapsed) / 1000)),
|
|
405
|
+
durationMs: dur, startedAt: Number(loc.startedAt) || now,
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const map = maps.find((m) => m.id === loc.mapId) || getActiveMap();
|
|
411
|
+
if (loc.toPlaceId) {
|
|
412
|
+
const info = travelInfo(loc, map);
|
|
413
|
+
if (info && info.progress >= 1) {
|
|
414
|
+
const next = finalizeArrival(loc, map);
|
|
415
|
+
CONFIG.location = next; saveConfig();
|
|
416
|
+
loc = loadLocation();
|
|
417
|
+
} else if (info) {
|
|
418
|
+
const target = placeOf(map, loc.toPlaceId);
|
|
419
|
+
return {
|
|
420
|
+
mapId: map.id, mapName: map.name, placeId: loc.placeId, name: target.name, icon: target.icon, desc: target.desc, feel: target.feel,
|
|
421
|
+
since: loc.since, minutes: Math.round((Date.now() - loc.since) / 60000), manual: !!loc.manual, reason: '', avatar: avatarUrl, traveling: info,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const place = placeOf(map, loc.placeId);
|
|
426
|
+
return {
|
|
427
|
+
mapId: map.id, mapName: map.name, placeId: place.id, name: place.name, icon: place.icon, desc: place.desc || '', feel: place.feel || '',
|
|
428
|
+
since: loc.since, minutes: Math.max(0, Math.round((Date.now() - loc.since) / 60000)), manual: !!loc.manual, reason: '', traveling: null, avatar: avatarUrl,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function travelInfo(loc, map) {
|
|
432
|
+
if (!loc.toPlaceId) return null;
|
|
433
|
+
const from = map.places.find((p) => p.id === loc.fromPlaceId);
|
|
434
|
+
const to = map.places.find((p) => p.id === loc.toPlaceId);
|
|
435
|
+
if (!to) return null;
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
const dur = Math.max(1000, Number(loc.durationMs) || 1000);
|
|
438
|
+
const elapsed = now - (Number(loc.startedAt) || now);
|
|
439
|
+
const distance = Number(loc.distanceMeters) || (from ? placeDistanceMeters(map, from, to) : 0);
|
|
440
|
+
return {
|
|
441
|
+
fromId: from ? from.id : '', fromName: from ? from.name : '', toId: to.id, toName: to.name, mode: loc.mode || 'walk',
|
|
442
|
+
modeLabel: (TRANSPORTS[loc.mode] || TRANSPORTS.walk).label, modeIcon: (TRANSPORTS[loc.mode] || TRANSPORTS.walk).icon,
|
|
443
|
+
distanceMeters: Math.round(distance), durationMs: dur, startedAt: Number(loc.startedAt) || now,
|
|
444
|
+
progress: Math.max(0, Math.min(1, elapsed / dur)), etaSec: Math.max(0, Math.round((dur - elapsed) / 1000)),
|
|
445
|
+
path: Array.isArray(loc.path) ? loc.path : [],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function finalizeArrival(loc, map) {
|
|
449
|
+
const place = placeOf(map, loc.toPlaceId || loc.placeId);
|
|
450
|
+
try { recordVisit(place.id); } catch {}
|
|
451
|
+
return { mapId: map.id, placeId: place.id, since: Date.now(), manual: !!loc.manual };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---------- weather ----------
|
|
455
|
+
const WEATHERS = {
|
|
456
|
+
sunny: { label: '晴天', icon: '☀️', walk: 1.0, bike: 1.0, note: '天气不错' },
|
|
457
|
+
cloudy: { label: '多云', icon: '☁️', walk: 1.0, bike: 1.0, note: '天有点阴' },
|
|
458
|
+
rain: { label: '下雨', icon: '🌧️', walk: 0.85, bike: 0.5, note: '下雨了' },
|
|
459
|
+
snow: { label: '下雪', icon: '❄️', walk: 0.7, bike: 0.35, note: '下雪了,路滑' },
|
|
460
|
+
hot: { label: '热', icon: '🥵', walk: 0.85, bike: 0.75, note: '太热了' },
|
|
461
|
+
cold: { label: '冷', icon: '🧣', walk: 0.9, bike: 0.8, note: '有点冷' },
|
|
462
|
+
windy: { label: '大风', icon: '💨', walk: 0.9, bike: 0.6, note: '风有点大' },
|
|
463
|
+
};
|
|
464
|
+
let weatherCache = { at: 0, data: null };
|
|
465
|
+
function weatherNow() {
|
|
466
|
+
const m = CONFIG.weatherMode === 'real' ? (weatherCache.data ? weatherCache.data.kind : 'sunny') : (CONFIG.weather || 'sunny');
|
|
467
|
+
return WEATHERS[m] ? m : 'sunny';
|
|
468
|
+
}
|
|
469
|
+
async function fetchJson(url, timeoutMs = 12000) {
|
|
470
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'user-agent': 'dsh-screen-beyond/1.0' } });
|
|
471
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
472
|
+
return await res.json();
|
|
473
|
+
}
|
|
474
|
+
async function getGeo(force = false) {
|
|
475
|
+
const geo = CONFIG.geo;
|
|
476
|
+
if (!force && geo && Number.isFinite(Number(geo.lat)) && Date.now() - (Number(geo.at) || 0) < 7 * 86400000) return geo;
|
|
477
|
+
const providers = [
|
|
478
|
+
{ url: 'https://ipwho.is/', pick: (d) => (d && d.success && d.latitude ? { lat: d.latitude, lon: d.longitude, city: d.city || '' } : null) },
|
|
479
|
+
{ url: 'http://ip-api.com/json/?fields=status,city,regionName,lat,lon', pick: (d) => (d && d.status === 'success' && Number.isFinite(Number(d.lat)) ? { lat: d.lat, lon: d.lon, city: d.city || d.regionName || '' } : null) },
|
|
480
|
+
{ url: 'https://ipapi.co/json/', pick: (d) => (d && Number.isFinite(Number(d.latitude)) ? { lat: d.latitude, lon: d.longitude, city: d.city || '' } : null) },
|
|
481
|
+
];
|
|
482
|
+
for (const p of providers) {
|
|
483
|
+
try { const loc = p.pick(await fetchJson(p.url, 10000)); if (loc) { CONFIG.geo = { ...loc, at: Date.now() }; saveConfig(); return CONFIG.geo; } } catch {}
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
function codeToKind(code, temp, wind) {
|
|
488
|
+
if (code >= 71 && code <= 77) return 'snow';
|
|
489
|
+
if (code >= 85 && code <= 86) return 'snow';
|
|
490
|
+
if (code >= 51 && code <= 67) return 'rain';
|
|
491
|
+
if (code >= 80 && code <= 82) return 'rain';
|
|
492
|
+
if (code >= 95) return 'rain';
|
|
493
|
+
if (code >= 45 && code <= 48) return 'cloudy';
|
|
494
|
+
if (code >= 1 && code <= 3) return 'cloudy';
|
|
495
|
+
if (temp >= 30) return 'hot';
|
|
496
|
+
if (temp <= 2) return 'cold';
|
|
497
|
+
if (wind >= 30) return 'windy';
|
|
498
|
+
return 'sunny';
|
|
499
|
+
}
|
|
500
|
+
async function getRealWeather(force = false) {
|
|
501
|
+
if (!force && weatherCache.data && Date.now() - weatherCache.at < 20 * 60 * 1000) return weatherCache.data;
|
|
502
|
+
try {
|
|
503
|
+
const geo = await getGeo();
|
|
504
|
+
if (!geo) throw new Error('no geo');
|
|
505
|
+
const d = await fetchJson('https://api.open-meteo.com/v1/forecast?latitude=' + geo.lat + '&longitude=' + geo.lon + '¤t=temperature_2m,weather_code,windspeed_10m&timezone=auto');
|
|
506
|
+
const cur = d.current || {};
|
|
507
|
+
const kind = codeToKind(Number(cur.weather_code) || 0, Number(cur.temperature_2m), Number(cur.windspeed_10m) || 0);
|
|
508
|
+
weatherCache = { at: Date.now(), data: { kind, temp: Number.isFinite(Number(cur.temperature_2m)) ? Math.round(Number(cur.temperature_2m)) : null, city: geo.city || '', source: 'real' } };
|
|
509
|
+
return weatherCache.data;
|
|
510
|
+
} catch {
|
|
511
|
+
weatherCache = { at: Date.now(), data: { kind: CONFIG.weather || 'sunny', temp: null, city: '', source: 'fallback' } };
|
|
512
|
+
return weatherCache.data;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function loadWeatherFx() { return CONFIG.weatherFx || {}; }
|
|
516
|
+
|
|
517
|
+
// ---------- capabilities / energy / routine / visits ----------
|
|
518
|
+
const MODE_RULES = { walk: { minAge: 0 }, bike: { minAge: 12 }, bus: {}, metro: {}, coach: {}, rail: {}, plane: {}, car: { minAge: 18, needLicense: true } };
|
|
519
|
+
function charCapabilities() {
|
|
520
|
+
const m = loadMeta();
|
|
521
|
+
const ageRaw = Number(m.age);
|
|
522
|
+
const age = ageRaw > 0 && ageRaw < 120 ? ageRaw : null;
|
|
523
|
+
const adult = age === null || age >= 18;
|
|
524
|
+
const license = m.license === true && adult;
|
|
525
|
+
return { age, minor: age !== null && age < 18, adult, license, pref: m.travelPref || 'auto', name: m.name || '' };
|
|
526
|
+
}
|
|
527
|
+
function currentActivityLabel(now) {
|
|
528
|
+
try {
|
|
529
|
+
const hit = scheduleHint(now || new Date());
|
|
530
|
+
if (hit && hit.label) return hit.label;
|
|
531
|
+
const life = loadLife();
|
|
532
|
+
const acts = (life.activities || []).filter((a) => a.window && isInWindow(a.window, now || new Date()));
|
|
533
|
+
if (acts.length) return acts[0].name;
|
|
534
|
+
} catch {}
|
|
535
|
+
return '';
|
|
536
|
+
}
|
|
537
|
+
function energyRateFor(label, placeId, hour) {
|
|
538
|
+
const s = String(label || '');
|
|
539
|
+
if (/睡|躺|休息|发呆|看视频|刷|小说|玩手机|听歌|晒太阳|看剧/.test(s)) return placeId === 'home' ? 2.2 : 1.4;
|
|
540
|
+
if (/健身|运动|跑步|骑|球|游泳/.test(s)) return -2.6;
|
|
541
|
+
if (/上课|学习|写作业|工作|上班|图书馆|收拾|打扫|做饭/.test(s)) return -1.6;
|
|
542
|
+
if (/游戏|电影|逛街|唱歌|咖啡|网吧|酒吧|约会/.test(s)) return -0.3;
|
|
543
|
+
if (hour >= 0 && hour < 6 && placeId === 'home') return 2.0;
|
|
544
|
+
if (placeId === 'home') return 1.0;
|
|
545
|
+
if (['cafe', 'library', 'park', 'riverside'].includes(placeId)) return 0.6;
|
|
546
|
+
return 0.2;
|
|
547
|
+
}
|
|
548
|
+
function loadCharState() {
|
|
549
|
+
const s = readJson(statePath(), {});
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
const updated = Number(s.updatedAt) || now;
|
|
552
|
+
const hours = Math.max(0, Math.min(48, (now - updated) / 3600000));
|
|
553
|
+
let energy = Number.isFinite(Number(s.energy)) ? Number(s.energy) : 82;
|
|
554
|
+
const loc = CONFIG.location || {};
|
|
555
|
+
const traveling = !!loc.toPlaceId || !!loc.intercity;
|
|
556
|
+
if (!traveling) {
|
|
557
|
+
const d = new Date();
|
|
558
|
+
energy += energyRateFor(currentActivityLabel(d), loc.placeId || 'home', d.getHours()) * hours;
|
|
559
|
+
}
|
|
560
|
+
energy = Math.max(0, Math.min(100, energy));
|
|
561
|
+
return { energy: Math.round(energy) };
|
|
562
|
+
}
|
|
563
|
+
function saveCharState(st) { writeJson(statePath(), { energy: Math.max(0, Math.min(100, Math.round(st.energy))), updatedAt: Date.now() }); }
|
|
564
|
+
function drainEnergy(mode, distanceMeters) {
|
|
565
|
+
const st = loadCharState();
|
|
566
|
+
const perKm = { walk: 7, bike: 3.5, bus: 1.4, metro: 1.2, coach: 1.2, rail: 0.8, plane: 2.2, car: 0.6 };
|
|
567
|
+
const wet = weatherNow() === 'rain' || weatherNow() === 'snow';
|
|
568
|
+
const p = (perKm[mode] || 2) * (wet && (mode === 'walk' || mode === 'bike') ? 1.15 : 1);
|
|
569
|
+
st.energy = Math.max(0, Math.round(st.energy - (distanceMeters / 1000) * p));
|
|
570
|
+
saveCharState(st);
|
|
571
|
+
return st.energy;
|
|
572
|
+
}
|
|
573
|
+
function validateMode(mode, ctx = {}) {
|
|
574
|
+
const caps = charCapabilities();
|
|
575
|
+
const rule = MODE_RULES[mode] || {};
|
|
576
|
+
const label = (TRANSPORTS[mode] || {}).label || mode;
|
|
577
|
+
if (rule.needLicense && !caps.license) {
|
|
578
|
+
if (caps.age !== null && caps.age < 18) return { ok: false, reason: `我${caps.age}岁,还没到能考驾照的年龄呢,${label}不行` };
|
|
579
|
+
return { ok: false, reason: `我没有驾照,${label}不行` };
|
|
580
|
+
}
|
|
581
|
+
if (rule.minAge && caps.age !== null && caps.age < rule.minAge) return { ok: false, reason: `我${caps.age}岁,还不能自己${label}` };
|
|
582
|
+
if ((mode === 'walk' || mode === 'bike') && ctx.distanceMeters) {
|
|
583
|
+
const st = loadCharState();
|
|
584
|
+
if (st.energy < 25 && ctx.distanceMeters > 1500) return { ok: false, reason: '我有点累了,不想走那么远' };
|
|
585
|
+
if (st.energy < 10) return { ok: false, reason: '我走不动了,想在家歇会儿' };
|
|
586
|
+
}
|
|
587
|
+
if (ctx.night && caps.minor && !ctx.confirmNight && ctx.toHome !== true) return { ok: false, needsConfirm: true, reason: '这么晚了,我一个人出门要跟你说一声——你确定让我去吗' };
|
|
588
|
+
return { ok: true };
|
|
589
|
+
}
|
|
590
|
+
function defaultRoutineBands() {
|
|
591
|
+
return [
|
|
592
|
+
{ start: 0, end: 6, allowed: false, weights: { home: 30 } },
|
|
593
|
+
{ start: 6, end: 8, allowed: true, weights: { home: 8, canteen: 3, market: 2 } },
|
|
594
|
+
{ start: 8, end: 12, allowed: true, weights: { school: 7, library: 4, home: 1, cafe: 1 } },
|
|
595
|
+
{ start: 12, end: 14, allowed: true, weights: { canteen: 7, home: 4, market: 1 } },
|
|
596
|
+
{ start: 14, end: 17.5, allowed: true, weights: { school: 6, library: 4, cafe: 2, park: 1, home: 1 } },
|
|
597
|
+
{ start: 17.5, end: 19.5, allowed: true, weights: { canteen: 6, home: 5, market: 1 } },
|
|
598
|
+
{ start: 19.5, end: 22, allowed: true, weights: { home: 6, park: 3, cafe: 2, market: 1, library: 1 } },
|
|
599
|
+
{ start: 22, end: 24, allowed: true, weights: { home: 20, canteen: 1 } },
|
|
600
|
+
];
|
|
601
|
+
}
|
|
602
|
+
function loadRoutine() { const r = readJson(routinePath(), null); if (r && Array.isArray(r.bands) && r.bands.length) return { bands: r.bands, smart: r.smart === true }; return { bands: defaultRoutineBands(), smart: false }; }
|
|
603
|
+
function saveRoutine(routine) {
|
|
604
|
+
const bands = (routine.bands || []).slice(0, 16).map((b) => ({
|
|
605
|
+
start: Math.max(0, Math.min(24, Number(b.start) || 0)), end: Math.max(0, Math.min(24, Number(b.end) || 24)),
|
|
606
|
+
allowed: b.allowed !== false,
|
|
607
|
+
weights: Object.fromEntries(Object.entries(b.weights || {}).map(([k, v]) => [String(k), Math.max(0, Math.min(100, Number(v) || 0))]).filter(([, v]) => v > 0)),
|
|
608
|
+
})).sort((a, b) => a.start - b.start);
|
|
609
|
+
writeJson(routinePath(), { bands, smart: routine.smart === true });
|
|
610
|
+
return { bands, smart: routine.smart === true };
|
|
611
|
+
}
|
|
612
|
+
function bandForHour(bands, hour) { for (const b of bands) { if (b.start <= b.end) { if (hour >= b.start && hour < b.end) return b; } else { if (hour >= b.start || hour < b.end) return b; } } return null; }
|
|
613
|
+
function loadVisits() { return readJson(visitsPath(), {}); }
|
|
614
|
+
function recordVisit(placeId) { const v = loadVisits(); const now = Date.now(); const cur = v[placeId] || { count: 0, lastAt: 0 }; v[placeId] = { count: (cur.count || 0) + 1, lastAt: now }; writeJson(visitsPath(), v); }
|
|
615
|
+
function scheduleHint(now) {
|
|
616
|
+
const life = loadLife();
|
|
617
|
+
for (const s of life.schedules || []) if (scheduleHitsNow(s, now)) return { label: s.label, place: s.place || guessPlaceId(s.label) };
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
function scheduleHitsNow(s, now) {
|
|
621
|
+
const m = String(s.time || '').match(/(\d{1,2}:\d{2})\s*(?:-\s*(\d{1,2}:\d{2}))?/);
|
|
622
|
+
if (!m) return false;
|
|
623
|
+
const cur = now.getHours() * 60 + now.getMinutes();
|
|
624
|
+
const toMin = (x) => { const [h, mi] = x.split(':').map(Number); return h * 60 + mi; };
|
|
625
|
+
if (!m[2]) return cur >= toMin(m[1]) && cur < toMin(m[1]) + 30;
|
|
626
|
+
const a = toMin(m[1]), b = toMin(m[2]);
|
|
627
|
+
return a <= b ? (cur >= a && cur <= b) : (cur >= a || cur <= b);
|
|
628
|
+
}
|
|
629
|
+
function guessPlaceId(s) {
|
|
630
|
+
const x = String(s || '');
|
|
631
|
+
if (/家|回家|睡|起床|洗漱/.test(x)) return 'home';
|
|
632
|
+
if (/学校|上课|课|放学/.test(x)) return 'school';
|
|
633
|
+
if (/图书馆|自习|写作业|学习/.test(x)) return 'library';
|
|
634
|
+
if (/咖啡/.test(x)) return 'cafe';
|
|
635
|
+
if (/公园|散步|运动/.test(x)) return 'park';
|
|
636
|
+
if (/超市|买|购物|菜/.test(x)) return 'market';
|
|
637
|
+
if (/饭|吃/.test(x)) return 'canteen';
|
|
638
|
+
if (/电影/.test(x)) return 'cinema';
|
|
639
|
+
return 'home';
|
|
640
|
+
}
|
|
641
|
+
function isInWindow(win, now) {
|
|
642
|
+
const m = String(win).match(/(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})/);
|
|
643
|
+
if (!m) return false;
|
|
644
|
+
const toMin = (x) => { const [h, mi] = x.split(':').map(Number); return h * 60 + mi; };
|
|
645
|
+
const cur = now.getHours() * 60 + now.getMinutes();
|
|
646
|
+
const a = toMin(m[1]), b = toMin(m[2]);
|
|
647
|
+
return a <= b ? (cur >= a && cur <= b) : (cur >= a || cur <= b);
|
|
648
|
+
}
|
|
649
|
+
function pickTransport(mapMode, distanceMeters) {
|
|
650
|
+
const caps = charCapabilities();
|
|
651
|
+
const w = WEATHERS[weatherNow()] || WEATHERS.sunny;
|
|
652
|
+
const st = loadCharState();
|
|
653
|
+
const badWeather = w.label === '下雨' || w.label === '下雪';
|
|
654
|
+
const canBike = !badWeather && !(st.energy < 20);
|
|
655
|
+
if (mapMode && mapMode !== 'auto' && TRANSPORTS[mapMode]) {
|
|
656
|
+
if (mapMode === 'car' && !caps.license) return 'metro';
|
|
657
|
+
if (mapMode === 'bike' && !canBike) return 'bus';
|
|
658
|
+
return mapMode;
|
|
659
|
+
}
|
|
660
|
+
if (distanceMeters < 800) return st.energy < 20 ? 'bus' : 'walk';
|
|
661
|
+
if (distanceMeters < 2500) { if (!canBike || st.energy < 35) return 'bus'; return Math.random() < 0.6 ? 'bike' : 'bus'; }
|
|
662
|
+
if (distanceMeters < 6000) return Math.random() < 0.5 ? 'bus' : 'metro';
|
|
663
|
+
return caps.license ? 'car' : (Math.random() < 0.5 ? 'metro' : 'bus');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ---------- economy ----------
|
|
667
|
+
const ECONOMY_DEFAULT = {
|
|
668
|
+
enabled: true, initialBalance: 200, dailyIncome: 20, wagePerHour: 15, mealCost: 12, entertainmentCost: 30,
|
|
669
|
+
moneyType: 'virtual', tokenMode: false, tokenBudget: 2000000, tokensPerYuan: 1000,
|
|
670
|
+
transportBase: { walk: 0, bike: 0, bus: 2, metro: 2, car: 10, coach: 15, rail: 30, plane: 80 },
|
|
671
|
+
transportPerKm: { walk: 0, bike: 0, bus: 0.6, metro: 0.6, car: 1.2, coach: 0.8, rail: 0.5, plane: 0.6 },
|
|
672
|
+
};
|
|
673
|
+
function loadEconomy() {
|
|
674
|
+
const e = CONFIG.economy || {};
|
|
675
|
+
const moneyType = e.moneyType === 'token' || e.tokenMode === true ? 'token' : 'virtual';
|
|
676
|
+
return { ...ECONOMY_DEFAULT, ...e, moneyType, transportBase: { ...ECONOMY_DEFAULT.transportBase, ...(e.transportBase || {}) }, transportPerKm: { ...ECONOMY_DEFAULT.transportPerKm, ...(e.transportPerKm || {}) } };
|
|
677
|
+
}
|
|
678
|
+
function saveEconomy(e) { CONFIG.economy = e; saveConfig(); return e; }
|
|
679
|
+
function readWalletRaw() { return readJson(walletPath(), {}); }
|
|
680
|
+
function writeWalletRaw(w) { writeJson(walletPath(), w); }
|
|
681
|
+
function loadWallet() {
|
|
682
|
+
const econ = loadEconomy();
|
|
683
|
+
const raw = readWalletRaw();
|
|
684
|
+
const now = Date.now();
|
|
685
|
+
let balance = Number.isFinite(Number(raw.balance)) ? Number(raw.balance) : econ.initialBalance;
|
|
686
|
+
const usedTokens = Number(raw.usedTokens) || 0;
|
|
687
|
+
let changed = !Number.isFinite(Number(raw.balance));
|
|
688
|
+
const lastIncomeAt = Number(raw.lastIncomeAt) || 0;
|
|
689
|
+
if (econ.enabled && now - lastIncomeAt >= 86400000) { balance += Number(econ.dailyIncome) || 0; changed = true; }
|
|
690
|
+
let lastWageAt = Number(raw.lastWageAt) || now;
|
|
691
|
+
try {
|
|
692
|
+
const label = currentActivityLabel(new Date());
|
|
693
|
+
if (econ.enabled && /工作|上班|打工|值班/.test(label)) {
|
|
694
|
+
const hrs = Math.max(0, (now - lastWageAt) / 3600000);
|
|
695
|
+
if (hrs >= 0.5) { balance += Math.round(hrs * (Number(econ.wagePerHour) || 0)); changed = true; lastWageAt = now; }
|
|
696
|
+
} else lastWageAt = now;
|
|
697
|
+
} catch {}
|
|
698
|
+
if (changed) writeWalletRaw({ balance, usedTokens, lastIncomeAt: now, lastWageAt });
|
|
699
|
+
return { balance, usedTokens, lastIncomeAt: changed ? now : lastIncomeAt, lastWageAt };
|
|
700
|
+
}
|
|
701
|
+
function walletMoney() {
|
|
702
|
+
const econ = loadEconomy(); const w = loadWallet();
|
|
703
|
+
const remainingTokens = Math.max(0, Number(econ.tokenBudget) - (Number(w.usedTokens) || 0));
|
|
704
|
+
const tokenMode = econ.moneyType === 'token';
|
|
705
|
+
let spendable = Math.max(0, Math.round(w.balance));
|
|
706
|
+
if (tokenMode) spendable = Math.floor(remainingTokens / Math.max(1, Number(econ.tokensPerYuan) || 1000));
|
|
707
|
+
return { econ, wallet: w, spendable, remainingTokens, tokenMode, moneyType: econ.moneyType };
|
|
708
|
+
}
|
|
709
|
+
function spendMoney(amount, why = '') {
|
|
710
|
+
const econ = loadEconomy(); const amt = Math.round(Number(amount) || 0);
|
|
711
|
+
if (!econ.enabled || amt <= 0) return { ok: true, left: walletMoney().spendable };
|
|
712
|
+
const st = walletMoney();
|
|
713
|
+
if (st.spendable < amt) {
|
|
714
|
+
if (st.tokenMode) return { ok: false, reason: `token 不够了:还差约 ${Math.ceil((amt - st.spendable) * (Number(econ.tokensPerYuan) || 1000))} token`, left: st.spendable };
|
|
715
|
+
return { ok: false, reason: `钱不够了:还差 ${amt - st.spendable} 块`, left: st.spendable };
|
|
716
|
+
}
|
|
717
|
+
const raw = readWalletRaw();
|
|
718
|
+
if (st.tokenMode) { writeWalletRaw({ ...raw, usedTokens: (Number(raw.usedTokens) || 0) + Math.round(amt * (Number(econ.tokensPerYuan) || 1000)) }); return { ok: true, left: st.spendable - amt, why }; }
|
|
719
|
+
const balance = (Number.isFinite(Number(raw.balance)) ? Number(raw.balance) : econ.initialBalance) - amt;
|
|
720
|
+
writeWalletRaw({ ...raw, balance: Math.max(0, balance), lastIncomeAt: raw.lastIncomeAt || Date.now() });
|
|
721
|
+
return { ok: true, left: Math.max(0, balance), why };
|
|
722
|
+
}
|
|
723
|
+
function earnMoney(amount) {
|
|
724
|
+
const econ = loadEconomy(); const raw = readWalletRaw();
|
|
725
|
+
if (econ.moneyType === 'token') { const used = Math.max(0, (Number(raw.usedTokens) || 0) - Math.round(Number(amount) * (Number(econ.tokensPerYuan) || 1000))); writeWalletRaw({ ...raw, usedTokens: used }); return Math.floor(Math.max(0, Number(econ.tokenBudget) - used) / Math.max(1, Number(econ.tokensPerYuan) || 1000)); }
|
|
726
|
+
const balance = (Number.isFinite(Number(raw.balance)) ? Number(raw.balance) : econ.initialBalance) + Math.round(Number(amount) || 0);
|
|
727
|
+
writeWalletRaw({ ...raw, balance }); return balance;
|
|
728
|
+
}
|
|
729
|
+
function recordUsageTokens(usage) {
|
|
730
|
+
if (!usage) return;
|
|
731
|
+
const total = (Number(usage.prompt_tokens) || 0) + (Number(usage.completion_tokens) || 0);
|
|
732
|
+
if (total <= 0) return;
|
|
733
|
+
const raw = readWalletRaw();
|
|
734
|
+
const date = todayStr();
|
|
735
|
+
const prevDaily = raw.dailyDate === date ? (Number(raw.dailyUsed) || 0) : 0;
|
|
736
|
+
writeWalletRaw({ ...raw, usedTokens: (Number(raw.usedTokens) || 0) + total, dailyDate: date, dailyUsed: prevDaily + total });
|
|
737
|
+
}
|
|
738
|
+
function tokenStats() {
|
|
739
|
+
const m = walletMoney();
|
|
740
|
+
const raw = readWalletRaw();
|
|
741
|
+
const today = todayStr();
|
|
742
|
+
return {
|
|
743
|
+
remainingTokens: m.remainingTokens,
|
|
744
|
+
usedTokens: Number(raw.usedTokens) || 0,
|
|
745
|
+
dailyUsed: raw.dailyDate === today ? (Number(raw.dailyUsed) || 0) : 0,
|
|
746
|
+
tokenBudget: Number(m.econ.tokenBudget) || 0,
|
|
747
|
+
moneyType: m.moneyType,
|
|
748
|
+
spendable: m.spendable,
|
|
749
|
+
balance: m.wallet.balance,
|
|
750
|
+
date: today,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
const CHAT_STYLE_DEFAULT = { noParens: false };
|
|
754
|
+
function loadChatStyle() { return { ...CHAT_STYLE_DEFAULT, ...(CONFIG.chatStyle || {}) }; }
|
|
755
|
+
function saveChatStyle(x) { CONFIG.chatStyle = { ...loadChatStyle(), ...(x || {}) }; saveConfig(); return CONFIG.chatStyle; }
|
|
756
|
+
function stripParens(text) {
|
|
757
|
+
let s = String(text || '');
|
|
758
|
+
s = s.replace(/([^)]*)/g, '')
|
|
759
|
+
.replace(/\([^)]*\)/g, '')
|
|
760
|
+
.replace(/【[^】]*】/g, '')
|
|
761
|
+
.replace(/\[[^\]]*\]/g, '')
|
|
762
|
+
.replace(/[**][^**]*[**]/g, '');
|
|
763
|
+
s = s.replace(/[ \t]{2,}/g, ' ').replace(/^[,,。.、;;::\s]+/, '').replace(/[,,、;;::\s]+$/, '');
|
|
764
|
+
return s.trim();
|
|
765
|
+
}
|
|
766
|
+
function herText(text) {
|
|
767
|
+
const s = String(text || '');
|
|
768
|
+
try { if (loadChatStyle().noParens) return stripParens(s) || '……'; } catch {}
|
|
769
|
+
return s;
|
|
770
|
+
}
|
|
771
|
+
function transportFare(mode, meters) {
|
|
772
|
+
const econ = loadEconomy();
|
|
773
|
+
const base = Number(econ.transportBase[mode]) || 0;
|
|
774
|
+
const perKm = Number(econ.transportPerKm[mode]) || 0;
|
|
775
|
+
return Math.round((base + perKm * (Math.max(0, Number(meters) || 0) / 1000)) * 10) / 10;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---------- move ----------
|
|
779
|
+
function moveTo(placeId, opts = {}) {
|
|
780
|
+
const maps = loadMaps();
|
|
781
|
+
const map = maps.find((m) => m.id === (opts.mapId || activeMapId())) || getActiveMap();
|
|
782
|
+
const target = map.places.find((p) => p.id === placeId);
|
|
783
|
+
if (!target) return currentPlace();
|
|
784
|
+
CONFIG.activeMapId = map.id; saveConfig();
|
|
785
|
+
const cur = currentPlace();
|
|
786
|
+
const fromPlace = map.places.find((p) => p.id === (cur.traveling ? cur.traveling.fromId : cur.placeId)) || target;
|
|
787
|
+
const distance = placeDistanceMeters(map, fromPlace, target);
|
|
788
|
+
const hour = new Date().getHours();
|
|
789
|
+
const night = hour >= 22 || hour < 6;
|
|
790
|
+
if (opts.instant || fromPlace.id === target.id) {
|
|
791
|
+
try { recordVisit(target.id); } catch {}
|
|
792
|
+
saveLocation({ mapId: map.id, placeId: target.id, since: Date.now(), manual: opts.manual !== false });
|
|
793
|
+
return currentPlace();
|
|
794
|
+
}
|
|
795
|
+
const ctx = { distanceMeters: distance, night, confirmNight: !!opts.confirmNight, toHome: target.id === 'home' };
|
|
796
|
+
if (opts.mode && TRANSPORTS[opts.mode]) {
|
|
797
|
+
const verdict = validateMode(opts.mode, ctx);
|
|
798
|
+
if (!verdict.ok) return { refused: true, reason: verdict.reason, needsConfirm: !!verdict.needsConfirm };
|
|
799
|
+
} else {
|
|
800
|
+
const nv = validateMode('walk', ctx);
|
|
801
|
+
if (!nv.ok && nv.needsConfirm) return { refused: true, reason: nv.reason, needsConfirm: true };
|
|
802
|
+
}
|
|
803
|
+
const route = routeOnRoads(map, fromPlace, target);
|
|
804
|
+
const realDistance = route.distanceMeters || distance;
|
|
805
|
+
let mode = opts.mode && TRANSPORTS[opts.mode] ? opts.mode : pickTransport(map.transport, realDistance);
|
|
806
|
+
let fare = transportFare(mode, realDistance);
|
|
807
|
+
if (fare > 0) {
|
|
808
|
+
const aff = walletMoney();
|
|
809
|
+
if (aff.spendable < fare) {
|
|
810
|
+
if (opts.mode) return { refused: true, reason: `钱不太够了:坐${(TRANSPORTS[mode] || {}).label}要 ${fare} 块,我现在只剩 ${aff.spendable} 块` };
|
|
811
|
+
fare = 0; mode = 'walk';
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (!opts.mode) {
|
|
815
|
+
try { const stt = walletMoney(); if (stt.tokenMode && fare > 0 && fare > Math.max(1, stt.spendable * 0.05)) { fare = 0; mode = 'walk'; } } catch {}
|
|
816
|
+
}
|
|
817
|
+
const wNow = WEATHERS[weatherNow()] || WEATHERS.sunny;
|
|
818
|
+
const weatherFactor = (mode === 'walk' || mode === 'bike') && Number(wNow[mode]) > 0 ? Number(wNow[mode]) : 1;
|
|
819
|
+
const speed = (TRANSPORTS[mode] || TRANSPORTS.walk).speed * weatherFactor;
|
|
820
|
+
const durationMs = Math.max(2000, Math.round((realDistance / Math.max(0.1, speed)) * 1000));
|
|
821
|
+
const energyAfter = drainEnergy(mode, realDistance);
|
|
822
|
+
if (night && target.id !== 'home') {
|
|
823
|
+
try {
|
|
824
|
+
const caps = charCapabilities();
|
|
825
|
+
const line = (caps.minor ? '(报备)这么晚了,我出门去' : '(报备)我出门去') + target.name + ',到了跟你说一声。';
|
|
826
|
+
const last = [...messages].reverse().find((m) => m.role === 'her' && typeof m.text === 'string' && m.text.startsWith('(报备)'));
|
|
827
|
+
const recent = last && Date.now() - (Number(last.at) || 0) < 10 * 60 * 1000;
|
|
828
|
+
if (!opts.noReport && !(recent && last.text === line)) addMessage('her', line);
|
|
829
|
+
} catch {}
|
|
830
|
+
}
|
|
831
|
+
if (fare > 0) { try { spendMoney(fare, '交通'); } catch {} }
|
|
832
|
+
saveLocation({
|
|
833
|
+
mapId: map.id, fromPlaceId: fromPlace.id, toPlaceId: target.id, fromName: fromPlace.name,
|
|
834
|
+
startedAt: Date.now(), durationMs, mode, distanceMeters: Math.round(realDistance),
|
|
835
|
+
path: route.path, placeId: target.id, since: Date.now(), manual: opts.manual !== false, energyAfter,
|
|
836
|
+
});
|
|
837
|
+
return currentPlace();
|
|
838
|
+
}
|
|
839
|
+
function intercityDistanceKm(a, b) {
|
|
840
|
+
const seed = (String(a.id) + String(b.id)).split('').reduce((s, c) => s + c.charCodeAt(0), 0);
|
|
841
|
+
return 180 + (seed * 37) % 620;
|
|
842
|
+
}
|
|
843
|
+
function pickIntercityMode(km) {
|
|
844
|
+
if (km < 250) return Math.random() < 0.7 ? 'coach' : 'rail';
|
|
845
|
+
if (km < 600) return Math.random() < 0.7 ? 'rail' : 'coach';
|
|
846
|
+
return 'plane';
|
|
847
|
+
}
|
|
848
|
+
function moveToMap(targetMapId, opts = {}) {
|
|
849
|
+
const maps = loadMaps();
|
|
850
|
+
const target = maps.find((m) => m.id === targetMapId);
|
|
851
|
+
if (!target) return currentPlace();
|
|
852
|
+
const from = getActiveMap();
|
|
853
|
+
if (from.id === target.id) return currentPlace();
|
|
854
|
+
{
|
|
855
|
+
const caps = charCapabilities();
|
|
856
|
+
const hour = new Date().getHours();
|
|
857
|
+
const night = hour >= 22 || hour < 6;
|
|
858
|
+
if (night && caps.minor && !opts.confirmNight) return { refused: true, needsConfirm: true, reason: '这么晚还要去别的城市?我得先跟你说一声——确定让我去吗' };
|
|
859
|
+
if (!opts.noReport) { try { addMessage('her', `(报备)我坐${(TRANSPORTS[opts.mode] || TRANSPORTS.rail).label}去${target.name},到了跟你说。`); } catch {} }
|
|
860
|
+
}
|
|
861
|
+
const km = intercityDistanceKm(from, target);
|
|
862
|
+
let mode = opts.mode && ['coach', 'rail', 'plane'].includes(opts.mode) ? opts.mode : pickIntercityMode(km);
|
|
863
|
+
const fare = transportFare(mode, km * 1000);
|
|
864
|
+
if (fare > 0) {
|
|
865
|
+
const aff = walletMoney();
|
|
866
|
+
if (aff.spendable < fare && !opts.instant) return { refused: true, reason: `去${target.name}的${(TRANSPORTS[mode] || {}).label}票要 ${fare} 块,我只有 ${aff.spendable} 块,买不起` };
|
|
867
|
+
if (!opts.instant) { try { spendMoney(fare, '跨城车票'); } catch {} }
|
|
868
|
+
}
|
|
869
|
+
const speed = (TRANSPORTS[mode] || TRANSPORTS.rail).speed;
|
|
870
|
+
const durationMs = opts.instant ? 1000 : Math.max(2000, Math.round((km * 1000) / speed * 1000));
|
|
871
|
+
if (opts.instant) {
|
|
872
|
+
CONFIG.activeMapId = target.id;
|
|
873
|
+
saveLocation({ mapId: target.id, placeId: target.places[0].id, since: Date.now(), manual: true });
|
|
874
|
+
saveConfig();
|
|
875
|
+
return currentPlace();
|
|
876
|
+
}
|
|
877
|
+
saveLocation({
|
|
878
|
+
mapId: from.id, placeId: CONFIG.location && CONFIG.location.placeId || 'home',
|
|
879
|
+
intercity: true, fromMapId: from.id, toMapId: target.id, fromMapName: from.name, toMapName: target.name,
|
|
880
|
+
mode, distanceKm: km, startedAt: Date.now(), durationMs, manual: opts.manual !== false,
|
|
881
|
+
});
|
|
882
|
+
return currentPlace();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ---------- auto wander / life tick ----------
|
|
886
|
+
function smartWeightFactor(p, baseW) {
|
|
887
|
+
const routine = loadRoutine();
|
|
888
|
+
if (!routine.smart) return baseW;
|
|
889
|
+
let w = baseW;
|
|
890
|
+
const st = loadCharState();
|
|
891
|
+
const weather = weatherNow();
|
|
892
|
+
const outdoor = p.id === 'park' || p.id === 'market' || p.id === 'nightmarket';
|
|
893
|
+
const v = loadVisits()[p.id];
|
|
894
|
+
if (v && v.lastAt) { const days = (Date.now() - v.lastAt) / 86400000; w *= 1 + Math.min(0.8, (v.count || 0) * 0.12) * Math.max(0, 1 - days / 7); }
|
|
895
|
+
else w *= 1.1;
|
|
896
|
+
if (weather === 'rain' || weather === 'snow') w *= outdoor ? 0.4 : 1.25;
|
|
897
|
+
if (weather === 'hot') w *= outdoor ? 0.6 : 1.1;
|
|
898
|
+
if (weather === 'cold' || weather === 'windy') w *= outdoor ? 0.75 : 1.05;
|
|
899
|
+
if (st.energy < 35) w *= p.id === 'home' ? 1.8 : (outdoor ? 0.6 : 1);
|
|
900
|
+
const feel = p.feel || '';
|
|
901
|
+
if (/烦|累|吵|不想|贵/.test(feel)) w *= st.energy < 50 ? 0.7 : 0.9;
|
|
902
|
+
if (/舒服|放松|喜欢|安静|香|好/.test(feel)) w *= 1.15;
|
|
903
|
+
try { if (MEM.facts.filter((f) => !f.archived).some((f) => String(f.text || '').includes(p.name))) w *= 1.3; } catch {}
|
|
904
|
+
return Math.max(0, w);
|
|
905
|
+
}
|
|
906
|
+
function autoWanderMaybe(force = false) {
|
|
907
|
+
const life = loadLife();
|
|
908
|
+
if (life.locationFree === false) return currentPlace();
|
|
909
|
+
const cur = currentPlace();
|
|
910
|
+
if (cur.traveling || cur.intercity) return cur;
|
|
911
|
+
const now = new Date();
|
|
912
|
+
const hour = now.getHours() + now.getMinutes() / 60;
|
|
913
|
+
const map = getActiveMap();
|
|
914
|
+
const routine = loadRoutine();
|
|
915
|
+
const band = bandForHour(routine.bands, hour) || { allowed: true, weights: {} };
|
|
916
|
+
if (band.allowed === false) { if (cur.placeId === 'home') return cur; return moveTo('home', { manual: false, confirmNight: true }); }
|
|
917
|
+
if (!force) { if (cur.minutes < 40) return cur; if (Math.random() > 0.35) return cur; }
|
|
918
|
+
const sched = scheduleHint(now);
|
|
919
|
+
const st = loadCharState();
|
|
920
|
+
const weights = map.places.map((p) => {
|
|
921
|
+
let w = band.weights[p.id] !== undefined ? band.weights[p.id] : 0;
|
|
922
|
+
if (sched && sched.place === p.id) w *= 1.8;
|
|
923
|
+
if (p.id === cur.placeId) w *= 0.25;
|
|
924
|
+
w = smartWeightFactor(p, w);
|
|
925
|
+
if (loadLife().smartLife) {
|
|
926
|
+
const kind = weatherNow();
|
|
927
|
+
if (p.id === 'park' || p.id === 'market') { if (kind === 'rain' || kind === 'snow') w *= 0.35; else if (kind === 'hot' || kind === 'cold' || kind === 'windy') w *= 0.7; }
|
|
928
|
+
if (st.energy < 20 && p.id !== 'home') w *= 0.5;
|
|
929
|
+
if (st.energy < 20 && p.id === 'home') w *= 1.8;
|
|
930
|
+
}
|
|
931
|
+
try { const m = walletMoney(); if (m.econ.enabled && m.spendable < m.econ.entertainmentCost && ['cinema', 'netcafe', 'mall', 'nightmarket'].includes(p.id)) w *= 0.3; } catch {}
|
|
932
|
+
return Math.max(0, w);
|
|
933
|
+
});
|
|
934
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
935
|
+
if (total <= 0) return cur;
|
|
936
|
+
let r = Math.random() * total;
|
|
937
|
+
let next = map.places[0];
|
|
938
|
+
for (let i = 0; i < map.places.length; i++) { r -= weights[i]; if (r <= 0) { next = map.places[i]; break; } }
|
|
939
|
+
if (next.id === cur.placeId) return cur;
|
|
940
|
+
return moveTo(next.id, { manual: false, confirmNight: true });
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ---------- LLM ----------
|
|
944
|
+
let typing = false, busy = false, lastTurnAt = 0, lastUsage = null;
|
|
945
|
+
const streamClients = new Set();
|
|
946
|
+
const streamState = { active: false, text: '' };
|
|
947
|
+
function broadcast(obj) { for (const res of streamClients) { try { res.write('data: ' + JSON.stringify(obj) + '\n\n'); } catch {} } }
|
|
948
|
+
async function streamText(text) {
|
|
949
|
+
streamState.active = true; streamState.text = '';
|
|
950
|
+
broadcast({ type: 'start' });
|
|
951
|
+
const s = herText(text);
|
|
952
|
+
for (let i = 0; i < s.length; i += 3) {
|
|
953
|
+
streamState.text += s.slice(i, i + 3);
|
|
954
|
+
broadcast({ type: 'delta', text: s.slice(i, i + 3) });
|
|
955
|
+
await new Promise((r) => setTimeout(r, 14));
|
|
956
|
+
}
|
|
957
|
+
broadcast({ type: 'done' });
|
|
958
|
+
streamState.active = false; streamState.text = '';
|
|
959
|
+
}
|
|
960
|
+
function apiKey() {
|
|
961
|
+
const direct = process.env.DEEPSEEK_API_KEY;
|
|
962
|
+
if (direct) return direct;
|
|
963
|
+
const files = [path.join(HOME, '.dsh', 'desktop', 'deepseek-api-key'), path.join(HOME, '.dsh', 'llm-deepseek', 'api-key'), path.join(HOME, '.dsh', 'deepseek-api-key'), path.join(ROOT, 'api-key.txt')];
|
|
964
|
+
for (const f of files) { try { const s = fs.readFileSync(f, 'utf8').trim(); if (s) return s; } catch {} }
|
|
965
|
+
try { const y = fs.readFileSync(path.join(HOME, '.dsh', '.credentials.yaml'), 'utf8'); const m = y.match(/(?:deepseek[_-]?api[_-]?key|DEEPSEEK_API_KEY)\s*:\s*(\S+)/i); if (m) return m[1].trim(); } catch {}
|
|
966
|
+
throw new Error('没有找到 DeepSeek API Key');
|
|
967
|
+
}
|
|
968
|
+
async function chatCompletion(body) {
|
|
969
|
+
const m = loadModelCfg();
|
|
970
|
+
const payload = { ...body, model: body.model || m.name };
|
|
971
|
+
if (payload.reasoning_effort === undefined) payload.reasoning_effort = m.reasoningEffort;
|
|
972
|
+
if (payload.max_tokens === undefined) payload.max_tokens = m.maxTokens;
|
|
973
|
+
if (payload.temperature === undefined) payload.temperature = m.temperature;
|
|
974
|
+
const res = await fetch('https://api.deepseek.com/chat/completions', {
|
|
975
|
+
method: 'POST',
|
|
976
|
+
headers: { authorization: 'Bearer ' + apiKey(), 'content-type': 'application/json' },
|
|
977
|
+
body: JSON.stringify(payload),
|
|
978
|
+
signal: AbortSignal.timeout(180000),
|
|
979
|
+
});
|
|
980
|
+
const text = await res.text();
|
|
981
|
+
if (!res.ok) throw new Error('deepseek HTTP ' + res.status + ': ' + text.slice(0, 300));
|
|
982
|
+
const j = JSON.parse(text);
|
|
983
|
+
return j;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ---------- 外部执行环境:pc / space / phone ----------
|
|
987
|
+
const ENV_DEFAULT_DIR = 'D:\\dsh-env';
|
|
988
|
+
let ENV_DIR = process.env.DSH_COMPANION_ENV ? process.env.DSH_COMPANION_ENV : (CONFIG.envDir ? String(CONFIG.envDir) : ENV_DEFAULT_DIR);
|
|
989
|
+
ENV_DIR = path.resolve(String(ENV_DIR || ENV_DEFAULT_DIR));
|
|
990
|
+
let ENV_PY = process.env.DSH_COMPANION_PY ? process.env.DSH_COMPANION_PY : path.join(ENV_DIR, 'venv-tools', 'Scripts', 'python.exe');
|
|
991
|
+
// 如果 D:\dsh-env 不存在,尝试用随包附带的 env 目录
|
|
992
|
+
try {
|
|
993
|
+
if (!fs.existsSync(path.join(ENV_DIR, 'space_ctl.py'))) {
|
|
994
|
+
const bundled = path.join(RUNTIME_DIR, 'env');
|
|
995
|
+
if (fs.existsSync(path.join(bundled, 'space_ctl.py'))) ENV_DIR = bundled;
|
|
996
|
+
}
|
|
997
|
+
} catch {}
|
|
998
|
+
try {
|
|
999
|
+
if (!fs.existsSync(ENV_PY)) {
|
|
1000
|
+
const pyCands = [
|
|
1001
|
+
process.env.DSH_COMPANION_PY,
|
|
1002
|
+
path.join(ENV_DIR, 'venv-tools', 'Scripts', 'python.exe'),
|
|
1003
|
+
path.join(RUNTIME_DIR, 'env', 'venv-tools', 'Scripts', 'python.exe'),
|
|
1004
|
+
'python',
|
|
1005
|
+
];
|
|
1006
|
+
for (const c of pyCands) {
|
|
1007
|
+
if (!c) continue;
|
|
1008
|
+
if (c === 'python' || fs.existsSync(c)) { ENV_PY = c; break; }
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
} catch {}
|
|
1012
|
+
const KERNEL_STATE = path.join(HOME, '.dsh', 'desktop', 'kernel-state.json');
|
|
1013
|
+
function pluginRootDir() { return path.resolve(RUNTIME_DIR, '..'); }
|
|
1014
|
+
function backupScriptPath() {
|
|
1015
|
+
const cands = [
|
|
1016
|
+
process.env.DSH_COMPANION_BACKUP,
|
|
1017
|
+
path.join(pluginRootDir(), 'scripts', 'backup.ps1'),
|
|
1018
|
+
];
|
|
1019
|
+
for (const c of cands) { if (c && fs.existsSync(c)) return c; }
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
async function runBackup() {
|
|
1023
|
+
const script = backupScriptPath();
|
|
1024
|
+
if (!script) return { ok: false, error: '找不到 scripts/backup.ps1(安装包缺少 scripts 目录)' };
|
|
1025
|
+
try {
|
|
1026
|
+
const { execFile } = await import('node:child_process');
|
|
1027
|
+
const outRoot = path.join(ROOT, 'backups');
|
|
1028
|
+
await new Promise((resolve) => {
|
|
1029
|
+
execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-PluginDir', pluginRootDir(), '-DataRoot', ROOT, '-OutRoot', outRoot],
|
|
1030
|
+
{ timeout: 300000, windowsHide: true }, (e, out) => {
|
|
1031
|
+
const m = String(out || '').match(/backup-\d{8}-\d{6}/);
|
|
1032
|
+
console.log('[companion] backup:', m ? m[0] : (e ? 'failed' : 'done'));
|
|
1033
|
+
resolve();
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
return { ok: true, outRoot };
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
return { ok: false, error: String(e.message) };
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function probeTcp(port, timeout = 800) {
|
|
1042
|
+
return new Promise((resolve) => {
|
|
1043
|
+
const s = net.connect({ host: '127.0.0.1', port }, () => { s.end(); resolve(true); });
|
|
1044
|
+
s.setTimeout(timeout); s.on('timeout', () => { s.destroy(); resolve(false); }); s.on('error', () => resolve(false));
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
function energyWord(e) {
|
|
1048
|
+
const n = Number(e) || 0;
|
|
1049
|
+
if (n >= 80) return '精力很足';
|
|
1050
|
+
if (n >= 55) return '状态不错';
|
|
1051
|
+
if (n >= 35) return '有点累';
|
|
1052
|
+
if (n >= 20) return '挺累的';
|
|
1053
|
+
return '快撑不住了';
|
|
1054
|
+
}
|
|
1055
|
+
let memCache = { at: 0, freeMB: 99999 };
|
|
1056
|
+
function freeMemMB() {
|
|
1057
|
+
return new Promise((resolve) => {
|
|
1058
|
+
if (Date.now() - memCache.at < 5000) { resolve(memCache.freeMB); return; }
|
|
1059
|
+
import('node:child_process').then(({ execFile }) => {
|
|
1060
|
+
execFile('powershell.exe', ['-NoProfile', '-Command', '[math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1024)'], { timeout: 8000, windowsHide: true }, (e, out) => {
|
|
1061
|
+
const v = Number(String(out || '').trim());
|
|
1062
|
+
if (Number.isFinite(v) && v > 0) { memCache = { at: Date.now(), freeMB: v }; resolve(v); } else resolve(99999);
|
|
1063
|
+
});
|
|
1064
|
+
}).catch(() => resolve(99999));
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
function adbExe() {
|
|
1068
|
+
const cands = [
|
|
1069
|
+
process.env.DSH_COMPANION_ADB,
|
|
1070
|
+
path.join(ENV_DIR, 'android-sdk', 'platform-tools', 'adb.exe'),
|
|
1071
|
+
path.join(RUNTIME_DIR, 'env', 'android-sdk', 'platform-tools', 'adb.exe'),
|
|
1072
|
+
];
|
|
1073
|
+
for (const c of cands) { try { if (c && fs.existsSync(c)) return c; } catch {} }
|
|
1074
|
+
return 'adb';
|
|
1075
|
+
}
|
|
1076
|
+
function runAdb(args, timeout = 30000) {
|
|
1077
|
+
return new Promise((resolve) => {
|
|
1078
|
+
import('node:child_process').then(({ execFile }) => {
|
|
1079
|
+
try {
|
|
1080
|
+
execFile(adbExe(), args, { windowsHide: true, timeout, maxBuffer: 4 * 1024 * 1024 }, (e, out, err) => resolve({ ok: !e, out: String((out || '') + (err || '')) }));
|
|
1081
|
+
} catch (e) { resolve({ ok: false, out: String(e.message) }); }
|
|
1082
|
+
}).catch((e) => resolve({ ok: false, out: String(e.message) }));
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
function adbDevicePresent() {
|
|
1086
|
+
return new Promise((resolve) => {
|
|
1087
|
+
import('node:child_process').then(({ execFile }) => {
|
|
1088
|
+
runAdb(['devices'], 8000).then((r) => resolve(r.out.includes('\tdevice')));
|
|
1089
|
+
}).catch(() => resolve(false));
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
let resourcesCache = { at: 0, data: null };
|
|
1093
|
+
async function resourcesInfo() {
|
|
1094
|
+
if (resourcesCache.data && Date.now() - resourcesCache.at < 8000) return resourcesCache.data;
|
|
1095
|
+
const space = await vmRunningNow();
|
|
1096
|
+
const phone = await adbDevicePresent();
|
|
1097
|
+
let mem = [];
|
|
1098
|
+
try {
|
|
1099
|
+
const { execFile } = await import('node:child_process');
|
|
1100
|
+
const out = await new Promise((resolve) => {
|
|
1101
|
+
execFile('powershell.exe', ['-NoProfile', '-Command', "Get-Process VBoxHeadless,emulator,qemu-system-x86_64 -ErrorAction SilentlyContinue | ForEach-Object { $_.ProcessName + ':' + [math]::Round($_.WorkingSet64/1MB) }"], { timeout: 8000, windowsHide: true }, (e, o) => resolve(String(o || '').trim()));
|
|
1102
|
+
});
|
|
1103
|
+
mem = out.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
1104
|
+
} catch {}
|
|
1105
|
+
const data = { space, phone, mem };
|
|
1106
|
+
resourcesCache = { at: Date.now(), data };
|
|
1107
|
+
return data;
|
|
1108
|
+
}
|
|
1109
|
+
async function vmRunningNow() {
|
|
1110
|
+
try {
|
|
1111
|
+
const { execFile } = await import('node:child_process');
|
|
1112
|
+
return await new Promise((resolve) => {
|
|
1113
|
+
execFile('C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe', ['list', 'runningvms'], { timeout: 8000, windowsHide: true }, (e, out) => resolve(!e && String(out || '').includes('DSH-Space')));
|
|
1114
|
+
});
|
|
1115
|
+
} catch { return false; }
|
|
1116
|
+
}
|
|
1117
|
+
function kernelState() { try { return JSON.parse(fs.readFileSync(KERNEL_STATE, 'utf8')); } catch { return null; } }
|
|
1118
|
+
function kernelBase() { const s = kernelState(); if (!s || !s.port) throw new Error('桌面内核没在运行'); return 'http://127.0.0.1:' + s.port + '/api/dsh'; }
|
|
1119
|
+
function kernelOwner() { return createHash('sha256').update('dsh-screen-beyond:' + String(ACTIVE_CHAR || 'default')).digest('hex'); }
|
|
1120
|
+
async function kernelCall(p, body) {
|
|
1121
|
+
const st = kernelState();
|
|
1122
|
+
if (!st || !st.token) throw new Error('桌面内核没在运行(缺少 kernel-state.json)');
|
|
1123
|
+
const url = 'http://127.0.0.1:' + st.port + '/api/dsh' + p;
|
|
1124
|
+
const res = await fetch(url, {
|
|
1125
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
1126
|
+
headers: { authorization: 'Bearer ' + st.token, 'x-dsh-owner': kernelOwner(), 'content-type': 'application/json' },
|
|
1127
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1128
|
+
signal: AbortSignal.timeout(180000),
|
|
1129
|
+
});
|
|
1130
|
+
const txt = await res.text();
|
|
1131
|
+
if (!res.ok) throw new Error('桌面内核 HTTP ' + res.status + ': ' + txt.slice(0, 200));
|
|
1132
|
+
try { return JSON.parse(txt); } catch { return { text: txt }; }
|
|
1133
|
+
}
|
|
1134
|
+
function runPy(script, args, timeoutMs = 180000) {
|
|
1135
|
+
return new Promise((resolve) => {
|
|
1136
|
+
import('node:child_process').then(({ execFile }) => {
|
|
1137
|
+
execFile(ENV_PY, [path.join(ENV_DIR, script), ...args.map(String)], { timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1138
|
+
resolve({ ok: !err, out: String((stdout || '') + (stderr || '')).slice(0, 4000), err: err ? String(err.message).slice(0, 300) : '' });
|
|
1139
|
+
});
|
|
1140
|
+
}).catch((e) => resolve({ ok: false, out: '', err: String(e.message) }));
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
function toolImage(p, mime) {
|
|
1144
|
+
try { const b = fs.readFileSync(p); if (b.length > 4 * 1024 * 1024) return null; return { path: p, dataUrl: 'data:' + (mime || 'image/jpeg') + ';base64,' + b.toString('base64') }; } catch { return null; }
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const TOOLS = [
|
|
1148
|
+
{ type: 'function', function: { name: 'send_message', description: '给用户发一条消息。想说话就调用,一次一条,说完就停。', parameters: { type: 'object', properties: { text: { type: 'string', description: '要说的话' } }, required: ['text'] } } },
|
|
1149
|
+
{ type: 'function', function: { name: 'read_diary', description: '读你自己某天的日记。date 形如 2026-11-10,不填就是今天。', parameters: { type: 'object', properties: { date: { type: 'string' } } } } },
|
|
1150
|
+
{ type: 'function', function: { name: 'remember', description: '记住一件关于用户的事(事实、约定、喜好)。', parameters: { type: 'object', properties: { text: { type: 'string' }, pinned: { type: 'boolean' } }, required: ['text'] } } },
|
|
1151
|
+
{ type: 'function', function: { name: 'spend_money', description: '花自己的钱买东西/娱乐(电影、游戏、零食、衣服等)。会检查余额,钱不够会被拒绝。', parameters: { type: 'object', properties: { amount: { type: 'number' }, item: { type: 'string' } }, required: ['amount'] } } },
|
|
1152
|
+
{ type: 'function', function: { name: 'code', description: '执行命令行(需要对应环境的「代码控制」权限)。target=pc/space/phone,command 为命令。', parameters: { type: 'object', properties: { target: { type: 'string', enum: ['pc', 'space', 'phone'] }, command: { type: 'string' } }, required: ['target', 'command'] } } },
|
|
1153
|
+
{ type: 'function', function: { name: 'pc', description: '操作用户的 Windows 电脑。look=看屏幕截图;windows=列窗口;focus=切换窗口;click/type/key/scroll=在窗口里操作;drag=拖拽;task=交给桌面代理完成一个操作。坐标是窗口内 0-1000 归一化。', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['look', 'windows', 'focus', 'click', 'type', 'key', 'scroll', 'drag', 'task'] }, window: { type: 'string', description: '窗口标题/窗口id,不填=当前前台' }, x: { type: 'number' }, y: { type: 'number' }, x2: { type: 'number' }, y2: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, clicks: { type: 'number' }, button: { type: 'string', enum: ['left', 'right'] }, task: { type: 'string', description: 'task 模式下要她做的事' } } } } },
|
|
1154
|
+
{ type: 'function', function: { name: 'space', description: '操作她自己的 Linux 桌面(虚拟屏幕)。look=截图;click/dclick/move/drag/type/key=操作(可传 window 指定窗口名先激活,打字务必传,否则会打到别的窗口);batch=一次连续执行多步(steps 数组,省 token,连续点击/输入优先用它);run=执行命令(需要代码权限)。', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['look', 'click', 'dclick', 'move', 'drag', 'type', 'key', 'run', 'res', 'batch'] }, window: { type: 'string', description: '窗口标题片段,如 Firefox / QQ / Terminal' }, x: { type: 'number' }, y: { type: 'number' }, x2: { type: 'number' }, y2: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, command: { type: 'string' }, steps: { type: 'array', description: 'batch 用:一次连续执行多步,每步 {action, x, y, x2, y2, text, key, ms};action 支持 click/dclick/move/drag/type/key/wait', items: { type: 'object', properties: { action: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, x2: { type: 'number' }, y2: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, ms: { type: 'number' } } } } } } } },
|
|
1155
|
+
{ type: 'function', function: { name: 'phone', description: '操作她自己的安卓手机。look=截图;tap/swipe/text=操作;key=按键;info=设备信息;shell=执行命令(需要代码权限)。', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['look', 'tap', 'swipe', 'text', 'key', 'info', 'shell'] }, x: { type: 'number' }, y: { type: 'number' }, x2: { type: 'number' }, y2: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, command: { type: 'string' } } } } },
|
|
1156
|
+
];
|
|
1157
|
+
function buildTools(ctxText) {
|
|
1158
|
+
const env = loadEnv();
|
|
1159
|
+
const saving = loadTokenSaving();
|
|
1160
|
+
let list = TOOLS.filter((t) => {
|
|
1161
|
+
const name = t.function.name;
|
|
1162
|
+
if (name === 'pc') return env.pc !== 'off';
|
|
1163
|
+
if (name === 'space') return env.space !== 'off';
|
|
1164
|
+
if (name === 'phone') return env.phone !== 'off';
|
|
1165
|
+
if (name === 'code') return env.pc === 'code' || env.space === 'code' || env.phone === 'code';
|
|
1166
|
+
return true;
|
|
1167
|
+
});
|
|
1168
|
+
if (!saving.batchActions) {
|
|
1169
|
+
list = list.map((t) => {
|
|
1170
|
+
if (t.function.name !== 'space') return t;
|
|
1171
|
+
const fn = JSON.parse(JSON.stringify(t.function));
|
|
1172
|
+
fn.description = String(fn.description || '').replace(';batch=一次连续执行多步(steps 数组,省 token,连续点击/输入优先用它)', '');
|
|
1173
|
+
if (fn.parameters && fn.parameters.properties) {
|
|
1174
|
+
delete fn.parameters.properties.steps;
|
|
1175
|
+
if (fn.parameters.properties.action && Array.isArray(fn.parameters.properties.action.enum)) fn.parameters.properties.action.enum = fn.parameters.properties.action.enum.filter((a) => a !== 'batch');
|
|
1176
|
+
}
|
|
1177
|
+
return { type: 'function', function: fn };
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
if (saving.dynamicTools) {
|
|
1181
|
+
const s = String(ctxText || '');
|
|
1182
|
+
const wanted = new Set(['send_message']);
|
|
1183
|
+
const KW = [
|
|
1184
|
+
['remember', /(记住|记一下|记下来|别忘了|记得)/],
|
|
1185
|
+
['read_diary', /(日记)/],
|
|
1186
|
+
['spend_money', /(钱|余额|花|买|奶茶|电影|游戏|零食|消费|贵)/],
|
|
1187
|
+
['pc', /(电脑|桌面|窗口|记事本|浏览器|打开|点|打字|搜索|截图|屏幕|任务)/],
|
|
1188
|
+
['space', /(屏幕|桌面|浏览器|b站|bilibili|黑盒|qq|鼠标|点|打开|打字|终端|命令)/i],
|
|
1189
|
+
['phone', /(手机|安卓|app|应用|滑动|点|打字)/i],
|
|
1190
|
+
['code', /(代码|命令|终端|shell|脚本|执行|运行)/i],
|
|
1191
|
+
];
|
|
1192
|
+
for (const [name, re] of KW) { if (re.test(s)) wanted.add(name); }
|
|
1193
|
+
list = list.filter((t) => wanted.has(t.function.name));
|
|
1194
|
+
}
|
|
1195
|
+
return list;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function dayPhaseLabel() {
|
|
1199
|
+
const h = new Date().getHours();
|
|
1200
|
+
if (h >= 5 && h < 7) return '清晨';
|
|
1201
|
+
if (h >= 7 && h < 11) return '上午';
|
|
1202
|
+
if (h >= 11 && h < 13) return '中午';
|
|
1203
|
+
if (h >= 13 && h < 17) return '下午';
|
|
1204
|
+
if (h >= 17 && h < 19.5) return '傍晚';
|
|
1205
|
+
if (h >= 19.5 && h < 23) return '晚上';
|
|
1206
|
+
return '深夜';
|
|
1207
|
+
}
|
|
1208
|
+
function buildSystemPrompt(userText) {
|
|
1209
|
+
const meta = loadMeta();
|
|
1210
|
+
const caps = charCapabilities();
|
|
1211
|
+
const st = loadCharState();
|
|
1212
|
+
const cur = currentPlace();
|
|
1213
|
+
const w = WEATHERS[weatherNow()] || WEATHERS.sunny;
|
|
1214
|
+
const life = loadLife();
|
|
1215
|
+
const now = new Date();
|
|
1216
|
+
const hm = pad2(now.getHours()) + ':' + pad2(now.getMinutes());
|
|
1217
|
+
const parts = [];
|
|
1218
|
+
parts.push(loadPersonaBody().trim() || PERSONA_DEFAULT);
|
|
1219
|
+
const traits = Object.entries(meta.traits || {}).filter(([, v]) => Number(v) > 0).map(([k, v]) => `${k} ${v}/10`);
|
|
1220
|
+
if (traits.length) parts.push('## 你的性格数值\n' + traits.join('、'));
|
|
1221
|
+
parts.push('## 你和用户\n你叫' + (meta.name || '大肥鱼') + ',称呼用户「' + (meta.userCall || '你') + '」,你们的关系是:' + (meta.relation || '住在用户电脑里的同伴') + '。');
|
|
1222
|
+
if (Array.isArray(meta.examples) && meta.examples.length) parts.push('## 你平时说话的样子(只学风格,不要照抄)\n' + meta.examples.map((e) => '- ' + e).join('\n'));
|
|
1223
|
+
parts.push('## 你的情况\n' + (caps.age !== null ? `你${caps.age}岁${caps.minor ? '(未成年)' : ''}。` : '') + (caps.license ? '你有驾照。' : '你没有驾照,开不了车。') + `今天是${todayStr()},现在是 ${hm}(${dayPhaseLabel()})。`);
|
|
1224
|
+
parts.push(`天气:${w.icon}${w.label}` + (CONFIG.weatherMode === 'real' && weatherCache.data ? `,${weatherCache.data.temp !== null ? weatherCache.data.temp + '°C' : ''}${weatherCache.data.city ? '(' + weatherCache.data.city + ')' : ''}` : '') + `。你现在的状态:${energyWord(st.energy)}(你不会知道具体数值,只能凭感觉说累不累)。`);
|
|
1225
|
+
try { const m = walletMoney(); const e = m.econ; if (e.enabled) parts.push(m.tokenMode ? `你的钱就是剩下的 token:约 ${m.remainingTokens} token(≈${m.spendable} 元)。坐车、买东西都会扣 token;你舍不得,能走路就走路,会念叨"太费 token 了"。` : `你手里有 ${m.spendable} 元(虚拟货币)。每天零花钱 ${e.dailyIncome} 元,工作每小时 ${e.wagePerHour} 元;交通、吃饭、娱乐都要花钱。`); } catch {}
|
|
1226
|
+
parts.push(`你现在在「${cur.traveling ? cur.name + '的路上' : cur.name}」(${cur.placeName || cur.name || ''})` + (cur.traveling ? `,正从${cur.traveling.fromName}过去,还有约${Math.ceil(cur.traveling.etaSec / 60)}分钟` : cur.intercity ? `,正在跨城路上` : `,已经待了约${cur.minutes || 0}分钟`));
|
|
1227
|
+
if (cur.desc) parts.push('这个地方:' + cur.desc);
|
|
1228
|
+
if (cur.feel) parts.push('你对这里的感受:' + cur.feel);
|
|
1229
|
+
const hint = scheduleHint(now);
|
|
1230
|
+
if (hint) parts.push(`按你的作息,现在大概是:${hint.label}。`);
|
|
1231
|
+
else if (life.activities && life.activities.length) {
|
|
1232
|
+
const acts = life.activities.filter((a) => !a.window || isInWindow(a.window, now)).map((a) => a.name);
|
|
1233
|
+
if (acts.length) parts.push(`这个时间你可能在做:${acts.join('、')}。`);
|
|
1234
|
+
}
|
|
1235
|
+
try { const memBlock = MEM.promptBlock(userText + ' ' + (summary.text || '')); if (memBlock) parts.push(memBlock); } catch {}
|
|
1236
|
+
if (summary.text) parts.push('## 你们之前聊过的(摘要)\n' + summary.text);
|
|
1237
|
+
const mp = getActiveMap();
|
|
1238
|
+
const feels = mp.places.slice(0, 10).map((p) => p.name + (p.feel ? '(' + p.feel + ')' : '')).join('、');
|
|
1239
|
+
if (feels) parts.push('## 你对小镇这些地方的印象\n' + feels);
|
|
1240
|
+
const env = loadEnv();
|
|
1241
|
+
const envTools = [];
|
|
1242
|
+
if (env.pc !== 'off') envTools.push('pc(看/操作用户电脑:look 截图、windows 列窗口、click/type/key 操作、task 交办)');
|
|
1243
|
+
if (env.space !== 'off') envTools.push('space(她自己的 Linux 桌面:look/click/type/run)');
|
|
1244
|
+
if (env.phone !== 'off') envTools.push('phone(她自己的安卓手机:look/tap/swipe/key)');
|
|
1245
|
+
parts.push('## 聊天的要求\n像真人聊天:短句、口语、不列提纲、不总结、不重复客套。\n想说话就用 send_message;不需要每条都回。不要机械报状态数据;可以自然提到天气/心情/钱。\n不要说体力、血条、数值这类东西——真人不知道自己的体力数字,只能感觉累不累、困不困。\n如果用户让你记住什么,用 remember;想读日记用 read_diary;想花钱娱乐用 spend_money。\n用户可能发图片,你能看到。');
|
|
1246
|
+
if (loadChatStyle().noParens) parts.push('## 说话方式\n不要用括号表示动作、表情、心理活动或旁白(不要写“(脸红了)”“(叹气)”这种),也不要用 *动作* 的写法;只直接说话。');
|
|
1247
|
+
if (loadTokenSaving().batchActions) parts.push('## 省 token 小提示\n连续操作(连续点击、点完再输入)优先用 space 的 action=batch + steps 一次提交,别一步一步点——每次单独调用都要重发上下文,很费 token。');
|
|
1248
|
+
if (envTools.length) {
|
|
1249
|
+
parts.push('## 你能做的事\n' + envTools.join('\n'));
|
|
1250
|
+
parts.push(env.selfStart === 'allow'
|
|
1251
|
+
? '用户允许你自己开屏幕/手机:如果发现没开,你可以自己启动(会占内存,别乱开)。'
|
|
1252
|
+
: '用户没允许你自己开机:屏幕/手机没开时不要自己启动,先跟用户说一声让他开。');
|
|
1253
|
+
}
|
|
1254
|
+
return parts.join('\n\n');
|
|
1255
|
+
}
|
|
1256
|
+
function buildUserTurnText(text, image) {
|
|
1257
|
+
if (!image) return text;
|
|
1258
|
+
return (text ? text + '\n' : '') + '(对方发来了一张图片,见下一条消息)';
|
|
1259
|
+
}
|
|
1260
|
+
function imageDataUrl(rel) {
|
|
1261
|
+
try {
|
|
1262
|
+
const p = path.join(ROOT, String(rel || '').replace(/^\//, ''));
|
|
1263
|
+
const b = fs.readFileSync(p);
|
|
1264
|
+
if (b.length > 8 * 1024 * 1024) return null;
|
|
1265
|
+
const mime = b[0] === 0xFF && b[1] === 0xD8 ? 'image/jpeg' : (b[0] === 0x52 ? 'image/webp' : 'image/png');
|
|
1266
|
+
return 'data:' + mime + ';base64,' + b.toString('base64');
|
|
1267
|
+
} catch { return null; }
|
|
1268
|
+
}
|
|
1269
|
+
function buildMessages(userText) {
|
|
1270
|
+
const ctx = loadContext();
|
|
1271
|
+
const limit = ctx.historyMode === 'short' ? 8 : 30;
|
|
1272
|
+
const recent = messages.filter((m) => m.role === 'user' || m.role === 'her').slice(-limit);
|
|
1273
|
+
const conv = recent.map((m) => {
|
|
1274
|
+
const role = m.role === 'her' ? 'assistant' : 'user';
|
|
1275
|
+
if (m.image) {
|
|
1276
|
+
const du = imageDataUrl(m.image);
|
|
1277
|
+
if (du) return { role, content: [{ type: 'text', text: m.text || '(发来一张图片)' }, { type: 'image_url', image_url: { url: du } }] };
|
|
1278
|
+
}
|
|
1279
|
+
return { role, content: m.text };
|
|
1280
|
+
});
|
|
1281
|
+
if (!conv.length || conv[conv.length - 1].role !== 'user') {
|
|
1282
|
+
conv.push({ role: 'user', content: userText });
|
|
1283
|
+
}
|
|
1284
|
+
return [{ role: 'system', content: buildSystemPrompt(userText) }, ...conv];
|
|
1285
|
+
}
|
|
1286
|
+
function ffmpegPath() {
|
|
1287
|
+
const cands = [process.env.DSH_COMPANION_FFMPEG, 'D:\\应用程序\\oopz\\oopz\\ffmpeg.exe', path.join(ENV_DIR, 'ffmpeg.exe')];
|
|
1288
|
+
for (const c of cands) { try { if (c && fs.existsSync(c)) return c; } catch {} }
|
|
1289
|
+
return null;
|
|
1290
|
+
}
|
|
1291
|
+
async function compressImageDataUrl(dataUrl) {
|
|
1292
|
+
try {
|
|
1293
|
+
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) return dataUrl;
|
|
1294
|
+
const m = dataUrl.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/);
|
|
1295
|
+
if (!m) return dataUrl;
|
|
1296
|
+
const buf = Buffer.from(m[2], 'base64');
|
|
1297
|
+
if (buf.length < 100 * 1024) return dataUrl;
|
|
1298
|
+
const ff = ffmpegPath();
|
|
1299
|
+
if (!ff) return dataUrl;
|
|
1300
|
+
const dir = path.join(ROOT, 'data', 'tmp');
|
|
1301
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1302
|
+
const ext = m[1].toLowerCase() === 'png' ? 'png' : 'jpg';
|
|
1303
|
+
const inP = path.join(dir, 'saving_in.' + ext);
|
|
1304
|
+
const outP = path.join(dir, 'saving_out.jpg');
|
|
1305
|
+
fs.writeFileSync(inP, buf);
|
|
1306
|
+
const { execFile } = await import('node:child_process');
|
|
1307
|
+
await new Promise((resolve, reject) => {
|
|
1308
|
+
execFile(ff, ['-y', '-i', inP, '-vf', "scale='min(720,iw)':-2", '-q:v', '6', outP], { timeout: 15000, windowsHide: true }, (e) => (e ? reject(e) : resolve()));
|
|
1309
|
+
});
|
|
1310
|
+
if (!fs.existsSync(outP)) return dataUrl;
|
|
1311
|
+
const out = fs.readFileSync(outP);
|
|
1312
|
+
if (out.length >= buf.length) return dataUrl;
|
|
1313
|
+
return 'data:image/jpeg;base64,' + out.toString('base64');
|
|
1314
|
+
} catch { return dataUrl; }
|
|
1315
|
+
}
|
|
1316
|
+
async function resolveWindowId(label) {
|
|
1317
|
+
const list = await kernelCall('/windows');
|
|
1318
|
+
const wins = (list && list.windows) || [];
|
|
1319
|
+
if (!wins.length) return null;
|
|
1320
|
+
let hit = wins[0];
|
|
1321
|
+
if (label) {
|
|
1322
|
+
const s = String(label).toLowerCase();
|
|
1323
|
+
hit = wins.find((w) => String(w.title || '').toLowerCase().includes(s) || String(w.window_key).toLowerCase().includes(s) || String(w.hwnd) === s) || wins[0];
|
|
1324
|
+
}
|
|
1325
|
+
if (hit.window_id) return hit.window_id;
|
|
1326
|
+
const b = await kernelCall('/windows/bind', { hwnd: hit.hwnd, window_key: hit.window_key });
|
|
1327
|
+
return (b && (b.window_id || (b.window && b.window.window_id))) || null;
|
|
1328
|
+
}
|
|
1329
|
+
function runShell(command, timeoutMs = 120000) {
|
|
1330
|
+
return new Promise((resolve) => {
|
|
1331
|
+
import('node:child_process').then(({ execFile }) => {
|
|
1332
|
+
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', String(command)], { timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1333
|
+
resolve({ ok: !err, out: String((stdout || '') + (stderr || '') || (err && err.message) || '').slice(0, 3000) });
|
|
1334
|
+
});
|
|
1335
|
+
}).catch((e) => resolve({ ok: false, out: String(e.message) }));
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
function isDangerousCommand(text) {
|
|
1339
|
+
const s = String(text || '');
|
|
1340
|
+
const re = /(rm\s+-rf|del\s+\/[fqs]|format\s+[a-z]:|shutdown|stop-computer|taskkill|mkfs|dd\s+if=|curl[^|]*\|\s*(ba)?sh|wget[^|]*\|\s*(ba)?sh|reg\s+delete|takeown|icacls[^\n]*\/reset|git\s+push[^\n]*--force|drop\s+(database|table)|truncate\s+table|删除|卸载|关机|格式化|转账|清空)/i;
|
|
1341
|
+
return re.test(s);
|
|
1342
|
+
}
|
|
1343
|
+
async function runCodeTool(args) {
|
|
1344
|
+
const env = loadEnv();
|
|
1345
|
+
const target = String(args.target || '').toLowerCase();
|
|
1346
|
+
const command = String(args.command || '');
|
|
1347
|
+
if (!['pc', 'space', 'phone'].includes(target)) return { text: 'target 只能是 pc/space/phone' };
|
|
1348
|
+
if (!command.trim()) return { text: '命令为空' };
|
|
1349
|
+
if (env.confirmDanger !== false && isDangerousCommand(command)) {
|
|
1350
|
+
return { text: '⚠️ 这条命令有风险(可能删除/影响系统或数据),我先不执行。\n要执行的话,请让用户自己确认,或到 设置→环境 临时关掉「危险操作确认」。' };
|
|
1351
|
+
}
|
|
1352
|
+
if (env[target] !== 'code') return { text: '没有开启「' + target + '」的代码控制权限' };
|
|
1353
|
+
if (target === 'pc') {
|
|
1354
|
+
const r = await runShell(command, 120000);
|
|
1355
|
+
return { text: '命令输出(' + (r.ok ? '成功' : '失败') + '):\n' + r.out.slice(0, 2500) };
|
|
1356
|
+
}
|
|
1357
|
+
if (target === 'space') {
|
|
1358
|
+
const r = await runPy('space_ctl.py', ['run', command], 180000);
|
|
1359
|
+
return { text: '命令输出:\n' + String(r.out || '').slice(0, 2500) };
|
|
1360
|
+
}
|
|
1361
|
+
const r = await runPy('phone_ctl.py', ['shell', command], 180000);
|
|
1362
|
+
return { text: '命令输出:\n' + String(r.out || '').slice(0, 2500) };
|
|
1363
|
+
}
|
|
1364
|
+
async function runPcTool(args) {
|
|
1365
|
+
const env = loadEnv();
|
|
1366
|
+
const action = String(args.action || 'look');
|
|
1367
|
+
if (env.pc === 'off') return { text: '用户关掉了电脑操作权限。' };
|
|
1368
|
+
if (action === 'task' && env.confirmDanger !== false && isDangerousCommand(args.task)) {
|
|
1369
|
+
return { text: '⚠️ 这个桌面任务有风险(可能删除/影响系统),我先不做。请让用户自己确认,或临时关掉「危险操作确认」。' };
|
|
1370
|
+
}
|
|
1371
|
+
if (action === 'windows') {
|
|
1372
|
+
const r = await kernelCall('/windows');
|
|
1373
|
+
const wins = (r.windows || []).slice(0, 15).map((w) => '- ' + (w.title || '(无标题)') + ' [' + w.exe + '] id=' + w.hwnd);
|
|
1374
|
+
return { text: wins.length ? '当前窗口:\n' + wins.join('\n') : '没有窗口。' };
|
|
1375
|
+
}
|
|
1376
|
+
if (action === 'look') {
|
|
1377
|
+
const r = await kernelCall('/desktop/capture', { analyze: true });
|
|
1378
|
+
const fg = r.foreground || {};
|
|
1379
|
+
const text = '屏幕已截图' + (fg.title ? ';前台窗口:' + fg.title : '') + (r.hint ? '\n' + r.hint : '');
|
|
1380
|
+
const img = r.image_path ? toolImage(r.image_path) : null;
|
|
1381
|
+
return { text, image: img };
|
|
1382
|
+
}
|
|
1383
|
+
if (action === 'task') {
|
|
1384
|
+
const r = await kernelCall('/tasks', { task: String(args.task || '').slice(0, 2000) });
|
|
1385
|
+
const id = r.task_id || r.id;
|
|
1386
|
+
if (!id) return { text: '任务创建失败:' + JSON.stringify(r).slice(0, 300) };
|
|
1387
|
+
let last = r;
|
|
1388
|
+
for (let i = 0; i < 60; i++) {
|
|
1389
|
+
await new Promise((res) => setTimeout(res, 2000));
|
|
1390
|
+
last = await kernelCall('/tasks/' + encodeURIComponent(id));
|
|
1391
|
+
const stt = String(last.status || last.state || '');
|
|
1392
|
+
if (['finished', 'done', 'completed', 'failed', 'error', 'stopped'].includes(stt)) break;
|
|
1393
|
+
}
|
|
1394
|
+
const text = last.result || last.summary || last.text || JSON.stringify(last).slice(0, 1500);
|
|
1395
|
+
const imgPath = last.image_path || last.screenshot_path;
|
|
1396
|
+
return { text: '桌面任务结束:' + String(text).slice(0, 1500), image: imgPath ? toolImage(imgPath) : null };
|
|
1397
|
+
}
|
|
1398
|
+
const wid = await resolveWindowId(args.window);
|
|
1399
|
+
if (action === 'focus') {
|
|
1400
|
+
if (!wid) return { text: '没找到窗口。' };
|
|
1401
|
+
await kernelCall('/windows/' + encodeURIComponent(wid) + '/activate', {});
|
|
1402
|
+
return { text: '已切到窗口 ' + wid };
|
|
1403
|
+
}
|
|
1404
|
+
if (!wid) return { text: '没找到可用窗口,先用 windows 看一下。' };
|
|
1405
|
+
if (action === 'click') {
|
|
1406
|
+
await kernelCall('/windows/' + encodeURIComponent(wid) + '/click', { x: Number(args.x) || 500, y: Number(args.y) || 500, clicks: Math.max(1, Math.min(3, Number(args.clicks) || 1)), button: args.button === 'right' ? 'right' : 'left' });
|
|
1407
|
+
return { text: '点了 (' + (args.x || 500) + ',' + (args.y || 500) + ')。' };
|
|
1408
|
+
}
|
|
1409
|
+
if (action === 'type') {
|
|
1410
|
+
await kernelCall('/windows/' + encodeURIComponent(wid) + '/type', { text: String(args.text || '').slice(0, 1000) });
|
|
1411
|
+
return { text: '输入完成。' };
|
|
1412
|
+
}
|
|
1413
|
+
if (action === 'key') {
|
|
1414
|
+
await kernelCall('/windows/' + encodeURIComponent(wid) + '/key', { key: String(args.key || 'enter') });
|
|
1415
|
+
return { text: '按键完成:' + args.key };
|
|
1416
|
+
}
|
|
1417
|
+
if (action === 'scroll') {
|
|
1418
|
+
await kernelCall('/windows/' + encodeURIComponent(wid) + '/scroll', { clicks: Number(args.clicks) || -3, x: Number(args.x) || 500, y: Number(args.y) || 500 });
|
|
1419
|
+
return { text: '滚动完成。' };
|
|
1420
|
+
}
|
|
1421
|
+
if (action === 'drag') {
|
|
1422
|
+
await kernelCall('/windows/' + encodeURIComponent(wid) + '/swipe', { x1: Number(args.x) || 200, y1: Number(args.y) || 200, x2: Number(args.x2) || 600, y2: Number(args.y2) || 600 });
|
|
1423
|
+
return { text: '拖拽完成。' };
|
|
1424
|
+
}
|
|
1425
|
+
return { text: '不支持的 pc 动作:' + action };
|
|
1426
|
+
}
|
|
1427
|
+
async function ensureSpaceUp(env) {
|
|
1428
|
+
if (await vmRunningNow()) return { ok: true };
|
|
1429
|
+
if (env.selfStart !== 'allow') return { ok: false, reason: '她自己的屏幕现在没开。用户没允许你自己开机(设置→环境→她自己能开机),先跟用户说一声让他开。' };
|
|
1430
|
+
const free = await freeMemMB();
|
|
1431
|
+
if (free < 9000) return { ok: false, reason: '想开自己的屏幕但内存不够(只剩 ' + free + ' MB),先别开,跟用户说一声。' };
|
|
1432
|
+
try {
|
|
1433
|
+
const { execFile } = await import('node:child_process');
|
|
1434
|
+
execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\dsh-env\\start_screen.ps1'], { timeout: 180000, windowsHide: true }, () => {});
|
|
1435
|
+
} catch {}
|
|
1436
|
+
for (let i = 0; i < 90; i++) { if (await probeTcp(6081)) return { ok: true, started: true }; await new Promise((r) => setTimeout(r, 2000)); }
|
|
1437
|
+
return { ok: false, reason: '自己试着开屏幕,等了 3 分钟没起来,跟用户说一声让他看看。' };
|
|
1438
|
+
}
|
|
1439
|
+
async function runSpaceTool(args) {
|
|
1440
|
+
const env = loadEnv();
|
|
1441
|
+
const action = String(args.action || 'look');
|
|
1442
|
+
if (env.space === 'off') return { text: '用户关掉了虚拟屏幕权限。' };
|
|
1443
|
+
try { if (!fs.existsSync(path.join(ENV_DIR, 'space_ctl.py'))) return { text: '完整版辅助脚本没配好:请把设置里的 envDir(或 DSH_COMPANION_ENV)指向包含 space_ctl.py 的目录,默认是 D:\\dsh-env。纯聊天不受影响。' }; } catch {}
|
|
1444
|
+
const up = await ensureSpaceUp(env);
|
|
1445
|
+
if (!up.ok) return { text: up.reason };
|
|
1446
|
+
if (action === 'look') {
|
|
1447
|
+
const out = path.join(ENV_DIR, 'space_shot.png');
|
|
1448
|
+
const r = await runPy('space_ctl.py', ['shot', out], 60000);
|
|
1449
|
+
if (!r.ok) return { text: '截屏失败:' + (r.out || r.err) };
|
|
1450
|
+
return { text: '她自己的屏幕已截图。', image: toolImage(out) };
|
|
1451
|
+
}
|
|
1452
|
+
const winArg = args.window ? ['--win=' + String(args.window)] : [];
|
|
1453
|
+
let typeArg = [];
|
|
1454
|
+
if (action === 'type') {
|
|
1455
|
+
try {
|
|
1456
|
+
const dir = path.join(ROOT, 'data', 'tmp');
|
|
1457
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1458
|
+
const tf = path.join(dir, 'space_type.txt');
|
|
1459
|
+
fs.writeFileSync(tf, String(args.text || ''), 'utf8');
|
|
1460
|
+
typeArg = ['type', '--text-file=' + tf];
|
|
1461
|
+
} catch { typeArg = ['type', String(args.text || '')]; }
|
|
1462
|
+
}
|
|
1463
|
+
const map = { click: [ 'click', args.x, args.y, ...winArg], dclick: ['dclick', args.x, args.y, ...winArg], move: ['move', args.x, args.y, ...winArg], drag: ['drag', args.x, args.y, args.x2, args.y2, ...winArg], type: [...typeArg, ...winArg], key: ['key', args.key, ...winArg], res: ['res'] };
|
|
1464
|
+
if (action === 'batch') {
|
|
1465
|
+
if (!loadTokenSaving().batchActions) return { text: '批量动作开关没开:设置 → 模型 → 省 token 开关里打开「批量动作」后再用。' };
|
|
1466
|
+
const steps = Array.isArray(args.steps) ? args.steps.slice(0, 30) : [];
|
|
1467
|
+
if (!steps.length) return { text: 'batch 需要 steps 数组(例如 [{action:"click",x:400,y:500},{action:"click",x:700,y:500}])。' };
|
|
1468
|
+
const allowed = new Set(['click', 'dclick', 'move', 'drag', 'type', 'key', 'wait']);
|
|
1469
|
+
const done = [];
|
|
1470
|
+
const dir = path.join(ROOT, 'data', 'tmp');
|
|
1471
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1472
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1473
|
+
const st = steps[i] || {};
|
|
1474
|
+
const a = String(st.action || '');
|
|
1475
|
+
if (!allowed.has(a)) return { text: '第 ' + (i + 1) + ' 步动作不支持:' + a + '(只支持 click/dclick/move/drag/type/key/wait)' };
|
|
1476
|
+
if (a === 'wait') { await new Promise((r) => setTimeout(r, Math.min(10000, Math.max(0, Number(st.ms) || 300)))); done.push('wait'); continue; }
|
|
1477
|
+
let cmdArgs;
|
|
1478
|
+
if (a === 'type') { const tf = path.join(dir, 'space_batch_type.txt'); fs.writeFileSync(tf, String(st.text || ''), 'utf8'); cmdArgs = ['type', '--text-file=' + tf]; }
|
|
1479
|
+
else if (a === 'click') cmdArgs = ['click', st.x, st.y];
|
|
1480
|
+
else if (a === 'dclick') cmdArgs = ['dclick', st.x, st.y];
|
|
1481
|
+
else if (a === 'move') cmdArgs = ['move', st.x, st.y];
|
|
1482
|
+
else if (a === 'drag') cmdArgs = ['drag', st.x, st.y, st.x2, st.y2];
|
|
1483
|
+
else cmdArgs = ['key', st.key];
|
|
1484
|
+
const r = await runPy('space_ctl.py', cmdArgs, 30000);
|
|
1485
|
+
if (!r.ok) return { text: '批量动作第 ' + (i + 1) + ' 步失败(' + a + '):' + String(r.out || r.err || '').slice(0, 300) + ';已完成:' + done.join('、') };
|
|
1486
|
+
done.push(a === 'type' ? 'type' : a + '(' + (st.x ?? '') + ',' + (st.y ?? '') + ')');
|
|
1487
|
+
}
|
|
1488
|
+
return { text: '批量完成 ' + done.length + ' 步:' + done.join(' → ') };
|
|
1489
|
+
}
|
|
1490
|
+
if (action === 'run') {
|
|
1491
|
+
if (env.space !== 'code') return { text: '执行命令需要「代码控制」权限。' };
|
|
1492
|
+
const r = await runPy('space_ctl.py', ['run', String(args.command || 'echo hi')], 120000);
|
|
1493
|
+
return { text: '命令输出:\n' + String(r.out || '').slice(0, 2000) };
|
|
1494
|
+
}
|
|
1495
|
+
if (!map[action]) return { text: '不支持的 space 动作:' + action };
|
|
1496
|
+
const r = await runPy('space_ctl.py', map[action], 60000);
|
|
1497
|
+
return { text: (r.ok ? '完成:' : '失败:') + action + ' ' + String(r.out || r.err || '').slice(0, 500) };
|
|
1498
|
+
}
|
|
1499
|
+
async function ensurePhoneUp(env) {
|
|
1500
|
+
if (await adbDevicePresent()) return { ok: true };
|
|
1501
|
+
if (env.selfStart !== 'allow') return { ok: false, reason: '她自己的手机现在没开。用户没允许你自己开机(设置→环境→她自己能开机),先跟用户说一声让他开。' };
|
|
1502
|
+
const free = await freeMemMB();
|
|
1503
|
+
if (free < 6000) return { ok: false, reason: '想开自己的手机但内存不够(只剩 ' + free + ' MB),先别开,跟用户说一声。' };
|
|
1504
|
+
try {
|
|
1505
|
+
const { execFile } = await import('node:child_process');
|
|
1506
|
+
const sdk = process.env.DSH_COMPANION_ANDROID_SDK || path.join(ENV_DIR, 'android-sdk');
|
|
1507
|
+
const emu = process.env.DSH_COMPANION_EMULATOR || path.join(sdk, 'emulator', 'emulator.exe');
|
|
1508
|
+
const envp = { ...process.env, ANDROID_SDK_ROOT: sdk, ANDROID_HOME: sdk };
|
|
1509
|
+
if (!fs.existsSync(emu)) return { ok: false, reason: '没找到安卓模拟器 emulator.exe(可设置 DSH_COMPANION_EMULATOR)' };
|
|
1510
|
+
execFile(emu, ['-avd', 'DSH_Phone', '-no-window', '-no-audio', '-no-boot-anim', '-gpu', 'swiftshader_indirect', '-no-snapshot-save'], { timeout: 600000, windowsHide: true, env: envp }, () => {});
|
|
1511
|
+
} catch {}
|
|
1512
|
+
for (let i = 0; i < 75; i++) { if (await adbDevicePresent()) return { ok: true, started: true }; await new Promise((r) => setTimeout(r, 2000)); }
|
|
1513
|
+
return { ok: false, reason: '自己试着开手机,等了 2 分半没起来,跟用户说一声让他看看。' };
|
|
1514
|
+
}
|
|
1515
|
+
async function runPhoneTool(args) {
|
|
1516
|
+
const env = loadEnv();
|
|
1517
|
+
const action = String(args.action || 'look');
|
|
1518
|
+
if (env.phone === 'off') return { text: '用户关掉了手机权限。' };
|
|
1519
|
+
try { if (!fs.existsSync(path.join(ENV_DIR, 'phone_ctl.py'))) return { text: '完整版辅助脚本没配好:请把设置的 envDir(或 DSH_COMPANION_ENV)指向包含 phone_ctl.py 的目录,默认是 D:\\dsh-env。纯聊天不受影响。' }; } catch {}
|
|
1520
|
+
const upP = await ensurePhoneUp(env);
|
|
1521
|
+
if (!upP.ok) return { text: upP.reason };
|
|
1522
|
+
if (action === 'look') {
|
|
1523
|
+
const out = path.join(ENV_DIR, 'phone_shot.png');
|
|
1524
|
+
const r = await runPy('phone_ctl.py', ['shot', out], 90000);
|
|
1525
|
+
if (!r.ok) return { text: '手机截图失败(可能模拟器没启动):' + (r.out || r.err) };
|
|
1526
|
+
return { text: '她自己的手机已截图。', image: toolImage(out) };
|
|
1527
|
+
}
|
|
1528
|
+
let phoneTypeArg = ['text', String(args.text || '')];
|
|
1529
|
+
if (action === 'text') {
|
|
1530
|
+
try {
|
|
1531
|
+
const dir = path.join(ROOT, 'data', 'tmp');
|
|
1532
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1533
|
+
const tf = path.join(dir, 'phone_text.txt');
|
|
1534
|
+
fs.writeFileSync(tf, String(args.text || ''), 'utf8');
|
|
1535
|
+
phoneTypeArg = ['text', '--text-file=' + tf];
|
|
1536
|
+
} catch {}
|
|
1537
|
+
}
|
|
1538
|
+
const map = { tap: ['tap', args.x, args.y], swipe: ['swipe', args.x, args.y, args.x2, args.y2], text: phoneTypeArg, key: ['key', args.key], info: ['info'] };
|
|
1539
|
+
if (action === 'shell') {
|
|
1540
|
+
if (env.phone !== 'code') return { text: '执行 adb 命令需要「代码控制」权限。' };
|
|
1541
|
+
const r = await runPy('phone_ctl.py', ['shell', String(args.command || 'echo hi')], 120000);
|
|
1542
|
+
return { text: '命令输出:\n' + String(r.out || '').slice(0, 2000) };
|
|
1543
|
+
}
|
|
1544
|
+
if (!map[action]) return { text: '不支持的 phone 动作:' + action };
|
|
1545
|
+
const r = await runPy('phone_ctl.py', map[action], 90000);
|
|
1546
|
+
return { text: (r.ok ? '完成:' : '失败:') + action + ' ' + String(r.out || r.err || '').slice(0, 500) };
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
async function runTool(name, args) {
|
|
1550
|
+
if (name === 'send_message') {
|
|
1551
|
+
const t2 = String(args.text || '').slice(0, 4000);
|
|
1552
|
+
await streamText(t2);
|
|
1553
|
+
addMessage('her', t2);
|
|
1554
|
+
return { text: '已发送' };
|
|
1555
|
+
}
|
|
1556
|
+
if (name === 'remember') {
|
|
1557
|
+
const text = String(args.text || '').trim().slice(0, 90);
|
|
1558
|
+
if (!text) return { text: '内容为空' };
|
|
1559
|
+
try { MEM.addFacts([{ text, pinned: !!args.pinned }]); MEM.save && MEM.save(); } catch {}
|
|
1560
|
+
return { text: '记住了:' + text };
|
|
1561
|
+
}
|
|
1562
|
+
if (name === 'read_diary') {
|
|
1563
|
+
const date = String(args.date || '').match(/^\d{4}-\d{2}-\d{2}$/) ? args.date : todayStr();
|
|
1564
|
+
const txt = MEM.readDiary(date);
|
|
1565
|
+
return { text: txt ? ('【' + date + '的日记】\n' + txt.slice(0, 2500)) : '那天没有写日记。' };
|
|
1566
|
+
}
|
|
1567
|
+
if (name === 'code') return runCodeTool(args);
|
|
1568
|
+
if (name === 'pc') return runPcTool(args);
|
|
1569
|
+
if (name === 'space') return runSpaceTool(args);
|
|
1570
|
+
if (name === 'phone') return runPhoneTool(args);
|
|
1571
|
+
if (name === 'spend_money') {
|
|
1572
|
+
const amount = Number(args.amount) || 0;
|
|
1573
|
+
const item = String(args.item || '娱乐').slice(0, 40);
|
|
1574
|
+
const r = spendMoney(amount, item);
|
|
1575
|
+
if (!r.ok) return { text: '没花成——' + r.reason + '。我现在只有 ' + r.left + ' 块。' };
|
|
1576
|
+
return { text: '好,花了 ' + amount + ' 块在「' + item + '」上,还剩 ' + r.left + ' 块。' };
|
|
1577
|
+
}
|
|
1578
|
+
return { text: '没有这个工具' };
|
|
1579
|
+
}
|
|
1580
|
+
async function generateReply(userText) {
|
|
1581
|
+
if (typing) return;
|
|
1582
|
+
typing = true; busy = true;
|
|
1583
|
+
try {
|
|
1584
|
+
if (CONFIG.weatherMode === 'real') { try { await getRealWeather(); } catch {} }
|
|
1585
|
+
const convo = buildMessages(userText);
|
|
1586
|
+
let sent = false;
|
|
1587
|
+
const maxSteps = Math.max(4, Math.min(60, Number(loadModelCfg().maxSteps) || 20));
|
|
1588
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
1589
|
+
const resp = await chatCompletion({ messages: convo, tools: buildTools(userText), tool_choice: 'auto' });
|
|
1590
|
+
lastUsage = resp.usage || lastUsage;
|
|
1591
|
+
try { recordUsageTokens(resp.usage); } catch {}
|
|
1592
|
+
lastTurnAt = Date.now();
|
|
1593
|
+
const msg = resp && resp.choices && resp.choices[0] && resp.choices[0].message;
|
|
1594
|
+
if (!msg) break;
|
|
1595
|
+
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
|
|
1596
|
+
const asst = { role: 'assistant', content: msg.content || '', tool_calls: msg.tool_calls };
|
|
1597
|
+
if (msg.reasoning_content) asst.reasoning_content = msg.reasoning_content;
|
|
1598
|
+
convo.push(asst);
|
|
1599
|
+
const toolImages = [];
|
|
1600
|
+
for (const tc of msg.tool_calls) {
|
|
1601
|
+
const name = tc && tc.function ? tc.function.name : '';
|
|
1602
|
+
let args = {};
|
|
1603
|
+
try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
|
|
1604
|
+
let out;
|
|
1605
|
+
try { out = await runTool(name, args); } catch (e) { out = { text: '失败:' + String(e.message || e).slice(0, 200) }; }
|
|
1606
|
+
const savingNow = loadTokenSaving();
|
|
1607
|
+
const keepLen = savingNow.compressImage ? 1200 : 3000;
|
|
1608
|
+
convo.push({ role: 'tool', tool_call_id: tc.id, content: String(out.text || '').slice(0, keepLen) });
|
|
1609
|
+
if (out && out.image && out.image.dataUrl) {
|
|
1610
|
+
let du = out.image.dataUrl;
|
|
1611
|
+
if (savingNow.compressImage) du = await compressImageDataUrl(du);
|
|
1612
|
+
toolImages.push(du);
|
|
1613
|
+
}
|
|
1614
|
+
if (name === 'send_message') sent = true;
|
|
1615
|
+
}
|
|
1616
|
+
if (toolImages.length) {
|
|
1617
|
+
const parts = [{ type: 'text', text: '(这是刚才工具返回的画面)' }];
|
|
1618
|
+
for (const u of toolImages.slice(0, 2)) parts.push({ type: 'image_url', image_url: { url: u } });
|
|
1619
|
+
convo.push({ role: 'user', content: parts });
|
|
1620
|
+
}
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
const text = String(msg.content || '').trim();
|
|
1624
|
+
if (text && !sent) { await streamText(text); addMessage('her', text); sent = true; }
|
|
1625
|
+
break;
|
|
1626
|
+
}
|
|
1627
|
+
// 兜底:工具调完却没说话(或模型空回复)→ 再要一句话
|
|
1628
|
+
if (!sent) {
|
|
1629
|
+
try {
|
|
1630
|
+
const convo2 = buildMessages(userText);
|
|
1631
|
+
for (let i = convo2.length - 1; i >= 0; i--) {
|
|
1632
|
+
if (convo2[i].role === 'user') {
|
|
1633
|
+
const hint = '\n\n(刚才的事情处理完了。现在直接用一句话跟用户说结果或感受,不要调用任何工具。)';
|
|
1634
|
+
if (typeof convo2[i].content === 'string') convo2[i].content += hint;
|
|
1635
|
+
break;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
const resp2 = await chatCompletion({ messages: convo2, max_tokens: 300, temperature: 0.8 });
|
|
1639
|
+
const msg2 = resp2 && resp2.choices && resp2.choices[0] && resp2.choices[0].message;
|
|
1640
|
+
const txt2 = msg2 ? String(msg2.content || '').trim() : '';
|
|
1641
|
+
if (txt2) { await streamText(txt2); addMessage('her', txt2); sent = true; }
|
|
1642
|
+
} catch {}
|
|
1643
|
+
}
|
|
1644
|
+
if (!sent) { await streamText('……嗯,我弄完了,你看看?'); addMessage('her', '……嗯,我弄完了,你看看?'); }
|
|
1645
|
+
// 后台:摘要 & 记忆抽取
|
|
1646
|
+
maybeSummarize();
|
|
1647
|
+
maybeExtractFacts(userText).catch(() => {});
|
|
1648
|
+
} catch (e) {
|
|
1649
|
+
addMessage('system', '(她这边出了点问题:' + String(e.message || e).slice(0, 200) + ')');
|
|
1650
|
+
} finally { typing = false; busy = false; }
|
|
1651
|
+
}
|
|
1652
|
+
async function maybeSummarize() {
|
|
1653
|
+
if (messages.length < 24) return;
|
|
1654
|
+
const text = messages.slice(0, messages.length - 10).map((m) => (m.role === 'user' ? '用户:' : m.role === 'her' ? '她:' : '') + String(m.text).slice(0, 200)).join('\n');
|
|
1655
|
+
if (text.length < 400) return;
|
|
1656
|
+
try {
|
|
1657
|
+
const resp = await chatCompletion({ messages: [{ role: 'system', content: '把下面这段聊天压缩成一段简短的中文摘要(保留关键事件、约定、情绪,200字内)。只输出摘要。' }, { role: 'user', content: text.slice(-6000) }], max_tokens: 300, temperature: 0.3 });
|
|
1658
|
+
const sum = resp.choices && resp.choices[0] && resp.choices[0].message && resp.choices[0].message.content;
|
|
1659
|
+
if (sum) { summary = { text: String(sum).trim().slice(0, 800), upto: messages[messages.length - 10] ? messages[messages.length - 10].id : 0 }; writeJson(summaryPath(), summary); }
|
|
1660
|
+
} catch {}
|
|
1661
|
+
}
|
|
1662
|
+
async function maybeExtractFacts(userText) {
|
|
1663
|
+
if (!userText || String(userText).length < 6) return;
|
|
1664
|
+
try {
|
|
1665
|
+
const resp = await chatCompletion({
|
|
1666
|
+
messages: [
|
|
1667
|
+
{ role: 'system', content: '从用户这句话里提取值得长期记住的事实(关于用户本人、喜好、约定、重要事件)。输出 JSON 数组,每项 {text,pinned},text 不超过 60 字;没有就给 []。只输出 JSON。' },
|
|
1668
|
+
{ role: 'user', content: String(userText).slice(0, 800) },
|
|
1669
|
+
], max_tokens: 300, temperature: 0.2,
|
|
1670
|
+
});
|
|
1671
|
+
const raw = resp.choices && resp.choices[0] && resp.choices[0].message && resp.choices[0].message.content;
|
|
1672
|
+
const m = String(raw || '').match(/\[[\s\S]*\]/);
|
|
1673
|
+
if (!m) return;
|
|
1674
|
+
const arr = JSON.parse(m[0]);
|
|
1675
|
+
const facts = arr.filter((x) => x && x.text).slice(0, 3).map((x) => ({ text: String(x.text).slice(0, 90), pinned: !!x.pinned }));
|
|
1676
|
+
if (facts.length) { MEM.addFacts(facts); MEM.save && MEM.save(); }
|
|
1677
|
+
} catch {}
|
|
1678
|
+
}
|
|
1679
|
+
async function previewReply(text) {
|
|
1680
|
+
const convo = buildMessages(text || '在吗');
|
|
1681
|
+
const resp = await chatCompletion({ messages: convo, tools: buildTools(text || ''), max_tokens: 400 });
|
|
1682
|
+
const msg = resp.choices && resp.choices[0] && resp.choices[0].message;
|
|
1683
|
+
if (!msg) return '';
|
|
1684
|
+
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
|
|
1685
|
+
for (const tc of msg.tool_calls) {
|
|
1686
|
+
if (tc.function && tc.function.name === 'send_message') { try { const a = JSON.parse(tc.function.arguments); return String(a.text || ''); } catch {} }
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return String(msg.content || '');
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// ---------- diary ----------
|
|
1693
|
+
async function generateDiary() {
|
|
1694
|
+
const date = todayStr();
|
|
1695
|
+
if (MEM.hasDiary(date)) return MEM.readDiary(date);
|
|
1696
|
+
const recent = messages.filter((m) => m.role === 'user' || m.role === 'her').slice(-30).map((m) => (m.role === 'user' ? '用户:' : '她:') + String(m.text).slice(0, 150)).join('\n');
|
|
1697
|
+
const cur = currentPlace();
|
|
1698
|
+
try {
|
|
1699
|
+
const resp = await chatCompletion({
|
|
1700
|
+
messages: [
|
|
1701
|
+
{ role: 'system', content: '你是' + loadMeta().name + ',用第一人称写今天的日记。纯文本,150-300字,口语、有细节、有情绪,不要标题不要总结。资料:' + `今天在${cur.name},天气${(WEATHERS[weatherNow()] || WEATHERS.sunny).label},身体状态「${energyWord(loadCharState().energy)}」(不要写具体数值)。` },
|
|
1702
|
+
{ role: 'user', content: (recent || '今天没什么特别的事。').slice(-3500) },
|
|
1703
|
+
], max_tokens: 700, temperature: 0.9,
|
|
1704
|
+
});
|
|
1705
|
+
const txt = resp.choices && resp.choices[0] && resp.choices[0].message && resp.choices[0].message.content;
|
|
1706
|
+
if (txt) { MEM.writeDiary(date, String(txt).trim()); return String(txt).trim(); }
|
|
1707
|
+
} catch {}
|
|
1708
|
+
return '';
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// ---------- modes ----------
|
|
1712
|
+
async function detectMode() {
|
|
1713
|
+
const probe = (port) => new Promise((resolve) => { const s = net.connect({ host: '127.0.0.1', port }, () => { s.end(); resolve(true); }); s.setTimeout(700); s.on('timeout', () => { s.destroy(); resolve(false); }); s.on('error', () => resolve(false)); });
|
|
1714
|
+
const [screen, phone] = await Promise.all([probe(2222), probe(5554)]);
|
|
1715
|
+
return { screen, phone, detected: screen || phone };
|
|
1716
|
+
}
|
|
1717
|
+
function effectiveMode() {
|
|
1718
|
+
const override = CONFIG.mode && ['auto', 'chat', 'full'].includes(CONFIG.mode) ? CONFIG.mode : 'auto';
|
|
1719
|
+
if (override === 'chat') return 'chat';
|
|
1720
|
+
if (override === 'full') return 'full';
|
|
1721
|
+
return CONFIG.modeDetected === true ? 'full' : 'chat';
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// ---------- tutorial ----------
|
|
1725
|
+
function tutorialText() {
|
|
1726
|
+
for (const p of [path.join(ROOT, 'tutorial.md'), path.join(RUNTIME_DIR, 'tutorial.md')]) { try { return fs.readFileSync(p, 'utf8'); } catch {} }
|
|
1727
|
+
return '# 同伴 · 说明\n\n打开地图页 /map、舞台 /stage、3D 舞台 /model、设置 /settings。';
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// ---------- HTTP helpers ----------
|
|
1731
|
+
function sendStatic(res, file, types) {
|
|
1732
|
+
if (!fs.existsSync(file)) { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('not found'); return; }
|
|
1733
|
+
const ext = path.extname(file).toLowerCase();
|
|
1734
|
+
const mime = (types || {})[ext] || 'application/octet-stream';
|
|
1735
|
+
res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-cache' });
|
|
1736
|
+
res.end(fs.readFileSync(file));
|
|
1737
|
+
}
|
|
1738
|
+
function pageFile(name) { const a = path.join(ROOT, name); if (fs.existsSync(a)) return a; const b = path.join(RUNTIME_DIR, name); return fs.existsSync(b) ? b : null; }
|
|
1739
|
+
function servePage(res, name) { const f = pageFile(name); if (!f) { res.writeHead(404); res.end('missing ' + name); return; } sendStatic(res, f, { '.html': 'text/html; charset=utf-8' }); }
|
|
1740
|
+
function json(res, code, obj) {
|
|
1741
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type', 'cache-control': 'no-store' });
|
|
1742
|
+
res.end(JSON.stringify(obj));
|
|
1743
|
+
}
|
|
1744
|
+
function saveDataUrlImage(dataUrl, prefix, maxBytes = 12 * 1024 * 1024) {
|
|
1745
|
+
const mm = String(dataUrl || '').match(/^data:(image\/[a-z+]+);base64,(.+)$/i);
|
|
1746
|
+
if (!mm) return { ok: false, error: '不支持的图片格式' };
|
|
1747
|
+
const buf = Buffer.from(mm[2], 'base64');
|
|
1748
|
+
if (buf.length > maxBytes) return { ok: false, error: '图片太大' };
|
|
1749
|
+
const ext = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }[mm[1].toLowerCase()] || 'png';
|
|
1750
|
+
const dir = path.join(ROOT, 'models'); fs.mkdirSync(dir, { recursive: true });
|
|
1751
|
+
const name = prefix + '-' + Date.now().toString(36) + '.' + ext;
|
|
1752
|
+
fs.writeFileSync(path.join(dir, name), buf);
|
|
1753
|
+
return { ok: true, url: '/models/' + name };
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// ---------- server ----------
|
|
1757
|
+
const server = http.createServer(async (req, res) => {
|
|
1758
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
1759
|
+
try {
|
|
1760
|
+
if (req.method === 'OPTIONS') { json(res, 204, {}); return; }
|
|
1761
|
+
// POST /api/*:先把 JSON 解析好;坏 JSON 直接 400,不进入业务逻辑
|
|
1762
|
+
if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
|
|
1763
|
+
const raw = await readBody(req);
|
|
1764
|
+
try { req._json = raw.trim() ? JSON.parse(raw) : {}; }
|
|
1765
|
+
catch { json(res, 400, { ok: false, error: '请求格式不正确' }); return; }
|
|
1766
|
+
}
|
|
1767
|
+
// pages
|
|
1768
|
+
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) return servePage(res, 'chat.html');
|
|
1769
|
+
if (req.method === 'GET' && url.pathname === '/map') return servePage(res, 'map.html');
|
|
1770
|
+
if (req.method === 'GET' && url.pathname === '/stage') return servePage(res, 'stage.html');
|
|
1771
|
+
if (req.method === 'GET' && url.pathname === '/model') return servePage(res, 'model.html');
|
|
1772
|
+
if (req.method === 'GET' && url.pathname === '/settings') return servePage(res, 'settings.html');
|
|
1773
|
+
if (req.method === 'POST' && url.pathname === '/api/backup') {
|
|
1774
|
+
const r = await runBackup();
|
|
1775
|
+
if (!r.ok) { json(res, 500, { ok: false, error: r.error }); return; }
|
|
1776
|
+
json(res, 200, { ok: true, message: '正在备份到 ' + path.join(ROOT, 'backups') });
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (req.method === 'GET' && url.pathname === '/api/resources') {
|
|
1780
|
+
json(res, 200, await resourcesInfo());
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (req.method === 'POST' && url.pathname === '/api/env/start-all') {
|
|
1784
|
+
(async () => {
|
|
1785
|
+
try {
|
|
1786
|
+
const free = await freeMemMB();
|
|
1787
|
+
if (free < 9000) return;
|
|
1788
|
+
const { execFile } = await import('node:child_process');
|
|
1789
|
+
execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\dsh-env\\start_screen.ps1'], { timeout: 180000, windowsHide: true }, () => {});
|
|
1790
|
+
for (let i = 0; i < 90; i++) { if (await probeTcp(6081)) break; await new Promise((r) => setTimeout(r, 2000)); }
|
|
1791
|
+
if ((await freeMemMB()) < 6000) return;
|
|
1792
|
+
const sdk = process.env.DSH_COMPANION_ANDROID_SDK || path.join(ENV_DIR, 'android-sdk');
|
|
1793
|
+
const envp = { ...process.env, ANDROID_SDK_ROOT: sdk, ANDROID_HOME: sdk };
|
|
1794
|
+
try { const emu = process.env.DSH_COMPANION_EMULATOR || path.join(ENV_DIR, 'android-sdk', 'emulator', 'emulator.exe'); execFile(emu, ['-avd', 'DSH_Phone', '-no-window', '-no-audio', '-no-boot-anim', '-gpu', 'swiftshader_indirect', '-no-snapshot-save'], { timeout: 600000, windowsHide: true, env: envp }, () => {}); } catch {}
|
|
1795
|
+
} catch {}
|
|
1796
|
+
})();
|
|
1797
|
+
json(res, 200, { ok: true, message: '正在依次启动她的屏幕和手机(约 2 分钟,内存不足会跳过)' });
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
if (req.method === 'POST' && url.pathname === '/api/space/start') {
|
|
1801
|
+
const free = await freeMemMB();
|
|
1802
|
+
if (free < 9000) { json(res, 200, { ok: false, refused: true, error: '内存不够,先别启动她的屏幕', freeMB: free, message: '当前空闲内存只剩 ' + free + ' MB,启动屏幕需要约 9 GB 余量。先关掉手机或其他程序再试。' }); return; }
|
|
1803
|
+
try { const { execFile } = await import('node:child_process'); execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\dsh-env\\start_screen.ps1'], { timeout: 120000, windowsHide: true }, () => {}); json(res, 200, { ok: true, starting: true, message: '正在启动她的屏幕(约 1 分钟)' }); } catch (e) { json(res, 500, { ok: false, error: String(e.message) }); }
|
|
1804
|
+
resourcesCache = { at: 0, data: null };
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (req.method === 'POST' && url.pathname === '/api/space/stop') {
|
|
1808
|
+
try { const { execFile } = await import('node:child_process'); execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\dsh-env\\stop_screen.ps1'], { timeout: 120000, windowsHide: true }, () => {}); json(res, 200, { ok: true, stopping: true, message: '正在关闭她的屏幕,释放内存' }); } catch (e) { json(res, 500, { ok: false, error: String(e.message) }); }
|
|
1809
|
+
resourcesCache = { at: 0, data: null };
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
if (req.method === 'POST' && url.pathname === '/api/phone/start') {
|
|
1813
|
+
const freeP = await freeMemMB();
|
|
1814
|
+
if (freeP < 6000) { json(res, 200, { ok: false, refused: true, error: '内存不够,先别启动她的手机', freeMB: freeP, message: '当前空闲内存只剩 ' + freeP + ' MB,启动手机需要约 6 GB 余量。先关掉屏幕或其他程序再试。' }); return; }
|
|
1815
|
+
try {
|
|
1816
|
+
const { execFile } = await import('node:child_process');
|
|
1817
|
+
const env = { ...process.env, ANDROID_SDK_ROOT: 'D:\\dsh-env\\android-sdk', ANDROID_HOME: 'D:\\dsh-env\\android-sdk' };
|
|
1818
|
+
const child = execFile('D:\\dsh-env\\android-sdk\\emulator\\emulator.exe', ['-avd', 'DSH_Phone', '-no-window', '-no-audio', '-no-boot-anim', '-gpu', 'swiftshader_indirect', '-no-snapshot-save'], { timeout: 600000, windowsHide: true, env });
|
|
1819
|
+
child.unref?.();
|
|
1820
|
+
json(res, 200, { ok: true, starting: true, message: '正在启动她的手机(约 1 分钟)' });
|
|
1821
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e.message) }); }
|
|
1822
|
+
resourcesCache = { at: 0, data: null };
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
if (req.method === 'POST' && url.pathname === '/api/phone/stop') {
|
|
1826
|
+
try {
|
|
1827
|
+
const { execFile } = await import('node:child_process');
|
|
1828
|
+
runAdb(['-s', 'emulator-5554', 'emu', 'kill'], 15000).finally(() => {
|
|
1829
|
+
try { execFile('taskkill', ['/IM', 'emulator.exe', '/F'], { timeout: 15000, windowsHide: true }, () => {}); } catch {}
|
|
1830
|
+
});
|
|
1831
|
+
json(res, 200, { ok: true, stopping: true, message: '已关闭她的手机,释放内存' });
|
|
1832
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e.message) }); }
|
|
1833
|
+
resourcesCache = { at: 0, data: null };
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
if (req.method === 'GET' && url.pathname === '/screen') return servePage(res, 'screen.html');
|
|
1837
|
+
if (req.method === 'GET' && url.pathname === '/api/screen/space/status') {
|
|
1838
|
+
const probe = (port) => new Promise((resolve) => { const s = net.connect({ host: '127.0.0.1', port }, () => { s.end(); resolve(true); }); s.setTimeout(800); s.on('timeout', () => { s.destroy(); resolve(false); }); s.on('error', () => resolve(false)); });
|
|
1839
|
+
const httpOk = await probe(6081);
|
|
1840
|
+
let vm = false;
|
|
1841
|
+
try {
|
|
1842
|
+
const { execFile } = await import('node:child_process');
|
|
1843
|
+
vm = await new Promise((resolve) => {
|
|
1844
|
+
execFile('C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe', ['list', 'runningvms'], { timeout: 8000, windowsHide: true }, (e, out) => resolve(!e && String(out || '').includes('DSH-Space')));
|
|
1845
|
+
});
|
|
1846
|
+
} catch {}
|
|
1847
|
+
json(res, 200, { ok: true, vm, http: httpOk, url: 'http://127.0.0.1:6081/vnc.html' });
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
if (req.method === 'POST' && url.pathname === '/api/screen/space/ensure') {
|
|
1851
|
+
const probe = (port) => new Promise((resolve) => { const s = net.connect({ host: '127.0.0.1', port }, () => { s.end(); resolve(true); }); s.setTimeout(800); s.on('timeout', () => { s.destroy(); resolve(false); }); s.on('error', () => resolve(false)); });
|
|
1852
|
+
let vm = false;
|
|
1853
|
+
try {
|
|
1854
|
+
const { execFile } = await import('node:child_process');
|
|
1855
|
+
vm = await new Promise((resolve) => {
|
|
1856
|
+
execFile('C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe', ['list', 'runningvms'], { timeout: 8000, windowsHide: true }, (e, out) => resolve(!e && String(out || '').includes('DSH-Space')));
|
|
1857
|
+
});
|
|
1858
|
+
if (!vm) {
|
|
1859
|
+
execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\dsh-env\\start_space.ps1'], { timeout: 120000, windowsHide: true }, () => {});
|
|
1860
|
+
json(res, 200, { ok: false, starting: true, vm: false, http: false, message: '虚拟机没开,正在启动(约 1 分钟)' });
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
} catch {}
|
|
1864
|
+
let ensured = null;
|
|
1865
|
+
try { const r = await runPy('space_ensure.py', ['ensure'], 45000); ensured = String(r.out || '').trim().slice(0, 500); } catch {}
|
|
1866
|
+
let httpOk = false;
|
|
1867
|
+
for (let i = 0; i < 16; i++) { httpOk = await probe(6081); if (httpOk) break; await new Promise((r) => setTimeout(r, 500)); }
|
|
1868
|
+
json(res, 200, { ok: httpOk, vm: true, http: httpOk, ensured });
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
if (req.method === 'GET' && url.pathname === '/api/screen/pc') {
|
|
1872
|
+
try {
|
|
1873
|
+
const r = await kernelCall('/desktop/capture', { analyze: false });
|
|
1874
|
+
const file = r && r.image_path;
|
|
1875
|
+
if (!file || !fs.existsSync(file)) { res.writeHead(503, {'content-type':'text/plain;charset=utf-8'}); res.end('桌面内核无截图'); return; }
|
|
1876
|
+
res.writeHead(200, { 'content-type': 'image/jpeg', 'cache-control': 'no-store' });
|
|
1877
|
+
res.end(fs.readFileSync(file));
|
|
1878
|
+
} catch (e) { res.writeHead(503, {'content-type':'text/plain;charset=utf-8'}); res.end('桌面内核不可用'); }
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
if (req.method === 'GET' && url.pathname === '/api/screen/phone') {
|
|
1882
|
+
try {
|
|
1883
|
+
const out = path.join(ROOT, 'data', 'screens', 'phone.png');
|
|
1884
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
1885
|
+
const r = await runPy('phone_ctl.py', ['shot', out], 12000);
|
|
1886
|
+
if (!r.ok || !fs.existsSync(out)) { res.writeHead(503, {'content-type':'text/plain;charset=utf-8'}); res.end('手机截图失败'); return; }
|
|
1887
|
+
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store' });
|
|
1888
|
+
res.end(fs.readFileSync(out));
|
|
1889
|
+
} catch (e) { res.writeHead(503, {'content-type':'text/plain;charset=utf-8'}); res.end('手机不可用'); }
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
if (req.method === 'GET' && url.pathname === '/api/screen/info') {
|
|
1893
|
+
json(res, 200, { ok: true, spaceUrl: 'http://127.0.0.1:6081/vnc.html', pc: true, phone: true });
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
if (req.method === 'GET' && url.pathname === '/theme.css') {
|
|
1897
|
+
const p1 = path.join(ROOT, 'theme.css');
|
|
1898
|
+
const p2 = path.join(RUNTIME_DIR, 'theme.css');
|
|
1899
|
+
const file = fs.existsSync(p1) ? p1 : (fs.existsSync(p2) ? p2 : null);
|
|
1900
|
+
if (!file) { res.writeHead(404); res.end('missing theme.css'); return; }
|
|
1901
|
+
sendStatic(res, file, { '.css': 'text/css; charset=utf-8' });
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
if (req.method === 'GET' && url.pathname === '/wx.js') return sendStatic(res, pageFile('wx.js') || path.join(RUNTIME_DIR, 'wx.js'), { '.js': 'text/javascript; charset=utf-8' });
|
|
1905
|
+
if (req.method === 'GET' && url.pathname === '/avatar.png') {
|
|
1906
|
+
if (!fs.existsSync(avatarPath())) { res.writeHead(404); res.end('no avatar'); return; }
|
|
1907
|
+
const buf = fs.readFileSync(avatarPath());
|
|
1908
|
+
const mime = buf[0] === 0xFF && buf[1] === 0xD8 ? 'image/jpeg' : (buf[0] === 0x52 && buf[1] === 0x49 ? 'image/webp' : 'image/png');
|
|
1909
|
+
res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-store' }); res.end(buf); return;
|
|
1910
|
+
}
|
|
1911
|
+
if (req.method === 'GET' && url.pathname === '/favicon.ico') { res.writeHead(204, { 'cache-control': 'public, max-age=86400' }); res.end(); return; }
|
|
1912
|
+
if (req.method === 'GET' && (url.pathname.startsWith('/vendor/') || url.pathname.startsWith('/models/'))) {
|
|
1913
|
+
const isVendor = url.pathname.startsWith('/vendor/');
|
|
1914
|
+
const rel = decodeURIComponent(url.pathname.replace(isVendor ? /^\/vendor\// : /^\/models\//, '')).replace(/\\/g, '/').replace(/\.\./g, '');
|
|
1915
|
+
const roots = isVendor ? [path.join(RUNTIME_DIR, 'vendor'), path.join(ROOT, 'vendor')] : [path.join(ROOT, 'models'), path.join(RUNTIME_DIR, 'models')];
|
|
1916
|
+
let file = null;
|
|
1917
|
+
for (const r of roots) { const f = path.join(r, rel); if (fs.existsSync(f) && fs.statSync(f).isFile()) { file = f; break; } }
|
|
1918
|
+
if (!file) { res.writeHead(404); res.end('not found'); return; }
|
|
1919
|
+
const ext = path.extname(file).toLowerCase();
|
|
1920
|
+
const mime = { '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.bin': 'application/octet-stream', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', '.svg': 'image/svg+xml' }[ext] || 'application/octet-stream';
|
|
1921
|
+
res.writeHead(200, { 'content-type': mime, 'cache-control': 'public, max-age=3600', 'access-control-allow-origin': '*' });
|
|
1922
|
+
res.end(fs.readFileSync(file)); return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// ---------- api ----------
|
|
1926
|
+
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
1927
|
+
json(res, 200, { version: VERSION, typing, busy, lastTurnAt, usage: lastUsage, count: messages.length, setup: (function(){ let keyOk = false; try { keyOk = !!apiKey(); } catch {} return { dataRoot: ROOT, apiKeyOk: keyOk, characters: listCharacters().length }; })(), tokens: (function(){ try { return tokenStats(); } catch { return null; } })(), model: loadModelCfg().name, modelConfig: loadModelCfg(), context: loadContext(), mode: effectiveMode(), place: currentPlace(), character: { id: ACTIVE_CHAR, name: loadMeta().name, avatar: fs.existsSync(avatarPath()) ? '/avatar.png?t=' + fs.statSync(avatarPath()).mtimeMs : '' }, memory: MEM.stats(), diaryToday: MEM.hasDiary(todayStr()), energy: loadCharState().energy });
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
1931
|
+
const since = Number(url.searchParams.get('since') || 0);
|
|
1932
|
+
json(res, 200, { messages: messages.filter((m) => Number(m.id) > since), typing, lastTurnAt, usage: lastUsage, mode: effectiveMode() });
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (req.method === 'POST' && url.pathname === '/api/chat') {
|
|
1936
|
+
const body = (req._json || {});
|
|
1937
|
+
const text = String(body.text || '').trim();
|
|
1938
|
+
let image = '';
|
|
1939
|
+
if (Array.isArray(body.images) && body.images.length) {
|
|
1940
|
+
const img = saveDataUrlImage(String(body.images[0]), 'chat', 8 * 1024 * 1024);
|
|
1941
|
+
if (img.ok) image = img.url;
|
|
1942
|
+
}
|
|
1943
|
+
if (!text && !image) { json(res, 400, { ok: false, error: '空消息' }); return; }
|
|
1944
|
+
if (typing) { json(res, 429, { ok: false, error: '她还在说话' }); return; }
|
|
1945
|
+
addMessage('user', text || '[图片]', image);
|
|
1946
|
+
generateReply(buildUserTurnText(text, image));
|
|
1947
|
+
json(res, 200, { ok: true });
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
if (req.method === 'GET' && url.pathname === '/api/memory') {
|
|
1951
|
+
MEM.decay();
|
|
1952
|
+
json(res, 200, { stats: MEM.stats(), params: MEM.cfg, facts: MEM.facts.filter((f) => !f.archived), archived: MEM.facts.filter((f) => f.archived), diaryDates: MEM.listDiaries() });
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (req.method === 'POST' && url.pathname === '/api/memory/pin') {
|
|
1956
|
+
const b = (req._json || {});
|
|
1957
|
+
const f = MEM.facts.find((x) => Number(x.id) === Number(b.id));
|
|
1958
|
+
if (f) { f.pinned = !f.pinned; if (f.pinned) f.strength = 1; MEM.save(); }
|
|
1959
|
+
json(res, 200, { ok: true });
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
if (req.method === 'POST' && url.pathname === '/api/memory/delete') {
|
|
1963
|
+
const b = (req._json || {});
|
|
1964
|
+
MEM.facts = MEM.facts.filter((x) => Number(x.id) !== Number(b.id)); MEM.save();
|
|
1965
|
+
json(res, 200, { ok: true });
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
if (req.method === 'POST' && url.pathname === '/api/memory/params') {
|
|
1969
|
+
const b = (req._json || {});
|
|
1970
|
+
const p = MEM.setParams(b); CONFIG.memory = { ...(CONFIG.memory || {}), ...p }; saveConfig();
|
|
1971
|
+
json(res, 200, { ok: true, params: p });
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
if (req.method === 'GET' && url.pathname === '/api/persona') {
|
|
1975
|
+
json(res, 200, { meta: { ...loadMeta(), avatar: fs.existsSync(avatarPath()) ? '/avatar.png?t=' + fs.statSync(avatarPath()).mtimeMs : '' }, body: loadPersonaBody() });
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (req.method === 'POST' && url.pathname === '/api/persona') {
|
|
1979
|
+
const b = (req._json || {});
|
|
1980
|
+
const meta = { ...loadMeta(), ...(b.meta || {}) };
|
|
1981
|
+
delete meta.avatar;
|
|
1982
|
+
saveMeta(meta);
|
|
1983
|
+
if (typeof b.body === 'string') fs.writeFileSync(personaPath(), b.body, 'utf8');
|
|
1984
|
+
json(res, 200, { ok: true, meta: { ...loadMeta(), avatar: fs.existsSync(avatarPath()) ? '/avatar.png?t=' + Date.now() : '' }, body: loadPersonaBody() });
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (req.method === 'POST' && url.pathname === '/api/avatar') {
|
|
1988
|
+
const b = (req._json || {});
|
|
1989
|
+
if (!b.dataUrl) { try { fs.unlinkSync(avatarPath()); } catch {} json(res, 200, { ok: true }); return; }
|
|
1990
|
+
const r = saveDataUrlImage(b.dataUrl, 'avatar', 3 * 1024 * 1024);
|
|
1991
|
+
if (!r.ok) { json(res, 400, { ok: false, error: r.error }); return; }
|
|
1992
|
+
fs.copyFileSync(path.join(ROOT, 'models', path.basename(r.url)), avatarPath());
|
|
1993
|
+
json(res, 200, { ok: true, avatar: '/avatar.png?t=' + Date.now() });
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (req.method === 'GET' && url.pathname === '/api/characters') {
|
|
1997
|
+
json(res, 200, { characters: listCharacters(), active: ACTIVE_CHAR });
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (req.method === 'POST' && url.pathname === '/api/characters') {
|
|
2001
|
+
const b = (req._json || {});
|
|
2002
|
+
const action = String(b.action || 'switch');
|
|
2003
|
+
if (action === 'new') {
|
|
2004
|
+
const id = 'char-' + Date.now().toString(36);
|
|
2005
|
+
ensureDirs(id);
|
|
2006
|
+
writeJson(path.join(charDir(id), 'meta.json'), { ...META_DEFAULT, name: String(b.name || '新角色') });
|
|
2007
|
+
activateCharacter(id);
|
|
2008
|
+
json(res, 200, { ok: true, id, characters: listCharacters(), active: id });
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
if (action === 'delete') {
|
|
2012
|
+
const id = String(b.id || '');
|
|
2013
|
+
if (!id || id === 'default') { json(res, 400, { ok: false, error: '不能删默认角色' }); return; }
|
|
2014
|
+
const ids = listCharacters().map((c) => c.id).filter((x) => x !== id);
|
|
2015
|
+
if (!ids.length) { json(res, 400, { ok: false, error: '至少要留一个角色' }); return; }
|
|
2016
|
+
fs.rmSync(path.join(CHARS_ROOT, id), { recursive: true, force: true });
|
|
2017
|
+
activateCharacter(ids.includes('default') ? 'default' : ids[0]);
|
|
2018
|
+
json(res, 200, { ok: true, characters: listCharacters(), active: ACTIVE_CHAR });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
const id = String(b.id || '');
|
|
2022
|
+
if (!listCharacters().some((c) => c.id === id)) { json(res, 404, { ok: false, error: '没有这个角色' }); return; }
|
|
2023
|
+
activateCharacter(id);
|
|
2024
|
+
json(res, 200, { ok: true, active: id, characters: listCharacters(), character: { id, name: loadMeta().name, avatar: fs.existsSync(avatarPath()) ? '/avatar.png?t=' + Date.now() : '' } });
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
if (req.method === 'GET' && url.pathname === '/api/life') { json(res, 200, { life: loadLife() }); return; }
|
|
2028
|
+
if (req.method === 'POST' && url.pathname === '/api/life') {
|
|
2029
|
+
const b = (req._json || {});
|
|
2030
|
+
const life = { ...loadLife(), ...b };
|
|
2031
|
+
saveLife(life);
|
|
2032
|
+
json(res, 200, { ok: true, life });
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (req.method === 'POST' && url.pathname === '/api/proactive/test') {
|
|
2036
|
+
try {
|
|
2037
|
+
const meta = loadMeta();
|
|
2038
|
+
const cur = currentPlace();
|
|
2039
|
+
const recent = messages.filter((m) => m.role === 'user' || m.role === 'her').slice(-6).map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text).slice(0, 100)).join('\n');
|
|
2040
|
+
const resp = await chatCompletion({ messages: [
|
|
2041
|
+
{ role: 'system', content: '你是' + meta.name + '。你突然想主动找他说话。一句话,25 字以内,像真人发消息,可撒娇/吐槽/分享小事;不要报体力数值、不要列清单、不要问"在吗"。你现在在' + cur.name + ',感觉' + energyWord(loadCharState().energy) + '。' },
|
|
2042
|
+
{ role: 'user', content: '最近聊的:\n' + (recent || '(没聊什么)') + '\n\n写出你想发的那一句:' },
|
|
2043
|
+
], max_tokens: 120, temperature: 0.95 });
|
|
2044
|
+
const msg = resp && resp.choices && resp.choices[0] && resp.choices[0].message;
|
|
2045
|
+
let txt = msg ? String(msg.content || '').trim() : '';
|
|
2046
|
+
txt = txt.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '').split('\n')[0].slice(0, 60);
|
|
2047
|
+
if (!txt) { json(res, 500, { ok: false, error: '生成失败' }); return; }
|
|
2048
|
+
await streamText(txt); addMessage('her', txt);
|
|
2049
|
+
json(res, 200, { ok: true, text: txt });
|
|
2050
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e.message || e) }); }
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (req.method === 'POST' && url.pathname === '/api/life-tick') {
|
|
2054
|
+
autoWanderMaybe(true);
|
|
2055
|
+
json(res, 200, { ok: true, place: currentPlace() });
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
if (req.method === 'GET' && url.pathname === '/api/token-saving') { json(res, 200, { saving: loadTokenSaving() }); return; }
|
|
2059
|
+
if (req.method === 'POST' && url.pathname === '/api/token-saving') {
|
|
2060
|
+
const b = (req._json || {});
|
|
2061
|
+
const next = { ...loadTokenSaving() };
|
|
2062
|
+
for (const k of Object.keys(TOKEN_SAVING_DEFAULT)) if (typeof b[k] === 'boolean') next[k] = b[k];
|
|
2063
|
+
json(res, 200, { ok: true, saving: saveTokenSaving(next) });
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (req.method === 'GET' && url.pathname === '/api/chat-style') {
|
|
2067
|
+
json(res, 200, { ok: true, style: loadChatStyle() });
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
if (req.method === 'POST' && url.pathname === '/api/chat-style') {
|
|
2071
|
+
const b = (req._json || {});
|
|
2072
|
+
const style = saveChatStyle({ noParens: !!b.noParens });
|
|
2073
|
+
json(res, 200, { ok: true, style });
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (req.method === 'GET' && url.pathname === '/api/pet') {
|
|
2077
|
+
json(res, 200, { ok: true, running: petRunning(), pid: petPid(), pet: loadPetCfg() });
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
if (req.method === 'POST' && url.pathname === '/api/pet') {
|
|
2081
|
+
const b = (req._json || {});
|
|
2082
|
+
const patch = {};
|
|
2083
|
+
for (const k of ['enabled', 'image', 'size', 'opacity', 'statusText', 'alwaysOnTop', 'gestures', 'buttons']) {
|
|
2084
|
+
if (Object.prototype.hasOwnProperty.call(b, k)) patch[k] = b[k];
|
|
2085
|
+
}
|
|
2086
|
+
const pet = savePetCfg(patch);
|
|
2087
|
+
let action = 'saved';
|
|
2088
|
+
if (pet.enabled) { const r = await startPet(); action = r.ok ? 'started' : 'start-failed'; }
|
|
2089
|
+
else { await stopPet(); action = 'stopped'; }
|
|
2090
|
+
json(res, 200, { ok: true, pet, running: petRunning(), action });
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
if (req.method === 'POST' && url.pathname === '/api/pet/image') {
|
|
2094
|
+
try {
|
|
2095
|
+
const b = (req._json || {});
|
|
2096
|
+
const m = String(b.dataUrl || '').match(/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i);
|
|
2097
|
+
if (!m) { json(res, 400, { ok: false, error: '需要 png/jpeg/webp 图片' }); return; }
|
|
2098
|
+
const buf = Buffer.from(m[2], 'base64');
|
|
2099
|
+
if (buf.length > 8 * 1024 * 1024) { json(res, 400, { ok: false, error: '图片太大(8MB 以内)' }); return; }
|
|
2100
|
+
const ext = m[1].toLowerCase() === 'jpeg' ? 'jpg' : m[1].toLowerCase();
|
|
2101
|
+
const dir = path.join(ROOT, 'pet');
|
|
2102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2103
|
+
const file = path.join(dir, 'avatar-custom.' + ext);
|
|
2104
|
+
fs.writeFileSync(file, buf);
|
|
2105
|
+
savePetCfg({ image: file });
|
|
2106
|
+
if (petRunning()) { await stopPet(); await startPet(); }
|
|
2107
|
+
json(res, 200, { ok: true, image: file });
|
|
2108
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e.message) }); }
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (req.method === 'POST' && url.pathname === '/api/pet/start') {
|
|
2112
|
+
const r = await startPet();
|
|
2113
|
+
savePetCfg({ enabled: true });
|
|
2114
|
+
json(res, r.ok ? 200 : 500, r);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (req.method === 'POST' && url.pathname === '/api/pet/stop') {
|
|
2118
|
+
const r = await stopPet();
|
|
2119
|
+
savePetCfg({ enabled: false });
|
|
2120
|
+
json(res, 200, r);
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
if (req.method === 'GET' && url.pathname === '/api/pet/manifest') {
|
|
2124
|
+
json(res, 200, { ok: true, pet: loadPetCfg(), status: petStatusPayload() });
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
if (req.method === 'GET' && url.pathname === '/api/pet/status') {
|
|
2128
|
+
json(res, 200, petStatusPayload());
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (req.method === 'GET' && url.pathname === '/api/pet/avatar') {
|
|
2132
|
+
let file = null;
|
|
2133
|
+
try { if (fs.existsSync(avatarPath())) file = avatarPath(); } catch {}
|
|
2134
|
+
if (!file) { const d = path.join(RUNTIME_DIR, 'pet', 'default-avatar.png'); if (fs.existsSync(d)) file = d; }
|
|
2135
|
+
if (!file) { res.writeHead(404); res.end('no avatar'); return; }
|
|
2136
|
+
const buf = fs.readFileSync(file);
|
|
2137
|
+
const mime = buf[0] === 0xFF && buf[1] === 0xD8 ? 'image/jpeg' : (buf[0] === 0x52 && buf[1] === 0x49 ? 'image/webp' : 'image/png');
|
|
2138
|
+
res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-store', 'access-control-allow-origin': '*' });
|
|
2139
|
+
res.end(buf);
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (req.method === 'POST' && url.pathname === '/api/pet/act') {
|
|
2143
|
+
const b = (req._json || {});
|
|
2144
|
+
if (typing) { json(res, 429, { ok: false, error: '她还在说话' }); return; }
|
|
2145
|
+
const action = String(b.action || '');
|
|
2146
|
+
const manifest = loadPetCfg();
|
|
2147
|
+
let prompt = '';
|
|
2148
|
+
if (action === 'look') prompt = '(桌宠按钮)用 pc 工具看看我的屏幕,然后用一句话告诉我你看到了什么。';
|
|
2149
|
+
else if (action === 'chat') { json(res, 200, { ok: true, action: 'chat', prompt: '' }); return; }
|
|
2150
|
+
else if (action === 'gesture') {
|
|
2151
|
+
const g = (manifest.gestures || []).find((x) => x.id === String(b.gestureId || ''));
|
|
2152
|
+
prompt = String((g && g.prompt) || b.prompt || '(你碰了碰她)');
|
|
2153
|
+
} else prompt = String(b.prompt || '');
|
|
2154
|
+
if (!prompt) { json(res, 400, { ok: false, error: '缺少 prompt' }); return; }
|
|
2155
|
+
addMessage('user', prompt);
|
|
2156
|
+
generateReply(buildUserTurnText(prompt, ''));
|
|
2157
|
+
json(res, 200, { ok: true, action: action || 'gesture', prompt });
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (req.method === 'GET' && url.pathname === '/api/env/check') {
|
|
2161
|
+
try {
|
|
2162
|
+
const vboxPaths = [process.env.DSH_COMPANION_VBOX, 'C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe', 'C:\\Program Files (x86)\\Oracle\\VirtualBox\\VBoxManage.exe'];
|
|
2163
|
+
const vbox = vboxPaths.find((p) => { try { return p && fs.existsSync(p); } catch { return false; } });
|
|
2164
|
+
const scriptsOk = ['space_ctl.py', 'phone_ctl.py'].every((n) => { try { return fs.existsSync(path.join(ENV_DIR, n)); } catch { return false; } });
|
|
2165
|
+
let paramiko = false;
|
|
2166
|
+
try {
|
|
2167
|
+
paramiko = await new Promise((resolve) => {
|
|
2168
|
+
import('node:child_process').then(({ execFile }) => {
|
|
2169
|
+
execFile(ENV_PY, ['-c', 'import paramiko; print(1)'], { timeout: 8000, windowsHide: true }, (e, out) => resolve(!e && String(out || '').includes('1')));
|
|
2170
|
+
}).catch(() => resolve(false));
|
|
2171
|
+
});
|
|
2172
|
+
} catch {}
|
|
2173
|
+
const phoneOn = await adbDevicePresent();
|
|
2174
|
+
let adbKey = false;
|
|
2175
|
+
let adbKeyDetail = '手机没开机,无法检测';
|
|
2176
|
+
if (phoneOn) {
|
|
2177
|
+
const r = await runAdb(['-s', 'emulator-5554', 'shell', 'ime', 'list', '-s'], 15000);
|
|
2178
|
+
adbKey = /com\.android\.adbkeyboard/i.test(r.out);
|
|
2179
|
+
adbKeyDetail = adbKey ? '已安装' : '未安装(中文输入会失败)';
|
|
2180
|
+
}
|
|
2181
|
+
const checks = [
|
|
2182
|
+
{ id: 'dataRoot', label: '数据目录', ok: true, detail: ROOT },
|
|
2183
|
+
{ id: 'apiKey', label: 'DeepSeek API Key', ok: (() => { try { apiKey(); return true; } catch { return false; } })(), detail: '读取 ~/.dsh 下的 key' },
|
|
2184
|
+
{ id: 'envDir', label: '辅助脚本目录', ok: scriptsOk, detail: ENV_DIR },
|
|
2185
|
+
{ id: 'envPy', label: '环境 Python', ok: (() => { try { return ENV_PY === 'python' || fs.existsSync(ENV_PY); } catch { return false; } })(), detail: ENV_PY },
|
|
2186
|
+
{ id: 'paramiko', label: 'Python 依赖 paramiko', ok: paramiko, detail: 'space/phone 脚本需要' },
|
|
2187
|
+
{ id: 'virtualbox', label: 'VirtualBox', ok: !!vbox, detail: vbox || '未找到 VBoxManage.exe' },
|
|
2188
|
+
{ id: 'vm', label: '虚拟机 DSH-Space', ok: await vmRunningNow(), detail: '运行中才能看她的屏幕' },
|
|
2189
|
+
{ id: 'ssh', label: '虚拟机 SSH (2222)', ok: await probeTcp(2222), detail: '端口转发' },
|
|
2190
|
+
{ id: 'vnc', label: '虚拟机 VNC (6081)', ok: await probeTcp(6081), detail: '看屏幕用' },
|
|
2191
|
+
{ id: 'emulator', label: '安卓模拟器', ok: phoneOn, detail: phoneOn ? '已连接' : '未启动' },
|
|
2192
|
+
{ id: 'adbkeyboard', label: 'ADBKeyboard 中文输入法', ok: adbKey, detail: adbKeyDetail },
|
|
2193
|
+
{ id: 'ffmpeg', label: 'ffmpeg(选,压缩截图用)', ok: !!ffmpegPath(), detail: ffmpegPath() || '未找到,压缩会跳过' },
|
|
2194
|
+
];
|
|
2195
|
+
const hints = [];
|
|
2196
|
+
if (!scriptsOk) hints.push('辅助脚本没配好:设置 envDir 或把脚本放到 ' + path.join(RUNTIME_DIR, 'env'));
|
|
2197
|
+
if (!paramiko) hints.push('环境 Python 缺 paramiko:在 envDir 的 venv 里 pip install paramiko');
|
|
2198
|
+
if (!vbox) hints.push('没装 VirtualBox:完整版需要它跑她的 Linux 桌面');
|
|
2199
|
+
if (!phoneOn) hints.push('手机没开机:设置 → 环境 → 启动手机(推荐 Android 16 / API 36 AVD DSH_Phone)');
|
|
2200
|
+
if (phoneOn && !adbKey) hints.push('点下方「安装 ADBKeyboard」解决手机中文输入');
|
|
2201
|
+
json(res, 200, { ok: true, checks, hints, paths: { dataRoot: ROOT, envDir: ENV_DIR, envPy: ENV_PY, version: VERSION } });
|
|
2202
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e.message) }); }
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
if (req.method === 'POST' && url.pathname === '/api/env/install-adbkeyboard') {
|
|
2206
|
+
try {
|
|
2207
|
+
const apks = [path.join(ENV_DIR, 'installers', 'ADBKeyboard.apk'), path.join(RUNTIME_DIR, 'env', 'installers', 'ADBKeyboard.apk')];
|
|
2208
|
+
const apk = apks.find((p) => { try { return fs.existsSync(p); } catch { return false; } });
|
|
2209
|
+
if (!apk) { json(res, 500, { ok: false, error: '没找到 ADBKeyboard.apk(放到 ' + path.join(ENV_DIR, 'installers') + ')' }); return; }
|
|
2210
|
+
if (!(await adbDevicePresent())) { json(res, 500, { ok: false, error: '手机没开机,先在设置→环境里启动手机' }); return; }
|
|
2211
|
+
const steps = [];
|
|
2212
|
+
const r1 = await runAdb(['-s', 'emulator-5554', 'install', '-r', apk], 120000);
|
|
2213
|
+
steps.push('install: ' + (r1.ok ? 'OK' : r1.out.slice(0, 120)));
|
|
2214
|
+
const r2 = await runAdb(['-s', 'emulator-5554', 'shell', 'ime', 'enable', 'com.android.adbkeyboard/.AdbIME'], 15000);
|
|
2215
|
+
steps.push('ime enable: ' + (r2.ok ? 'OK' : r2.out.slice(0, 120)));
|
|
2216
|
+
const r3 = await runAdb(['-s', 'emulator-5554', 'shell', 'ime', 'set', 'com.android.adbkeyboard/.AdbIME'], 15000);
|
|
2217
|
+
steps.push('ime set: ' + (r3.ok ? 'OK' : r3.out.slice(0, 120)));
|
|
2218
|
+
const r4 = await runAdb(['-s', 'emulator-5554', 'shell', 'settings', 'put', 'secure', 'show_ime_with_hard_keyboard', '1'], 15000);
|
|
2219
|
+
steps.push('show_ime: ' + (r4.ok ? 'OK' : r4.out.slice(0, 120)));
|
|
2220
|
+
json(res, 200, { ok: true, steps });
|
|
2221
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e.message) }); }
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
if (req.method === 'GET' && url.pathname === '/api/env') { json(res, 200, { env: loadEnv(), resources: await resourcesInfo() }); return; }
|
|
2225
|
+
if (req.method === 'POST' && url.pathname === '/api/env') {
|
|
2226
|
+
const b = (req._json || {});
|
|
2227
|
+
const env = { ...loadEnv() };
|
|
2228
|
+
for (const k of ['pc', 'space', 'phone']) if (['off', 'gui', 'code'].includes(String(b[k]))) env[k] = b[k];
|
|
2229
|
+
if (typeof b.confirmDanger === 'boolean') env.confirmDanger = b.confirmDanger;
|
|
2230
|
+
if (['allow', 'deny'].includes(String(b.selfStart))) env.selfStart = b.selfStart;
|
|
2231
|
+
saveEnv(env); json(res, 200, { ok: true, env }); return;
|
|
2232
|
+
}
|
|
2233
|
+
if (req.method === 'GET' && url.pathname === '/api/model') { json(res, 200, { model: loadModelCfg(), usage: lastUsage }); return; }
|
|
2234
|
+
if (req.method === 'POST' && url.pathname === '/api/model/key') {
|
|
2235
|
+
const b = (req._json || {});
|
|
2236
|
+
const key = String(b.key || '').trim();
|
|
2237
|
+
if (key.length < 10) { json(res, 400, { ok: false, error: 'API Key 太短' }); return; }
|
|
2238
|
+
try { fs.writeFileSync(path.join(ROOT, 'api-key.txt'), key, { mode: 0o600 }); json(res, 200, { ok: true }); }
|
|
2239
|
+
catch (e) { json(res, 500, { ok: false, error: '保存失败' }); }
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (req.method === 'POST' && url.pathname === '/api/model') {
|
|
2243
|
+
const b = (req._json || {});
|
|
2244
|
+
const m = { ...loadModelCfg() };
|
|
2245
|
+
if (typeof b.name === 'string' && b.name.trim()) m.name = b.name.trim();
|
|
2246
|
+
if (['low', 'medium', 'high', 'max'].includes(b.reasoningEffort)) m.reasoningEffort = b.reasoningEffort;
|
|
2247
|
+
if (Number.isFinite(Number(b.temperature))) m.temperature = Math.min(1.5, Math.max(0, Number(b.temperature)));
|
|
2248
|
+
if (Number.isFinite(Number(b.maxTokens))) m.maxTokens = Math.min(8000, Math.max(200, Math.round(Number(b.maxTokens))));
|
|
2249
|
+
if (Number.isFinite(Number(b.maxSteps))) m.maxSteps = Math.min(60, Math.max(4, Math.round(Number(b.maxSteps))));
|
|
2250
|
+
saveModelCfg(m); json(res, 200, { ok: true, model: m }); return;
|
|
2251
|
+
}
|
|
2252
|
+
if (req.method === 'GET' && url.pathname === '/api/context') { json(res, 200, { context: loadContext(), historyKeep: 10, summary }); return; }
|
|
2253
|
+
if (req.method === 'POST' && url.pathname === '/api/context') {
|
|
2254
|
+
const b = (req._json || {});
|
|
2255
|
+
const c = { ...loadContext() };
|
|
2256
|
+
if (['full', 'short', 'off'].includes(b.historyMode)) c.historyMode = b.historyMode;
|
|
2257
|
+
if (Number.isFinite(Number(b.memoryInject))) c.memoryInject = Math.max(2, Math.min(20, Math.round(Number(b.memoryInject))));
|
|
2258
|
+
saveContext(c); json(res, 200, { ok: true, context: c }); return;
|
|
2259
|
+
}
|
|
2260
|
+
if (req.method === 'GET' && url.pathname === '/api/mode') {
|
|
2261
|
+
const detected = await detectMode();
|
|
2262
|
+
if (detected.detected) { CONFIG.modeDetected = true; saveConfig(); }
|
|
2263
|
+
const override = ['auto', 'chat', 'full'].includes(CONFIG.mode) ? CONFIG.mode : 'auto';
|
|
2264
|
+
const eff = override === 'chat' ? 'chat' : override === 'full' ? 'full' : (detected.detected ? 'full' : 'chat');
|
|
2265
|
+
json(res, 200, { override, detected, effective: eff });
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
if (req.method === 'POST' && url.pathname === '/api/mode') {
|
|
2269
|
+
const b = (req._json || {});
|
|
2270
|
+
if (['auto', 'chat', 'full'].includes(b.override)) { CONFIG.mode = b.override; saveConfig(); }
|
|
2271
|
+
const detected = await detectMode();
|
|
2272
|
+
const eff = CONFIG.mode === 'chat' ? 'chat' : CONFIG.mode === 'full' ? 'full' : (detected.detected ? 'full' : 'chat');
|
|
2273
|
+
json(res, 200, { ok: true, override: CONFIG.mode || 'auto', detected, effective: eff });
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
if (req.method === 'GET' && url.pathname === '/api/tutorial') { json(res, 200, { text: tutorialText() }); return; }
|
|
2277
|
+
if (req.method === 'POST' && url.pathname === '/api/preview') {
|
|
2278
|
+
const b = (req._json || {});
|
|
2279
|
+
const t = await previewReply(String(b.text || ''));
|
|
2280
|
+
json(res, 200, { ok: true, text: t });
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
if (req.method === 'GET' && url.pathname === '/api/diary') {
|
|
2284
|
+
if (url.searchParams.get('date')) { json(res, 200, { date: url.searchParams.get('date'), text: MEM.readDiary(url.searchParams.get('date')) }); return; }
|
|
2285
|
+
json(res, 200, { dates: MEM.listDiaries() }); return;
|
|
2286
|
+
}
|
|
2287
|
+
if (req.method === 'POST' && url.pathname === '/api/diary/generate') {
|
|
2288
|
+
const text = await generateDiary();
|
|
2289
|
+
json(res, 200, { ok: !!text, text });
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
if (req.method === 'POST' && url.pathname === '/api/diary/show') {
|
|
2293
|
+
const text = MEM.readDiary(todayStr());
|
|
2294
|
+
if (!text) { json(res, 200, { ok: false, error: '今天还没写日记' }); return; }
|
|
2295
|
+
addMessage('user', '(她把日记给你看了)\n' + text);
|
|
2296
|
+
json(res, 200, { ok: true });
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
// map
|
|
2300
|
+
if (req.method === 'GET' && url.pathname === '/api/map') {
|
|
2301
|
+
const map = getActiveMap();
|
|
2302
|
+
json(res, 200, {
|
|
2303
|
+
maps: loadMaps().map((m) => ({ id: m.id, name: m.name, placeCount: m.places.length })), activeMapId: map.id,
|
|
2304
|
+
map: { id: map.id, name: map.name, places: map.places, widthMeters: map.widthMeters, transport: map.transport, theme: map.theme, model: map.model || null, image: map.image || null, displayMode: map.displayMode || '2d', roads: map.roads || null },
|
|
2305
|
+
transports: TRANSPORTS, longTransports: ['coach', 'rail', 'plane'],
|
|
2306
|
+
capabilities: charCapabilities(), weather: weatherNow(), weathers: WEATHERS, energy: loadCharState().energy,
|
|
2307
|
+
location: currentPlace(), schedules: (loadLife().schedules || []).map((s, i) => ({ ...s, index: i, place: s.place || guessPlaceId(s.label) })),
|
|
2308
|
+
});
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
if (req.method === 'POST' && url.pathname === '/api/map') {
|
|
2312
|
+
const b = (req._json || {});
|
|
2313
|
+
const maps = loadMaps();
|
|
2314
|
+
const id = String(b.mapId || activeMapId());
|
|
2315
|
+
const m = maps.find((x) => x.id === id);
|
|
2316
|
+
if (!m) { json(res, 404, { ok: false, error: '没有这张地图' }); return; }
|
|
2317
|
+
if (typeof b.name === 'string' && b.name.trim()) m.name = b.name.trim().slice(0, 20);
|
|
2318
|
+
if (b.widthMeters !== undefined) m.widthMeters = Math.max(200, Math.min(20000, Number(b.widthMeters) || 2000));
|
|
2319
|
+
if (b.transport !== undefined) m.transport = (TRANSPORTS[b.transport] || b.transport === 'auto') ? b.transport : 'auto';
|
|
2320
|
+
if (b.theme !== undefined) m.theme = ['town', 'coast', 'city', 'forest'].includes(b.theme) ? b.theme : 'town';
|
|
2321
|
+
if (['2d', '3d'].includes(b.displayMode)) m.displayMode = b.displayMode;
|
|
2322
|
+
if (b.roads !== undefined) m.roads = normalizeRoads(b.roads);
|
|
2323
|
+
if (b.image !== undefined) m.image = b.image && b.image.url ? { url: String(b.image.url).slice(0, 300) } : null;
|
|
2324
|
+
if (b.model !== undefined) m.model = b.model && b.model.url ? { url: String(b.model.url).slice(0, 300), scale: Number(b.model.scale) || 1, rotationY: Number(b.model.rotationY) || 0, yOffset: Number(b.model.yOffset) || 0 } : null;
|
|
2325
|
+
if (Array.isArray(b.places)) {
|
|
2326
|
+
m.places = b.places.slice(0, 40).map((p) => ({ id: String(p.id || ('p-' + Math.random().toString(36).slice(2, 7))).slice(0, 40), name: String(p.name || '未命名').slice(0, 20), icon: String(p.icon || '📍').slice(0, 4), x: Math.max(0.05, Math.min(0.95, Number(p.x) || 0.5)), y: Math.max(0.08, Math.min(0.92, Number(p.y) || 0.5)), desc: String(p.desc || '').slice(0, 80), feel: String(p.feel || '').slice(0, 80), image: p.image && String(p.image).startsWith('/models/') ? String(p.image) : null }));
|
|
2327
|
+
}
|
|
2328
|
+
if (Array.isArray(b.schedules)) {
|
|
2329
|
+
const life = loadLife();
|
|
2330
|
+
const assigns = new Map(b.schedules.map((s) => [Number(s.index), String(s.place || '')]));
|
|
2331
|
+
life.schedules = (life.schedules || []).map((s, i) => assigns.has(i) ? { ...s, place: assigns.get(i) } : s);
|
|
2332
|
+
saveLife(life);
|
|
2333
|
+
}
|
|
2334
|
+
saveMaps(maps);
|
|
2335
|
+
json(res, 200, { ok: true, current: currentPlace() });
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
if (req.method === 'POST' && url.pathname === '/api/map/create') {
|
|
2339
|
+
const b = (req._json || {});
|
|
2340
|
+
const maps = loadMaps();
|
|
2341
|
+
const id = 'map-' + Date.now().toString(36);
|
|
2342
|
+
maps.push({ id, name: String(b.name || '新地图').slice(0, 20), places: DEFAULT_PLACES.map((p) => ({ ...p })), widthMeters: 2000, transport: 'auto', theme: 'town', displayMode: '2d' });
|
|
2343
|
+
saveMaps(maps);
|
|
2344
|
+
json(res, 200, { ok: true, id, maps: loadMaps().map((mm) => ({ id: mm.id, name: mm.name, placeCount: mm.places.length })) });
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
if (req.method === 'POST' && url.pathname === '/api/map/delete') {
|
|
2348
|
+
const b = (req._json || {});
|
|
2349
|
+
const maps = loadMaps().filter((m) => m.id !== String(b.id));
|
|
2350
|
+
if (!maps.length) { json(res, 400, { ok: false, error: '至少保留一张地图' }); return; }
|
|
2351
|
+
saveMaps(maps);
|
|
2352
|
+
if (!maps.some((m) => m.id === CONFIG.activeMapId)) { CONFIG.activeMapId = maps[0].id; saveConfig(); }
|
|
2353
|
+
json(res, 200, { ok: true });
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
if (req.method === 'POST' && url.pathname === '/api/map/activate') {
|
|
2357
|
+
const b = (req._json || {});
|
|
2358
|
+
const loc = moveToMap(String(b.id || ''), { mode: b.mode, instant: !!b.instant, confirmNight: !!b.confirmNight });
|
|
2359
|
+
if (loc && loc.refused) { json(res, 200, { ok: false, refused: true, needsConfirm: !!loc.needsConfirm, reason: loc.reason, location: currentPlace() }); return; }
|
|
2360
|
+
json(res, 200, { ok: true, location: loc });
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
if (req.method === 'POST' && url.pathname === '/api/map/import') {
|
|
2364
|
+
const b = (req._json || {});
|
|
2365
|
+
const maps = loadMaps();
|
|
2366
|
+
const map = maps.find((m) => m.id === (b.mapId || activeMapId())) || maps[0];
|
|
2367
|
+
const content = String(b.content || '').trim();
|
|
2368
|
+
const ICONS = ['🏠', '🏫', '🏢', '📚', '🌳', '☕', '🍚', '🛒', '🏥', '🏦', '🚉', '✈️', '🎬', '🏬', '🏋️', '📖', '💻', '🌉', '🏞️', '⛰️', '🍜', '🍰', '🍺', '🎮', '🎤', '🌃', '🏪', '💊', '🎪', '🚏'];
|
|
2369
|
+
const guess = (name) => { const hints = [['家|宿舍|卧室', '🏠'], ['学校|教室', '🏫'], ['公司|办公', '🏢'], ['图书馆|自习', '📚'], ['公园|花园', '🌳'], ['咖啡', '☕'], ['食堂|饭|餐', '🍚'], ['超市|商场|购物', '🛒'], ['医院|诊所|药', '🏥'], ['银行', '🏦'], ['车站|地铁', '🚉'], ['机场', '✈️'], ['电影', '🎬'], ['健身', '🏋️'], ['书店', '📖'], ['网吧|电脑', '💻'], ['桥', '🌉'], ['河|湖|海', '🏞️'], ['山', '⛰️'], ['酒吧', '🍺'], ['游戏', '🎮'], ['KTV|唱歌', '🎤']]; for (const [re, ic] of hints) { try { if (new RegExp(re).test(name)) return ic; } catch {} } return ICONS[Math.floor(Math.random() * ICONS.length)]; };
|
|
2370
|
+
const existing = new Set(map.places.map((x) => String(x.name)));
|
|
2371
|
+
const items = [];
|
|
2372
|
+
if (content.startsWith('[') || content.startsWith('{')) {
|
|
2373
|
+
try {
|
|
2374
|
+
const parsed = JSON.parse(content);
|
|
2375
|
+
if (!Array.isArray(parsed) && Array.isArray(parsed.roads)) map.roads = normalizeRoads(parsed.roads);
|
|
2376
|
+
const arr = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.places) ? parsed.places : [parsed]);
|
|
2377
|
+
for (const it of arr) if (it && it.name) items.push(it);
|
|
2378
|
+
} catch { json(res, 400, { ok: false, error: 'JSON 解析失败' }); return; }
|
|
2379
|
+
} else {
|
|
2380
|
+
for (const line of content.split(/\r?\n/)) {
|
|
2381
|
+
const s = line.trim(); if (!s || s.startsWith('#') || s.startsWith('//')) continue;
|
|
2382
|
+
const parts = (s.includes('\t') ? s.split('\t') : s.split(',')).map((x) => x.trim());
|
|
2383
|
+
if (!parts[0] || /^(名字|name)$/i.test(parts[0])) continue;
|
|
2384
|
+
items.push({ name: parts[0], icon: parts[1], x: parts[2], y: parts[3], desc: parts[4], feel: parts[5] });
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
let added = 0, skipped = 0;
|
|
2388
|
+
for (const it of items) {
|
|
2389
|
+
const name = String(it.name || '').trim(); if (!name) continue;
|
|
2390
|
+
if (existing.has(name)) { skipped++; continue; }
|
|
2391
|
+
map.places.push({ id: 'p-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6), name, icon: String(it.icon || '').trim() || guess(name), x: Number.isFinite(Number(it.x)) ? Math.max(0.04, Math.min(0.96, Number(it.x))) : 0.2 + Math.random() * 0.6, y: Number.isFinite(Number(it.y)) ? Math.max(0.08, Math.min(0.92, Number(it.y))) : 0.15 + Math.random() * 0.7, desc: String(it.desc || ''), feel: String(it.feel || ''), image: it.image && String(it.image).startsWith('/models/') ? String(it.image) : null });
|
|
2392
|
+
existing.add(name); added++;
|
|
2393
|
+
}
|
|
2394
|
+
saveMaps(maps);
|
|
2395
|
+
json(res, 200, { ok: true, added, skipped, total: map.places.length, places: map.places });
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
if (req.method === 'POST' && url.pathname === '/api/map/image') {
|
|
2399
|
+
const b = (req._json || {});
|
|
2400
|
+
const maps = loadMaps();
|
|
2401
|
+
const map = maps.find((m) => m.id === (b.mapId || activeMapId())) || maps[0];
|
|
2402
|
+
if (!b.dataUrl) { map.image = null; saveMaps(maps); json(res, 200, { ok: true, url: '' }); return; }
|
|
2403
|
+
const r = saveDataUrlImage(b.dataUrl, 'map-' + String(map.id).replace(/[^a-z0-9-]/gi, ''), 12 * 1024 * 1024);
|
|
2404
|
+
if (!r.ok) { json(res, 400, { ok: false, error: r.error }); return; }
|
|
2405
|
+
map.image = { url: r.url }; saveMaps(maps);
|
|
2406
|
+
json(res, 200, { ok: true, url: r.url });
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
if (req.method === 'POST' && url.pathname === '/api/place/image') {
|
|
2410
|
+
const b = (req._json || {});
|
|
2411
|
+
const maps = loadMaps();
|
|
2412
|
+
const map = maps.find((m) => m.id === (b.mapId || activeMapId())) || maps[0];
|
|
2413
|
+
const place = map.places.find((p) => p.id === b.placeId);
|
|
2414
|
+
if (!place) { json(res, 404, { ok: false, error: '地点不存在' }); return; }
|
|
2415
|
+
if (!b.dataUrl) { place.image = null; saveMaps(maps); json(res, 200, { ok: true, image: '' }); return; }
|
|
2416
|
+
const r = saveDataUrlImage(b.dataUrl, 'place-' + String(place.id).replace(/[^a-z0-9-]/gi, ''), 6 * 1024 * 1024);
|
|
2417
|
+
if (!r.ok) { json(res, 400, { ok: false, error: r.error }); return; }
|
|
2418
|
+
place.image = r.url; saveMaps(maps);
|
|
2419
|
+
json(res, 200, { ok: true, image: r.url, places: map.places });
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
if (req.method === 'GET' && url.pathname === '/api/location') { json(res, 200, { location: currentPlace() }); return; }
|
|
2423
|
+
if (req.method === 'POST' && url.pathname === '/api/location') {
|
|
2424
|
+
const b = (req._json || {});
|
|
2425
|
+
const loc = moveTo(String(b.placeId || ''), { mapId: b.mapId, manual: true, mode: b.mode, instant: !!b.instant, confirmNight: !!b.confirmNight });
|
|
2426
|
+
if (loc && loc.refused) { json(res, 200, { ok: false, refused: true, needsConfirm: !!loc.needsConfirm, reason: loc.reason, location: currentPlace() }); return; }
|
|
2427
|
+
json(res, 200, { ok: true, location: loc });
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
if (req.method === 'GET' && url.pathname === '/api/routine') {
|
|
2431
|
+
json(res, 200, { routine: loadRoutine(), visits: loadVisits(), places: getActiveMap().places.map((p) => ({ id: p.id, name: p.name, icon: p.icon })), character: loadMeta().name });
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
if (req.method === 'POST' && url.pathname === '/api/routine') {
|
|
2435
|
+
const b = (req._json || {});
|
|
2436
|
+
json(res, 200, { ok: true, routine: saveRoutine(b) });
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
if (req.method === 'GET' && url.pathname === '/api/weather') {
|
|
2440
|
+
const mode = CONFIG.weatherMode === 'real' ? 'real' : 'manual';
|
|
2441
|
+
const real = mode === 'real' ? await getRealWeather() : null;
|
|
2442
|
+
const manual = WEATHERS[CONFIG.weather] ? CONFIG.weather : 'sunny';
|
|
2443
|
+
const kind = mode === 'real' ? (real.kind || manual) : manual;
|
|
2444
|
+
json(res, 200, { mode, manual, kind, label: WEATHERS[kind].label, icon: WEATHERS[kind].icon, temp: real ? real.temp : null, city: real ? real.city : null, source: real ? real.source : 'manual', weathers: WEATHERS, fx: loadWeatherFx() });
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
if (req.method === 'POST' && url.pathname === '/api/weather') {
|
|
2448
|
+
const b = (req._json || {});
|
|
2449
|
+
if (b.mode === 'real' || b.mode === 'manual') CONFIG.weatherMode = b.mode;
|
|
2450
|
+
if (b.manual && WEATHERS[b.manual]) CONFIG.weather = b.manual;
|
|
2451
|
+
saveConfig();
|
|
2452
|
+
const real = CONFIG.weatherMode === 'real' ? await getRealWeather(!!b.refresh) : null;
|
|
2453
|
+
const kind = CONFIG.weatherMode === 'real' ? (real.kind || CONFIG.weather || 'sunny') : (CONFIG.weather || 'sunny');
|
|
2454
|
+
json(res, 200, { ok: true, mode: CONFIG.weatherMode || 'manual', kind, temp: real ? real.temp : null, city: real ? real.city : null, source: real ? real.source : 'manual' });
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
if (req.method === 'GET' && url.pathname === '/api/weatherfx') { json(res, 200, { ok: true, fx: loadWeatherFx(), kinds: Object.keys(WEATHERS) }); return; }
|
|
2458
|
+
if (req.method === 'POST' && url.pathname === '/api/weatherfx') {
|
|
2459
|
+
const b = (req._json || {});
|
|
2460
|
+
const kind = String(b.kind || '');
|
|
2461
|
+
if (!WEATHERS[kind]) { json(res, 400, { ok: false, error: '未知天气' }); return; }
|
|
2462
|
+
CONFIG.weatherFx = CONFIG.weatherFx || {};
|
|
2463
|
+
if (b.clear) delete CONFIG.weatherFx[kind];
|
|
2464
|
+
else CONFIG.weatherFx[kind] = { type: String(b.type || 'image'), url: String(b.url || ''), opacity: Math.max(0.05, Math.min(1, Number(b.opacity) || 1)) };
|
|
2465
|
+
saveConfig();
|
|
2466
|
+
json(res, 200, { ok: true, fx: CONFIG.weatherFx });
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
if (req.method === 'POST' && url.pathname === '/api/weatherfx/upload') {
|
|
2470
|
+
const b = (req._json || {});
|
|
2471
|
+
const kind = String(b.kind || '');
|
|
2472
|
+
if (!WEATHERS[kind]) { json(res, 400, { ok: false, error: '未知天气' }); return; }
|
|
2473
|
+
const r = saveDataUrlImage(b.dataUrl, 'wxfx-' + kind, 20 * 1024 * 1024);
|
|
2474
|
+
if (!r.ok) { json(res, 400, { ok: false, error: r.error }); return; }
|
|
2475
|
+
CONFIG.weatherFx = CONFIG.weatherFx || {};
|
|
2476
|
+
CONFIG.weatherFx[kind] = { type: /\.(mp4|webm)$/i.test(r.url) ? 'video' : 'image', url: r.url, opacity: 1 };
|
|
2477
|
+
saveConfig();
|
|
2478
|
+
json(res, 200, { ok: true, fx: CONFIG.weatherFx[kind] });
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
if (req.method === 'GET' && url.pathname === '/api/economy') {
|
|
2482
|
+
const econ = loadEconomy(); const w = walletMoney();
|
|
2483
|
+
json(res, 200, { ok: true, economy: econ, wallet: { balance: w.wallet.balance, spendable: w.spendable, tokenMode: w.tokenMode, remainingTokens: w.remainingTokens, usedTokens: w.wallet.usedTokens, moneyType: w.moneyType } });
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
if (req.method === 'POST' && url.pathname === '/api/economy') {
|
|
2487
|
+
const b = (req._json || {});
|
|
2488
|
+
const econ = loadEconomy();
|
|
2489
|
+
for (const k of ['initialBalance', 'dailyIncome', 'wagePerHour', 'mealCost', 'entertainmentCost', 'tokenBudget', 'tokensPerYuan']) if (Number.isFinite(Number(b[k]))) econ[k] = Math.max(0, Number(b[k]));
|
|
2490
|
+
if (typeof b.enabled === 'boolean') econ.enabled = b.enabled;
|
|
2491
|
+
if (b.moneyType === 'virtual' || b.moneyType === 'token') econ.moneyType = b.moneyType;
|
|
2492
|
+
if (typeof b.tokenMode === 'boolean') econ.tokenMode = b.tokenMode;
|
|
2493
|
+
saveEconomy(econ);
|
|
2494
|
+
json(res, 200, { ok: true, economy: econ, wallet: walletMoney() });
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
if (req.method === 'POST' && url.pathname === '/api/wallet/spend') {
|
|
2498
|
+
const b = (req._json || {});
|
|
2499
|
+
const r = spendMoney(Number(b.amount) || 0, String(b.item || ''));
|
|
2500
|
+
json(res, 200, { ok: r.ok, result: r, wallet: walletMoney() });
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
if (req.method === 'GET' && url.pathname === '/api/stream') {
|
|
2504
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive', 'access-control-allow-origin': '*' });
|
|
2505
|
+
streamClients.add(res);
|
|
2506
|
+
try { res.write('data: ' + JSON.stringify({ type: 'init', active: streamState.active, text: streamState.text }) + '\n\n'); } catch {}
|
|
2507
|
+
req.on('close', () => { streamClients.delete(res); });
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
if (req.method === 'GET' && url.pathname === '/api/stage') { json(res, 200, stagePayload()); return; }
|
|
2511
|
+
if (req.method === 'GET' && url.pathname === '/api/stage/stream') {
|
|
2512
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive', 'access-control-allow-origin': '*' });
|
|
2513
|
+
const send = () => { try { res.write('data: ' + JSON.stringify(stagePayload()) + '\n\n'); } catch {} };
|
|
2514
|
+
send(); const t = setInterval(send, 1000); req.on('close', () => clearInterval(t));
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
json(res, 404, { ok: false, error: 'not found' });
|
|
2518
|
+
} catch (e) {
|
|
2519
|
+
const status = (e && Number(e.statusCode)) || 500;
|
|
2520
|
+
const msg = status === 400 ? '请求格式不正确' : '服务器内部错误';
|
|
2521
|
+
if (status >= 500) { try { console.error('[companion] request failed:', e); } catch {} }
|
|
2522
|
+
try { json(res, status, { ok: false, error: msg }); } catch {}
|
|
2523
|
+
}
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
function stagePayload() {
|
|
2527
|
+
const map = getActiveMap();
|
|
2528
|
+
const cur = currentPlace();
|
|
2529
|
+
const meta = loadMeta();
|
|
2530
|
+
const real = CONFIG.weatherMode === 'real' && weatherCache.data;
|
|
2531
|
+
const weather = real ? { source: 'real', kind: weatherCache.data.kind, temp: weatherCache.data.temp, city: weatherCache.data.city } : { source: 'manual', kind: weatherNow(), temp: null, city: null };
|
|
2532
|
+
const w = WEATHERS[weather.kind] || WEATHERS.sunny;
|
|
2533
|
+
const state = cur.intercity ? 'intercity' : (cur.traveling ? 'traveling' : 'stationary');
|
|
2534
|
+
let wallet = null;
|
|
2535
|
+
try { const m = walletMoney(); wallet = { balance: m.wallet.balance, spendable: m.spendable, tokenMode: m.tokenMode, remainingTokens: m.remainingTokens, moneyType: m.moneyType }; } catch {}
|
|
2536
|
+
return {
|
|
2537
|
+
ok: true, apiVersion: 1, serverTime: Date.now(),
|
|
2538
|
+
character: { id: ACTIVE_CHAR, name: meta.name || '', avatar: fs.existsSync(avatarPath()) ? '/avatar.png' : '', model: meta.model || { type: 'none', url: '', scale: 1 }, age: Number(meta.age) || null, license: meta.license === true },
|
|
2539
|
+
map: { id: map.id, name: map.name, theme: map.theme || 'town', widthMeters: Number(map.widthMeters) || 2000, places: map.places.map((p) => ({ id: p.id, name: p.name, icon: p.icon, image: p.image || null, x: p.x, y: p.y, desc: p.desc || '', feel: p.feel || '' })), model: map.model || null, image: map.image || null, displayMode: map.displayMode || '2d', roads: map.roads || null },
|
|
2540
|
+
location: { state, placeId: cur.placeId, placeName: cur.name, icon: cur.icon, since: cur.since || Date.now(), minutes: cur.minutes || 0, traveling: cur.traveling || null, intercity: cur.intercity || null, avatar: cur.avatar || '' },
|
|
2541
|
+
weather: { kind: weather.kind, label: w.label, icon: w.icon, temp: weather.temp, city: weather.city, source: weather.source, fx: loadWeatherFx() },
|
|
2542
|
+
energy: loadCharState().energy, wallet,
|
|
2543
|
+
settings: { weatherMode: CONFIG.weatherMode === 'real' ? 'real' : 'manual', smartRoutine: loadRoutine().smart === true, smartLife: loadLife().smartLife === true, locationFree: loadLife().locationFree !== false },
|
|
2544
|
+
animationHint: state === 'stationary' ? (cur.placeId === 'home' && (new Date().getHours() >= 23 || new Date().getHours() < 7) ? 'sleeping' : 'idle') : (cur.traveling ? 'walking' : 'riding'),
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// ---------- startup ----------
|
|
2549
|
+
function writePortFile(port) { writeJson(PORT_FILE, { port, root: ROOT, pid: process.pid, version: VERSION, at: Date.now() }); }
|
|
2550
|
+
function tryListen(port, attempt = 0) {
|
|
2551
|
+
server.once('error', (e) => {
|
|
2552
|
+
if (e && e.code === 'EADDRINUSE' && attempt < 20) { setTimeout(() => tryListen(port + 1, attempt + 1), 200); }
|
|
2553
|
+
else { console.error('[companion] listen error', e && e.message); process.exit(1); }
|
|
2554
|
+
});
|
|
2555
|
+
server.listen(port, '127.0.0.1', () => {
|
|
2556
|
+
LISTEN_PORT = port;
|
|
2557
|
+
console.log('[companion] http://127.0.0.1:' + port + '/ root=' + ROOT);
|
|
2558
|
+
writePortFile(port);
|
|
2559
|
+
try { if (loadPetCfg().enabled) setTimeout(() => { startPet().catch(() => {}); }, 800); } catch {}
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
function boot() {
|
|
2564
|
+
// 迁移:老版本 data/messages.json
|
|
2565
|
+
fs.mkdirSync(CHARS_ROOT, { recursive: true });
|
|
2566
|
+
if (!fs.readdirSync(CHARS_ROOT).filter((n) => fs.statSync(path.join(CHARS_ROOT, n)).isDirectory()).length) {
|
|
2567
|
+
const id = 'default';
|
|
2568
|
+
ensureDirs(id);
|
|
2569
|
+
writeJson(path.join(charDir(id), 'meta.json'), { ...META_DEFAULT });
|
|
2570
|
+
fs.writeFileSync(path.join(charDir(id), 'persona.md'), PERSONA_DEFAULT, 'utf8');
|
|
2571
|
+
const legacy = readJson(path.join(ROOT, 'data', 'messages.json'), []);
|
|
2572
|
+
if (Array.isArray(legacy) && legacy.length) writeJson(path.join(charDir(id), 'messages.json'), legacy);
|
|
2573
|
+
}
|
|
2574
|
+
ACTIVE_CHAR = activeCharId();
|
|
2575
|
+
activateCharacter(ACTIVE_CHAR);
|
|
2576
|
+
loadMessages();
|
|
2577
|
+
// 后台心跳
|
|
2578
|
+
setInterval(() => {
|
|
2579
|
+
try {
|
|
2580
|
+
if (loadLife().enabled === false) return;
|
|
2581
|
+
const cur = currentPlace();
|
|
2582
|
+
if (cur.traveling || cur.intercity) return;
|
|
2583
|
+
if (messages.filter((m) => m.role !== 'system').length < 2) return;
|
|
2584
|
+
autoWanderMaybe();
|
|
2585
|
+
} catch {}
|
|
2586
|
+
}, 6 * 60 * 1000).unref?.();
|
|
2587
|
+
setInterval(() => {
|
|
2588
|
+
try {
|
|
2589
|
+
const h = new Date().getHours();
|
|
2590
|
+
const life = loadLife();
|
|
2591
|
+
if (life.diaryAuto !== false && h === Number(life.diaryHour ?? 22) && !MEM.hasDiary(todayStr())) generateDiary().catch(() => {});
|
|
2592
|
+
} catch {}
|
|
2593
|
+
}, 5 * 60 * 1000).unref?.();
|
|
2594
|
+
// 自动备份:每天 4 点后第一次心跳备份一次
|
|
2595
|
+
setInterval(async () => {
|
|
2596
|
+
try {
|
|
2597
|
+
const h = new Date().getHours();
|
|
2598
|
+
if (h < 4) return;
|
|
2599
|
+
const today = todayStr();
|
|
2600
|
+
if (CONFIG.backupDate === today) return;
|
|
2601
|
+
CONFIG.backupDate = today; saveConfig();
|
|
2602
|
+
await runBackup();
|
|
2603
|
+
} catch {}
|
|
2604
|
+
}, 30 * 60 * 1000).unref?.();
|
|
2605
|
+
if (CONFIG.weatherMode === 'real') getRealWeather().catch(() => {});
|
|
2606
|
+
// 主动消息:她偶尔自己找你说话(可在 设置→生活 里关)
|
|
2607
|
+
let nextProactiveAt = 0;
|
|
2608
|
+
const scheduleNextProactive = () => {
|
|
2609
|
+
const life = loadLife();
|
|
2610
|
+
const min = Math.max(5, Number(life.proactiveMinMin) || 30);
|
|
2611
|
+
const max = Math.max(min, Number(life.proactiveMaxMin) || 90);
|
|
2612
|
+
nextProactiveAt = Date.now() + (min + Math.random() * (max - min)) * 60 * 1000;
|
|
2613
|
+
};
|
|
2614
|
+
scheduleNextProactive();
|
|
2615
|
+
setInterval(async () => {
|
|
2616
|
+
try {
|
|
2617
|
+
const life = loadLife();
|
|
2618
|
+
if (life.enabled === false || life.proactiveEnabled !== true) return;
|
|
2619
|
+
if (typing || busy) return;
|
|
2620
|
+
const h = new Date().getHours();
|
|
2621
|
+
const qs = Number(life.quietStart ?? 2), qe = Number(life.quietEnd ?? 7);
|
|
2622
|
+
const quiet = qs <= qe ? (h >= qs && h < qe) : (h >= qs || h < qe);
|
|
2623
|
+
if (quiet) return;
|
|
2624
|
+
if (Date.now() < nextProactiveAt) return;
|
|
2625
|
+
const lastHer = [...messages].reverse().find((m) => m.role === 'her');
|
|
2626
|
+
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
|
2627
|
+
if (lastUser && Date.now() - Number(lastUser.at || 0) < 20 * 60 * 1000) { scheduleNextProactive(); return; }
|
|
2628
|
+
const meta = loadMeta();
|
|
2629
|
+
const cur = currentPlace();
|
|
2630
|
+
const recent = messages.filter((m) => m.role === 'user' || m.role === 'her').slice(-6).map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text).slice(0, 100)).join('\n');
|
|
2631
|
+
const resp = await chatCompletion({
|
|
2632
|
+
messages: [
|
|
2633
|
+
{ role: 'system', content: '你是' + meta.name + '。现在你一个人待着,突然想主动找他说句话。\n要求:一句话,25 字以内,像真人发消息;可以撒娇/吐槽/分享小事/想他;不要报体力数值,不要说"记录"这种;不要问"在吗";不要列清单。\n你现在的状态:在' + cur.name + ',感觉' + (typeof energyWord === 'function' ? energyWord(loadCharState().energy) : '还好') + '。' },
|
|
2634
|
+
{ role: 'user', content: '最近聊的:\n' + (recent || '(没聊什么)') + '\n\n请写出你想发的那一句话:' },
|
|
2635
|
+
],
|
|
2636
|
+
max_tokens: 120, temperature: 0.95,
|
|
2637
|
+
});
|
|
2638
|
+
const msg = resp && resp.choices && resp.choices[0] && resp.choices[0].message;
|
|
2639
|
+
let txt = msg ? String(msg.content || '').trim() : '';
|
|
2640
|
+
txt = txt.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '').split('\n')[0].slice(0, 60);
|
|
2641
|
+
if (txt) { await streamText(txt); addMessage('her', txt); }
|
|
2642
|
+
} catch {}
|
|
2643
|
+
scheduleNextProactive();
|
|
2644
|
+
}, 60 * 1000).unref?.();
|
|
2645
|
+
|
|
2646
|
+
// 她的屏幕守护:VNC 链路掉了自动修复(不主动开虚拟机)
|
|
2647
|
+
let lastEnsureAt = 0;
|
|
2648
|
+
setInterval(async () => {
|
|
2649
|
+
try {
|
|
2650
|
+
if (Date.now() - lastEnsureAt < 5 * 60 * 1000) return;
|
|
2651
|
+
if (!(await vmRunningNow())) return;
|
|
2652
|
+
if (await probeTcp(6081)) return;
|
|
2653
|
+
lastEnsureAt = Date.now();
|
|
2654
|
+
runPy('space_ensure.py', ['ensure'], 45000).catch(() => {});
|
|
2655
|
+
} catch {}
|
|
2656
|
+
}, 60 * 1000).unref?.();
|
|
2657
|
+
tryListen(PREFERRED_PORT);
|
|
2658
|
+
}
|
|
2659
|
+
boot();
|