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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -3
  3. package/cordis.patch.yml +6 -0
  4. package/examples/CREDITS.md +17 -0
  5. package/examples/dafeiyu-ai-avatar.png +0 -0
  6. package/examples/dafeiyu-ai-candidate-2.png +0 -0
  7. package/examples/dafeiyu-avatar-cc-by-nc-sa.png +0 -0
  8. package/examples/dafeiyu-avatar-preview.png +0 -0
  9. package/examples/dafeiyu-character-card.json +24 -0
  10. package/lib/client.js +235 -0
  11. package/lib/index.js +149 -0
  12. package/package.json +47 -3
  13. package/runtime/chat.html +215 -0
  14. package/runtime/config.example.json +93 -0
  15. package/runtime/env/README.md +14 -0
  16. package/runtime/env/agent_ssh.py +73 -0
  17. package/runtime/env/installers/ADBKeyboard.apk +0 -0
  18. package/runtime/env/phone_ctl.py +244 -0
  19. package/runtime/env/space_ctl.py +163 -0
  20. package/runtime/env/space_ensure.py +65 -0
  21. package/runtime/env/start_screen.ps1 +16 -0
  22. package/runtime/env/start_space.ps1 +33 -0
  23. package/runtime/env/stop_screen.ps1 +10 -0
  24. package/runtime/example-character.json +24 -0
  25. package/runtime/map.html +784 -0
  26. package/runtime/memory.mjs +205 -0
  27. package/runtime/model.html +363 -0
  28. package/runtime/models/chat-mway331n.png +0 -0
  29. package/runtime/models/dafeiyu-chibi.gltf +1 -0
  30. package/runtime/models/demo-town.gltf +1 -0
  31. package/runtime/models/demo-town2.gltf +1 -0
  32. package/runtime/models/duck.glb +0 -0
  33. package/runtime/models/map-map-mwb087zu-mwb0880c.png +0 -0
  34. package/runtime/models/map-map-mwb0bfsb-mwb0bft6.png +0 -0
  35. package/runtime/models/map-map-mwb0c9iw-mwb0c9j2.png +0 -0
  36. package/runtime/models/map-map-mwb0d8cc-mwb0d8d2.png +0 -0
  37. package/runtime/models/map-map-mwb0fm53-mwb0fm59.png +0 -0
  38. package/runtime/persona.example.md +34 -0
  39. package/runtime/pet/default-avatar.png +0 -0
  40. package/runtime/pet/pet.example.json +49 -0
  41. package/runtime/pet/pet.ps1 +532 -0
  42. package/runtime/screen.html +125 -0
  43. package/runtime/server.mjs +2659 -0
  44. package/runtime/settings.html +1015 -0
  45. package/runtime/stage.html +195 -0
  46. package/runtime/theme.css +210 -0
  47. package/runtime/tutorial.md +116 -0
  48. package/runtime/vendor/addons/loaders/GLTFLoader.js +4663 -0
  49. package/runtime/vendor/addons/utils/BufferGeometryUtils.js +1375 -0
  50. package/runtime/vendor/three.module.min.js +6 -0
  51. package/runtime/wx.js +164 -0
  52. package/scripts/backup.ps1 +79 -0
  53. package/start-pure-chat.cmd +26 -0
  54. package/start-pure-chat.sh +4 -0
@@ -0,0 +1,532 @@
1
+ param(
2
+ [string]$Base = 'http://127.0.0.1:8900',
3
+ [string]$Root = ''
4
+ )
5
+ $ErrorActionPreference = 'SilentlyContinue'
6
+ Add-Type -AssemblyName PresentationFramework
7
+ Add-Type -AssemblyName PresentationCore
8
+ Add-Type -AssemblyName WindowsBase
9
+ Add-Type -AssemblyName System.Windows.Forms
10
+ Add-Type -AssemblyName System.Drawing
11
+
12
+ if (-not $Root) { $Root = Join-Path $env:USERPROFILE '.dsh-companion' }
13
+ $DataDir = Join-Path $Root 'data'
14
+ New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
15
+ $UiFile = Join-Path $DataDir 'pet-ui.json'
16
+ $LogFile = Join-Path $DataDir 'pet.log'
17
+ $script:mutex = New-Object System.Threading.Mutex($false, 'Local\DshCompanionPet')
18
+ if (-not $script:mutex.WaitOne(0)) { try { exit } catch {} }
19
+
20
+ function Log($m) { try { Add-Content $LogFile ("[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) } catch {} }
21
+ function ApiGet($path) { try { return Invoke-RestMethod -Uri ($Base + $path) -TimeoutSec 8 -UseBasicParsing } catch { return $null } }
22
+ function ApiPost($path, $obj) {
23
+ try {
24
+ $json = $obj | ConvertTo-Json -Depth 12 -Compress
25
+ return Invoke-RestMethod -Uri ($Base + $path) -Method Post -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($json)) -TimeoutSec 30 -UseBasicParsing
26
+ } catch { Log ("POST " + $path + " err: " + $_.Exception.Message); return $null }
27
+ }
28
+ function Fmt-Tok($n) {
29
+ $v = [double]$n
30
+ if ($v -ge 100000000) { return ('{0:N1}亿' -f ($v / 100000000)) }
31
+ if ($v -ge 10000) { return ('{0:N1}万' -f ($v / 10000)) }
32
+ if ($v -ge 1000) { return ('{0:N1}k' -f ($v / 1000)) }
33
+ return [string][int]$v
34
+ }
35
+ function Brush($hex) { return [System.Windows.Media.BrushConverter]::new().ConvertFromString($hex) }
36
+
37
+ $script:pet = $null
38
+ $script:status = $null
39
+ $script:lastMsgId = 0
40
+ $script:lastBubbleId = 0
41
+ $script:statusText = $null
42
+ $script:tokenText = $null
43
+ $script:img = $null
44
+ $script:win = $null
45
+ $script:chatPanel = $null
46
+ $script:chatStack = $null
47
+ $script:chatScroll = $null
48
+ $script:chatBox = $null
49
+ $script:chatTimer = $null
50
+ $script:mainTimer = $null
51
+ $script:downPoint = $null
52
+ $script:isDragging = $false
53
+ $script:bubbleUntil = [DateTime]::MinValue
54
+ $script:lastAvatarUrl = ''
55
+ $script:chatDelta = 318
56
+ $script:suppressSave = $false
57
+
58
+ function Load-Manifest() {
59
+ $m = ApiGet '/api/pet/manifest'
60
+ if ($m -and $m.pet) { $script:pet = $m.pet } else {
61
+ $script:pet = [pscustomobject]@{
62
+ image = 'avatar'; size = 180; opacity = 1; statusText = $true; alwaysOnTop = $true
63
+ gestures = @(
64
+ [pscustomobject]@{ id='pat'; label='摸摸头'; hit=[pscustomobject]@{x=0.15;y=0.08;w=0.7;h=0.42}; prompt='(你摸了摸她的头)'; bubble='唔…' },
65
+ [pscustomobject]@{ id='poke'; label='戳一下'; hit=[pscustomobject]@{x=0.3;y=0.5;w=0.4;h=0.32}; prompt='(你戳了戳她的脸)'; bubble='干嘛啦' }
66
+ )
67
+ buttons = @(
68
+ [pscustomobject]@{ id='chat'; label=''; action='chat'; title='和她说话'; icon='chat-line' },
69
+ [pscustomobject]@{ id='look'; label=''; action='look'; title='看看我的屏幕'; icon='eye-line' }
70
+ )
71
+ }
72
+ }
73
+ }
74
+ function Resolve-ImagePath() {
75
+ $spec = [string]$script:pet.image
76
+ if (-not $spec -or $spec -eq 'avatar') {
77
+ $dest = Join-Path $DataDir 'pet-avatar.png'
78
+ try {
79
+ $resp = Invoke-WebRequest -Uri ($Base + '/api/pet/avatar') -TimeoutSec 15 -UseBasicParsing
80
+ $bytes = $resp.Content
81
+ if ($bytes -is [byte[]]) { [IO.File]::WriteAllBytes($dest, $bytes) }
82
+ else { [IO.File]::WriteAllBytes($dest, [Text.Encoding]::UTF8.GetBytes([string]$bytes)) }
83
+ return $dest
84
+ } catch { Log 'avatar download failed'; return (Join-Path $PSScriptRoot 'default-avatar.png') }
85
+ }
86
+ if ($spec -match '^https?://') {
87
+ $dest = Join-Path $DataDir 'pet-custom.png'
88
+ try { Invoke-WebRequest -Uri $spec -OutFile $dest -TimeoutSec 20 -UseBasicParsing; return $dest } catch { Log 'custom image download failed'; return $null }
89
+ }
90
+ if (-not [IO.Path]::IsPathRooted($spec)) {
91
+ $relative = Join-Path $Root $spec
92
+ if (Test-Path $relative) { return (Resolve-Path $relative).Path }
93
+ }
94
+ if (Test-Path $spec) { return (Resolve-Path $spec).Path }
95
+ return $null
96
+ }
97
+ function Make-Bitmap([string]$path) {
98
+ try {
99
+ $bmp = New-Object System.Windows.Media.Imaging.BitmapImage
100
+ $bmp.BeginInit()
101
+ $bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
102
+ $bmp.UriSource = New-Object System.Uri($path)
103
+ $bmp.EndInit(); $bmp.Freeze()
104
+ return $bmp
105
+ } catch { Log ('bitmap err: ' + $_.Exception.Message); return $null }
106
+ }
107
+ function Update-Image() {
108
+ $path = Resolve-ImagePath
109
+ if (-not $path) { return }
110
+ $bmp = Make-Bitmap $path
111
+ if (-not $bmp) { return }
112
+ $size = [double]$script:pet.size; if ($size -le 0 -or $size -gt 600) { $size = 180 }
113
+ $ratio = $bmp.PixelWidth / [double]$bmp.PixelHeight; if ($ratio -le 0) { $ratio = 0.8 }
114
+ $script:img.Source = $bmp
115
+ $script:img.Height = $size
116
+ $script:img.Width = [math]::Round($size * $ratio)
117
+ try {
118
+ $clip = New-Object System.Windows.Media.RectangleGeometry
119
+ $clip.Rect = New-Object System.Windows.Rect(0, 0, $script:img.Width, $script:img.Height)
120
+ $clip.RadiusX = 16; $clip.RadiusY = 16
121
+ $script:img.Clip = $clip
122
+ } catch {}
123
+ $script:img.ToolTip = [string]$script:pet.label
124
+ }
125
+ function Show-Bubble([string]$text, [int]$ms = 6000) {
126
+ if (-not $script:statusText) { return }
127
+ $script:statusText.Text = $text
128
+ $script:bubbleUntil = (Get-Date).AddMilliseconds($ms)
129
+ }
130
+ function Gesture-At([double]$nx, [double]$ny) {
131
+ foreach ($g in @($script:pet.gestures)) {
132
+ if (-not $g.hit) { continue }
133
+ $h = $g.hit
134
+ if ($nx -ge [double]$h.x -and $nx -le ([double]$h.x + [double]$h.w) -and $ny -ge [double]$h.y -and $ny -le ([double]$h.y + [double]$h.h)) { return $g }
135
+ }
136
+ return $null
137
+ }
138
+ function Send-Act($action, $gestureId, $prompt) {
139
+ $body = @{ action = $action }
140
+ if ($gestureId) { $body.gestureId = $gestureId }
141
+ if ($prompt) { $body.prompt = $prompt }
142
+ $r = ApiPost '/api/pet/act' $body
143
+ if ($r -and $r.ok) {
144
+ if ($action -eq 'look') { Show-Bubble '她正在看你的屏幕…' 12000 }
145
+ elseif ($action -eq 'gesture') { }
146
+ else { Show-Bubble '(她收到了)' 2500 }
147
+ } elseif ($r -and $r.error) { Show-Bubble ('她:' + $r.error) 4000 }
148
+ else { Show-Bubble '连不上她…' 4000 }
149
+ }
150
+ function Append-Chat($who, $text) {
151
+ if (-not $script:chatStack -or -not $text) { return }
152
+ $item = New-Object System.Windows.Controls.TextBlock
153
+ $item.Text = ($(if ($who -eq 'her') { '她:' } else { '你:' }) + $text)
154
+ $item.TextWrapping = 'Wrap'
155
+ $item.Width = 232
156
+ $item.Margin = [System.Windows.Thickness]::new(4, 4, 4, 4)
157
+ $item.Foreground = Brush $(if ($who -eq 'her') { '#CFE6FF' } else { '#8FB9FF' })
158
+ $script:chatStack.Children.Add($item) | Out-Null
159
+ $script:chatScroll.ScrollToEnd()
160
+ }
161
+ function Load-ChatHistory() {
162
+ $history = ApiGet '/api/events?since=0'
163
+ if (-not ($history -and $history.messages)) { return }
164
+ $recent = @($history.messages) | Select-Object -Last 8
165
+ $script:lastMsgId = 0
166
+ foreach ($m in $recent) {
167
+ if ($m.id -gt $script:lastMsgId) { $script:lastMsgId = $m.id }
168
+ if ($m.role -eq 'her' -or $m.role -eq 'user') { Append-Chat $m.role $m.text }
169
+ }
170
+ }
171
+ function Send-Chat() {
172
+ $text = $script:chatBox.Text.Trim()
173
+ if (-not $text) { return }
174
+ $script:chatBox.Text = ''
175
+ Append-Chat 'user' $text
176
+ $r = ApiPost '/api/chat' @{ text = $text }
177
+ if ($r -and -not $r.ok) { Append-Chat 'her' ('(' + $r.error + ')') }
178
+ }
179
+ function Clamp-Window() {
180
+ try {
181
+ $wa = [System.Windows.SystemParameters]::WorkArea
182
+ $w = [math]::Max(160, $script:win.ActualWidth)
183
+ $h = [math]::Max(120, $script:win.ActualHeight)
184
+ if ($script:win.Left -lt $wa.Left + 8) { $script:win.Left = $wa.Left + 8 }
185
+ if ($script:win.Top -lt $wa.Top + 8) { $script:win.Top = $wa.Top + 8 }
186
+ if ($script:win.Left + $w -gt $wa.Right - 8) { $script:win.Left = $wa.Right - $w - 8 }
187
+ if ($script:win.Top + $h -gt $wa.Bottom - 8) { $script:win.Top = $wa.Bottom - $h - 8 }
188
+ } catch {}
189
+ }
190
+ function Toggle-Chat() {
191
+ if (-not $script:chatPanel) { return }
192
+ $script:suppressSave = $true
193
+ $visible = $script:chatPanel.Visibility -eq 'Visible'
194
+ if ($visible) {
195
+ $h0 = $script:win.ActualHeight
196
+ $script:chatPanel.Visibility = 'Collapsed'
197
+ $script:chatTimer.Stop()
198
+ $script:win.UpdateLayout()
199
+ $script:win.Top = $script:win.Top + [math]::Max(0, ($h0 - $script:win.ActualHeight))
200
+ } else {
201
+ $h0 = $script:win.ActualHeight
202
+ $script:chatPanel.Visibility = 'Visible'
203
+ $script:chatTimer.Start()
204
+ $script:win.UpdateLayout()
205
+ $script:win.Top = $script:win.Top - [math]::Max(0, ($script:win.ActualHeight - $h0))
206
+ Load-ChatHistory
207
+ $script:chatBox.Focus() | Out-Null
208
+ }
209
+ Clamp-Window
210
+ $script:win.UpdateLayout()
211
+ $script:suppressSave = $false
212
+ }
213
+ function Refresh-Status() {
214
+ $st = ApiGet '/api/pet/status'
215
+ if (-not $st -or -not $st.ok) {
216
+ if ($script:statusText) { $script:statusText.Text = '连不上她…' }
217
+ return
218
+ }
219
+ $script:status = $st
220
+ if ($st.character -and $st.character.avatar -and $st.character.avatar -ne $script:lastAvatarUrl) {
221
+ $script:lastAvatarUrl = $st.character.avatar
222
+ Update-Image
223
+ }
224
+ if ($st.tokens) {
225
+ $t = $st.tokens
226
+ $script:tokenText.Text = '余 ' + (Fmt-Tok $t.remainingTokens) + ' · 今日 ' + (Fmt-Tok $t.dailyUsed)
227
+ $script:tokenText.ToolTip = ('剩余 token:{0:N0}{1}今日已用:{2:N0}{3}预算:{4:N0}' -f [double]$t.remainingTokens, [Environment]::NewLine, [double]$t.dailyUsed, [Environment]::NewLine, [double]$t.tokenBudget)
228
+ }
229
+ if ($script:bubbleUntil -gt (Get-Date)) { return }
230
+ $loc = ''; if ($st.place) { $loc = [string]$st.place.icon + ' ' + [string]$st.place.name }
231
+ $act = if ($st.activity) { [string]$st.activity } elseif ($st.state -eq 'traveling') { '在路上' } elseif ($st.state -eq 'intercity') { '出远门' } else { '待着' }
232
+ $en = if ($st.energy) { [string]$st.energy } else { '' }
233
+ if ($st.state -eq 'traveling') {
234
+ $txt = ([string]$st.character.name) + ' · 在路上'
235
+ } else {
236
+ $txt = ([string]$st.character.name) + ' · ' + $loc + ' · ' + $act
237
+ }
238
+ if ($en) { $txt += ' · ' + $en }
239
+ $script:statusText.Text = $txt
240
+ if ($st.lastHer -and $st.lastHer.id -gt $script:lastBubbleId) {
241
+ $script:lastBubbleId = [int]$st.lastHer.id
242
+ $line = [string]$st.lastHer.text
243
+ if ($line.Length -gt 60) { $line = $line.Substring(0, 60) + '…' }
244
+ Show-Bubble ('她:' + $line) 9000
245
+ if ($script:chatPanel -and $script:chatPanel.Visibility -eq 'Visible') { }
246
+ }
247
+ }
248
+ function Build-Button($b) {
249
+ $border = New-Object System.Windows.Controls.Border
250
+ $border.Width = 40; $border.Height = 30
251
+ $border.CornerRadius = [System.Windows.CornerRadius]::new(15)
252
+ $border.Background = Brush '#D91E3A5F'
253
+ $border.BorderBrush = Brush '#55A8CFFF'
254
+ $border.BorderThickness = [System.Windows.Thickness]::new(1)
255
+ $border.Margin = [System.Windows.Thickness]::new(4, 2, 4, 2)
256
+ $border.Cursor = [System.Windows.Input.Cursors]::Hand
257
+ if ([string]$b.icon -eq 'chat-line') {
258
+ $grid = New-Object System.Windows.Controls.Grid
259
+ $bubble = New-Object System.Windows.Shapes.Path
260
+ $bubble.Data = [System.Windows.Media.Geometry]::Parse('M4.5,6 C4.5,4.6 5.6,3.5 7,3.5 L17,3.5 C18.4,3.5 19.5,4.6 19.5,6 L19.5,13 C19.5,14.4 18.4,15.5 17,15.5 L10.2,15.5 L6.5,18.7 L6.5,15.5 C5.3,15.5 4.5,14.7 4.5,13.5 Z')
261
+ $bubble.Stroke = Brush '#CFE6FF'
262
+ $bubble.StrokeThickness = 1.5
263
+ $bubble.StrokeStartLineCap = 'Round'; $bubble.StrokeEndLineCap = 'Round'; $bubble.StrokeLineJoin = 'Round'
264
+ $bubble.Stretch = 'Uniform'
265
+ $bubble.Width = 19; $bubble.Height = 17
266
+ $bubble.HorizontalAlignment = 'Center'; $bubble.VerticalAlignment = 'Center'
267
+ $dots = New-Object System.Windows.Controls.StackPanel
268
+ $dots.Orientation = 'Horizontal'
269
+ $dots.HorizontalAlignment = 'Center'; $dots.VerticalAlignment = 'Center'
270
+ $dots.Margin = [System.Windows.Thickness]::new(0, 0, 0, 1)
271
+ foreach ($i in 1..3) {
272
+ $d = New-Object System.Windows.Shapes.Ellipse
273
+ $d.Width = 2.2; $d.Height = 2.2
274
+ $d.Fill = Brush '#CFE6FF'
275
+ $d.Margin = [System.Windows.Thickness]::new(1.1, 0, 1.1, 0)
276
+ $dots.Children.Add($d) | Out-Null
277
+ }
278
+ $grid.Children.Add($bubble) | Out-Null
279
+ $grid.Children.Add($dots) | Out-Null
280
+ $border.Child = $grid
281
+ } elseif ([string]$b.icon -eq 'eye-line') {
282
+ $grid = New-Object System.Windows.Controls.Grid
283
+ $eye = New-Object System.Windows.Shapes.Path
284
+ $eye.Data = [System.Windows.Media.Geometry]::Parse('M2.5,12 C5.5,7.5 9,6 12,6 C15,6 18.5,7.5 21.5,12 C18.5,16.5 15,18 12,18 C9,18 5.5,16.5 2.5,12 Z')
285
+ $eye.Stroke = Brush '#CFE6FF'
286
+ $eye.StrokeThickness = 1.5
287
+ $eye.StrokeStartLineCap = 'Round'; $eye.StrokeEndLineCap = 'Round'
288
+ $eye.Stretch = 'Uniform'
289
+ $eye.Width = 19; $eye.Height = 16
290
+ $eye.HorizontalAlignment = 'Center'; $eye.VerticalAlignment = 'Center'
291
+ $pupil = New-Object System.Windows.Shapes.Ellipse
292
+ $pupil.Width = 4.8; $pupil.Height = 4.8
293
+ $pupil.Fill = Brush '#CFE6FF'
294
+ $pupil.HorizontalAlignment = 'Center'; $pupil.VerticalAlignment = 'Center'
295
+ $grid.Children.Add($eye) | Out-Null
296
+ $grid.Children.Add($pupil) | Out-Null
297
+ $border.Child = $grid
298
+ } else {
299
+ $tb = New-Object System.Windows.Controls.TextBlock
300
+ $tb.Text = [string]$b.label
301
+ $tb.FontSize = 15
302
+ $tb.HorizontalAlignment = 'Center'; $tb.VerticalAlignment = 'Center'
303
+ $border.Child = $tb
304
+ }
305
+ $border.ToolTip = if ($b.title) { [string]$b.title } else { [string]$b.label }
306
+ $border.Tag = [pscustomobject]@{ action = [string]$b.action; prompt = [string]$b.prompt; id = [string]$b.id }
307
+ $border.Add_MouseEnter({ param($s,$e) $s.Background = Brush '#F22E5A86' })
308
+ $border.Add_MouseLeave({ param($s,$e) $s.Background = Brush '#D91E3A5F' })
309
+ $border.Add_MouseLeftButtonUp({
310
+ param($s, $e)
311
+ $script:isDragging = $false
312
+ $tag = $s.Tag
313
+ switch ([string]$tag.action) {
314
+ 'chat' { Toggle-Chat }
315
+ 'look' { Send-Act 'look' $null $null }
316
+ 'prompt' { if ($tag.prompt) { Send-Act 'gesture' $null ([string]$tag.prompt) } }
317
+ default {
318
+ if ($tag.prompt) { Send-Act 'gesture' $null ([string]$tag.prompt) }
319
+ else { Send-Act 'gesture' ([string]$tag.id) $null }
320
+ }
321
+ }
322
+ })
323
+ return $border
324
+ }
325
+
326
+ Load-Manifest
327
+ $win = New-Object System.Windows.Window
328
+ $win.Title = '屏幕彼端 · 桌宠'
329
+ $win.WindowStyle = 'None'
330
+ $win.AllowsTransparency = $true
331
+ $win.Background = [System.Windows.Media.Brushes]::Transparent
332
+ $win.Topmost = $true
333
+ $win.ShowInTaskbar = $false
334
+ $win.ResizeMode = 'NoResize'
335
+ $win.SizeToContent = 'WidthAndHeight'
336
+ $script:win = $win
337
+ if ($script:pet.alwaysOnTop -eq $false) { $win.Topmost = $false }
338
+ try { $op = [double]$script:pet.opacity; if ($op -gt 0 -and $op -le 1) { $win.Opacity = $op } } catch {}
339
+
340
+ $outer = New-Object System.Windows.Controls.StackPanel
341
+ $outer.HorizontalAlignment = 'Center'
342
+ $win.Content = $outer
343
+
344
+ # 聊天气泡面板(默认收起)
345
+ $chatBorder = New-Object System.Windows.Controls.Border
346
+ $chatBorder.Width = 260
347
+ $chatBorder.CornerRadius = [System.Windows.CornerRadius]::new(16)
348
+ $chatBorder.Background = Brush '#E60F1B2B'
349
+ $chatBorder.BorderBrush = Brush '#66A8CFFF'
350
+ $chatBorder.BorderThickness = [System.Windows.Thickness]::new(1)
351
+ $chatBorder.Padding = [System.Windows.Thickness]::new(10)
352
+ $chatBorder.Margin = [System.Windows.Thickness]::new(0, 0, 0, 8)
353
+ $chatBorder.Visibility = 'Collapsed'
354
+ $script:chatPanel = $chatBorder
355
+ $chatInner = New-Object System.Windows.Controls.StackPanel
356
+
357
+ $chatHead = New-Object System.Windows.Controls.StackPanel
358
+ $chatHead.Orientation = 'Horizontal'
359
+ $chatTitle = New-Object System.Windows.Controls.TextBlock
360
+ $chatTitle.Text = '和她说说话'
361
+ $chatTitle.FontSize = 12
362
+ $chatTitle.Foreground = Brush '#BFE0FF'
363
+ $chatTitle.VerticalAlignment = 'Center'
364
+ $chatHead.Children.Add($chatTitle) | Out-Null
365
+ $closeBtn = New-Object System.Windows.Controls.Button
366
+ $closeBtn.Content = '✕'
367
+ $closeBtn.Width = 22; $closeBtn.Height = 22
368
+ $closeBtn.Margin = [System.Windows.Thickness]::new(142, 0, 0, 0)
369
+ $closeBtn.Background = [System.Windows.Media.Brushes]::Transparent
370
+ $closeBtn.Foreground = Brush '#8FB9FF'
371
+ $closeBtn.BorderThickness = [System.Windows.Thickness]::new(0)
372
+ $closeBtn.Cursor = [System.Windows.Input.Cursors]::Hand
373
+ $closeBtn.Add_Click({ Toggle-Chat })
374
+ $chatHead.Children.Add($closeBtn) | Out-Null
375
+ $chatInner.Children.Add($chatHead) | Out-Null
376
+
377
+ $script:chatScroll = New-Object System.Windows.Controls.ScrollViewer
378
+ $script:chatScroll.Height = 200
379
+ $script:chatScroll.VerticalScrollBarVisibility = 'Auto'
380
+ $script:chatScroll.HorizontalScrollBarVisibility = 'Disabled'
381
+ $script:chatScroll.Margin = [System.Windows.Thickness]::new(0, 6, 0, 6)
382
+ $script:chatStack = New-Object System.Windows.Controls.StackPanel
383
+ $script:chatScroll.Content = $script:chatStack
384
+ $chatInner.Children.Add($script:chatScroll) | Out-Null
385
+
386
+ $inputRow = New-Object System.Windows.Controls.StackPanel
387
+ $inputRow.Orientation = 'Horizontal'
388
+ $script:chatBox = New-Object System.Windows.Controls.TextBox
389
+ $script:chatBox.Width = 188; $script:chatBox.Height = 38
390
+ $script:chatBox.FontSize = 13; $script:chatBox.TextWrapping = 'Wrap'
391
+ $script:chatBox.Background = Brush '#16263A'
392
+ $script:chatBox.Foreground = Brush '#E8F2FF'
393
+ $script:chatBox.BorderThickness = [System.Windows.Thickness]::new(0)
394
+ $chatSend = New-Object System.Windows.Controls.Button
395
+ $chatSend.Content = '发送'; $chatSend.Width = 44; $chatSend.Height = 38
396
+ $chatSend.Margin = [System.Windows.Thickness]::new(6, 0, 0, 0)
397
+ $chatSend.Add_Click({ Send-Chat })
398
+ $script:chatBox.Add_KeyDown({ param($s, $e) if ($e.Key -eq 'Enter') { Send-Chat } })
399
+ $inputRow.Children.Add($script:chatBox) | Out-Null
400
+ $inputRow.Children.Add($chatSend) | Out-Null
401
+ $chatInner.Children.Add($inputRow) | Out-Null
402
+ $chatBorder.Child = $chatInner
403
+ $outer.Children.Add($chatBorder) | Out-Null
404
+
405
+ # 形象
406
+ $script:img = New-Object System.Windows.Controls.Image
407
+ $script:img.Stretch = 'Uniform'
408
+ $script:img.Cursor = [System.Windows.Input.Cursors]::Hand
409
+ $script:img.Margin = [System.Windows.Thickness]::new(0, 0, 0, 4)
410
+ $outer.Children.Add($script:img) | Out-Null
411
+
412
+ # 状态气泡
413
+ $statusBorder = New-Object System.Windows.Controls.Border
414
+ $statusBorder.CornerRadius = [System.Windows.CornerRadius]::new(12)
415
+ $statusBorder.Background = Brush '#D90B1526'
416
+ $statusBorder.BorderBrush = Brush '#33A8CFFF'
417
+ $statusBorder.BorderThickness = [System.Windows.Thickness]::new(1)
418
+ $statusBorder.Padding = [System.Windows.Thickness]::new(10, 5, 10, 5)
419
+ $statusBorder.HorizontalAlignment = 'Center'
420
+ $script:statusText = New-Object System.Windows.Controls.TextBlock
421
+ $script:statusText.Foreground = Brush '#CFE6FF'
422
+ $script:statusText.FontSize = 12
423
+ $script:statusText.MaxWidth = 280
424
+ $script:statusText.TextWrapping = 'Wrap'
425
+ $script:statusText.Text = '正在连接她…'
426
+ $statusBorder.Child = $script:statusText
427
+ $outer.Children.Add($statusBorder) | Out-Null
428
+ if ($script:pet.statusText -eq $false) { $statusBorder.Visibility = 'Collapsed' }
429
+
430
+ # token 行
431
+ $script:tokenText = New-Object System.Windows.Controls.TextBlock
432
+ $script:tokenText.FontSize = 11
433
+ $script:tokenText.Foreground = Brush '#8FB9FF'
434
+ $script:tokenText.Opacity = 0.9
435
+ $script:tokenText.HorizontalAlignment = 'Center'
436
+ $script:tokenText.Margin = [System.Windows.Thickness]::new(0, 4, 0, 2)
437
+ $script:tokenText.Text = '余 — · 今日 —'
438
+ $outer.Children.Add($script:tokenText) | Out-Null
439
+
440
+ # 按钮
441
+ $btnPanel = New-Object System.Windows.Controls.StackPanel
442
+ $btnPanel.Orientation = 'Horizontal'
443
+ $btnPanel.HorizontalAlignment = 'Center'
444
+ $btnPanel.Margin = [System.Windows.Thickness]::new(0, 3, 0, 0)
445
+ foreach ($b in @($script:pet.buttons)) { $btnPanel.Children.Add((Build-Button $b)) | Out-Null }
446
+ $outer.Children.Add($btnPanel) | Out-Null
447
+
448
+ # 位置
449
+ $x = 1200; $y = 760
450
+ try {
451
+ if (Test-Path $UiFile) { $ui = Get-Content $UiFile -Raw | ConvertFrom-Json; $x = [double]$ui.x; $y = [double]$ui.y }
452
+ else {
453
+ $wa = [System.Windows.SystemParameters]::WorkArea
454
+ $x = $wa.Right - 380; $y = $wa.Bottom - 400
455
+ }
456
+ } catch {}
457
+ $win.Left = $x; $win.Top = $y
458
+
459
+ $win.Add_LocationChanged({ if ($script:suppressSave) { return }; try { @{ x = [math]::Round($script:win.Left); y = [math]::Round($script:win.Top) } | ConvertTo-Json -Compress | Set-Content $UiFile -Encoding UTF8 } catch {} })
460
+
461
+ # 拖动 + 点击手势
462
+ $script:img.Add_MouseLeftButtonDown({ param($s, $e) $script:downPoint = $e.GetPosition($script:win); $script:isDragging = $false })
463
+ $script:img.Add_MouseMove({
464
+ param($s, $e)
465
+ if ($e.LeftButton -eq 'Pressed' -and $script:downPoint) {
466
+ $p = $e.GetPosition($script:win)
467
+ if (([math]::Abs($p.X - $script:downPoint.X) + [math]::Abs($p.Y - $script:downPoint.Y)) -gt 6) {
468
+ $script:isDragging = $true
469
+ try { $script:win.DragMove() } catch {}
470
+ }
471
+ }
472
+ })
473
+ $script:img.Add_MouseLeftButtonUp({
474
+ param($s, $e)
475
+ if ($script:isDragging) { $script:isDragging = $false; return }
476
+ $p = $e.GetPosition($script:img)
477
+ $nx = $p.X / [math]::Max(1, $script:img.ActualWidth)
478
+ $ny = $p.Y / [math]::Max(1, $script:img.ActualHeight)
479
+ $g = Gesture-At $nx $ny
480
+ if ($g) { if ($g.bubble) { Show-Bubble ([string]$g.bubble) 3500 }; Send-Act 'gesture' ([string]$g.id) ([string]$g.prompt) }
481
+ })
482
+
483
+ # 右键菜单
484
+ $cm = New-Object System.Windows.Controls.ContextMenu
485
+ foreach ($item in @(
486
+ @{ h = '和她说话'; a = { Toggle-Chat } },
487
+ @{ h = '看看我的屏幕'; a = { Send-Act 'look' $null $null } },
488
+ @{ h = '刷新状态'; a = { Refresh-Status } },
489
+ @{ h = '重新加载桌宠'; a = { Load-Manifest; Update-Image; Refresh-Status } },
490
+ @{ h = '退出桌宠'; a = { $script:win.Close() } }
491
+ )) {
492
+ $mi = New-Object System.Windows.Controls.MenuItem
493
+ $mi.Header = $item.h
494
+ $mi.Add_Click($item.a)
495
+ $cm.Items.Add($mi) | Out-Null
496
+ }
497
+ $outer.ContextMenu = $cm
498
+ $script:img.ContextMenu = $cm
499
+ $statusBorder.ContextMenu = $cm
500
+ $script:tokenText.ContextMenu = $cm
501
+
502
+ $script:chatTimer = New-Object System.Windows.Threading.DispatcherTimer
503
+ $script:chatTimer.Interval = [TimeSpan]::FromSeconds(1)
504
+ $script:chatTimer.Add_Tick({
505
+ if (-not $script:chatPanel -or $script:chatPanel.Visibility -ne 'Visible') { return }
506
+ $ev = ApiGet ('/api/events?since=' + $script:lastMsgId)
507
+ if ($ev -and $ev.messages) {
508
+ foreach ($m in @($ev.messages)) {
509
+ if ($m.id -le $script:lastMsgId) { continue }
510
+ $script:lastMsgId = $m.id
511
+ if ($m.role -eq 'her') { Append-Chat 'her' $m.text }
512
+ }
513
+ }
514
+ })
515
+ $script:mainTimer = New-Object System.Windows.Threading.DispatcherTimer
516
+ $script:mainTimer.Interval = [TimeSpan]::FromSeconds(5)
517
+ $script:mainTimer.Add_Tick({ Refresh-Status })
518
+
519
+ $win.Add_Loaded({
520
+ Update-Image
521
+ Refresh-Status
522
+ $script:mainTimer.Start()
523
+ $script:clampTimer = New-Object System.Windows.Threading.DispatcherTimer
524
+ $script:clampTimer.Interval = [TimeSpan]::FromMilliseconds(250)
525
+ $script:clampTimer.Add_Tick({ $script:clampTimer.Stop(); Clamp-Window })
526
+ $script:clampTimer.Start()
527
+ })
528
+ $win.Add_Closed({ try { $script:mainTimer.Stop() } catch {}; try { $script:chatTimer.Stop() } catch {} })
529
+
530
+ Log 'pet starting'
531
+ $win.ShowDialog() | Out-Null
532
+ Log 'pet closed'
@@ -0,0 +1,125 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>看屏幕 · 屏幕彼端</title>
3
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <style>
5
+ :root{color-scheme:dark}
6
+ *{box-sizing:border-box}
7
+ html,body{margin:0;height:100%}
8
+ body{background:#060b14;color:#e9f0fb;font:13px/1.6 "Segoe UI","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;height:100vh}
9
+ header{display:flex;align-items:center;gap:10px;padding:9px 16px;border-bottom:1px solid rgba(130,175,255,.14);background:linear-gradient(180deg,rgba(16,27,45,.92),rgba(10,17,29,.88));flex-wrap:wrap}
10
+ header h1{font-size:15px;margin:0;font-weight:600}
11
+ header .btn{background:rgba(22,35,55,.85);border:1px solid rgba(130,175,255,.3);color:#e9f0fb;border-radius:10px;padding:5px 12px;font:inherit;cursor:pointer;font-size:12.5px}
12
+ header .btn.on{background:linear-gradient(135deg,#4f8dff,#7b6bff);border-color:transparent;box-shadow:0 6px 18px rgba(79,141,255,.35)}
13
+ header .spacer{flex:1}
14
+ header a{color:#8fbf7f;text-decoration:none;font-size:12px;padding:5px 11px;border:1px solid rgba(130,175,255,.25);border-radius:10px}
15
+ #view{flex:1;position:relative;overflow:hidden;background:#060b14}
16
+ #view iframe,#view img{position:absolute;inset:0;width:100%;height:100%;border:0;object-fit:contain;background:#060b14}
17
+ #hint{padding:6px 16px;font-size:11.5px;color:#8ea4c4;border-top:1px solid rgba(130,175,255,.12);background:rgba(10,17,29,.9)}
18
+ #msg{position:absolute;inset:0;display:none;align-items:center;justify-content:center;color:#8ea4c4;font-size:13px;padding:20px;text-align:center}
19
+ </style></head><body>
20
+ <header>
21
+ <h1>👁️ 看屏幕</h1>
22
+ <button class="btn on" id="bSpace" onclick="show('space')">她自己的屏幕</button>
23
+ <button class="btn" id="bPc" onclick="show('pc')">你的电脑</button>
24
+ <button class="btn" id="bPhone" onclick="show('phone')">她的手机</button>
25
+ <span class="spacer"></span>
26
+ <button class="btn" id="btnSpacePower" onclick="togglePower('space')">关闭她的屏幕</button>
27
+ <button class="btn" id="btnPhonePower" onclick="togglePower('phone')">关闭她的手机</button>
28
+ <a href="/" target="_blank">回到聊天</a>
29
+ </header>
30
+ <div id="view"><div id="msg">加载中…</div></div>
31
+ <div id="hint">她的屏幕可以直接在上面点鼠标、敲键盘(noVNC 交互)· 快捷键 Alt+S 从聊天页打开这里</div>
32
+ <script>
33
+ const $ = (s) => document.querySelector(s);
34
+ let timer = null, mode = 'space', spaceTimer = null;
35
+ async function checkSpace(){
36
+ const dot = document.getElementById('spaceDot'), txt = document.getElementById('spaceText');
37
+ if (!dot || !txt) return;
38
+ try {
39
+ const r = await (await fetch('/api/screen/space/status')).json();
40
+ if (r.http) { dot.style.background = '#22c55e'; txt.textContent = '已连接'; }
41
+ else if (!r.vm) { dot.style.background = '#ef4444'; txt.innerHTML = '她的屏幕没开 <button onclick="ensureSpace()" style="margin-left:6px;background:#2563eb;color:#fff;border:0;border-radius:7px;padding:2px 8px;font-size:11px;cursor:pointer">启动</button>'; }
42
+ else { dot.style.background = '#f59e0b'; txt.innerHTML = '连接掉了 <button onclick="ensureSpace()" style="margin-left:6px;background:#2563eb;color:#fff;border:0;border-radius:7px;padding:2px 8px;font-size:11px;cursor:pointer">修复屏幕</button>'; }
43
+ } catch (e) { dot.style.background = '#ef4444'; txt.textContent = '运行时连不上'; }
44
+ }
45
+ async function ensureSpace(){
46
+ const txt = document.getElementById('spaceText');
47
+ if (txt) txt.textContent = '正在修复…';
48
+ try {
49
+ await fetch('/api/screen/space/ensure', { method: 'POST', headers: {'content-type':'application/json'}, body: '{}' });
50
+ } catch (e) {}
51
+ const f = document.getElementById('spaceFrame');
52
+ if (f) f.src = 'http://127.0.0.1:6081/vnc.html?autoconnect=1&reconnect=1&reconnect_delay=2000&resize=scale&t=' + Date.now();
53
+ setTimeout(checkSpace, 1500);
54
+ }
55
+ function clearView(){ const v=$('#view'); [...v.children].forEach(c=>{ if(c.id!=='msg') c.remove(); }); if(timer){clearInterval(timer);timer=null;} if(spaceTimer){clearInterval(spaceTimer);spaceTimer=null;} }
56
+ function setOn(id){ for(const k of ['bSpace','bPc','bPhone']) $('#'+k).classList.toggle('on', k===id); }
57
+ function msg(text){ const m=$('#msg'); m.style.display='flex'; m.textContent=text; }
58
+ function hideMsg(){ $('#msg').style.display='none'; }
59
+ function show(which){
60
+ mode = which; clearView();
61
+ setOn(which==='space'?'bSpace':which==='pc'?'bPc':'bPhone');
62
+ if (which === 'space') {
63
+ hideMsg();
64
+ const f = document.createElement('iframe');
65
+ f.id = 'spaceFrame';
66
+ f.src = 'http://127.0.0.1:6081/vnc.html?autoconnect=1&reconnect=1&reconnect_delay=2000&resize=scale';
67
+ f.title = '她的屏幕';
68
+ $('#view').appendChild(f);
69
+ const bar = document.createElement('div');
70
+ bar.id = 'spaceBar';
71
+ bar.style.cssText = 'position:absolute;left:50%;top:10px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;background:rgba(10,18,31,.9);border:1px solid rgba(130,175,255,.3);border-radius:10px;padding:6px 12px;font-size:12px;z-index:3';
72
+ bar.innerHTML = '<span id="spaceDot" style="width:8px;height:8px;border-radius:50%;background:#f59e0b"></span><span id="spaceText">连接中…</span>';
73
+ $('#view').appendChild(bar);
74
+ checkSpace();
75
+ if (spaceTimer) clearInterval(spaceTimer);
76
+ spaceTimer = setInterval(checkSpace, 8000);
77
+ $('#hint').textContent = '这是她自己的 Linux 屏幕(noVNC,可直接操作);断开会自动重连。屏幕虚拟机约占用 2~6 GB 内存,不用时可点右上角关闭。';
78
+ return;
79
+ }
80
+ hideMsg();
81
+ const img = document.createElement('img');
82
+ img.id = 'snap';
83
+ if (which === 'pc') { img.src = '/api/screen/pc?t=' + Date.now(); img.alt = '你的电脑屏幕'; $('#hint').textContent = '这是你的电脑屏幕(只读截图,自动刷新);要操作请让她用 pc 工具。'; }
84
+ else {
85
+ const box = document.createElement('div');
86
+ box.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;gap:14px;align-items:center;justify-content:center;text-align:center;padding:24px;color:#bcd2ee';
87
+ box.innerHTML = '<div style="font-size:34px">📱</div><div>她的手机是 Waydroid,运行在她的屏幕里(GNOME 桌面上的窗口)。</div><div style="color:#8ea4c4;font-size:12px">点左边「她自己的屏幕」就能看到她手机,还能直接点。</div>';
88
+ const b = document.createElement('button');
89
+ b.className = 'btn on';
90
+ b.textContent = '打开她的屏幕';
91
+ b.onclick = () => show('space');
92
+ b.style.cssText = 'background:linear-gradient(135deg,#4f8dff,#7b6bff);border:0;border-radius:10px;padding:8px 16px;color:#fff;cursor:pointer';
93
+ box.appendChild(b);
94
+ $('#view').appendChild(box);
95
+ hideMsg();
96
+ $('#hint').textContent = '手机在她的屏幕里,直接看她屏幕即可。';
97
+ return;
98
+ }
99
+ img.onerror = () => msg('截不到画面:' + (which === 'pc' ? '桌面内核没运行' : '手机/模拟器没启动'));
100
+ img.onload = hideMsg;
101
+ $('#view').appendChild(img);
102
+ timer = setInterval(() => {
103
+ const el = document.getElementById('snap');
104
+ if (el) el.src = '/api/screen/' + (mode === 'pc' ? 'pc' : 'phone') + '?t=' + Date.now();
105
+ }, which === 'pc' ? 2500 : 3000);
106
+ }
107
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') window.close(); });
108
+ async function togglePower(kind){
109
+ const label = kind === 'space' ? '她的屏幕' : '她的手机';
110
+ try {
111
+ const r = await (await fetch('/api/resources')).json();
112
+ const running = !!r[kind];
113
+ if (running && !confirm('关掉' + label + '?关掉后她就不能看/用这个环境了(屏幕约 2~6GB、手机约 2~4GB 内存会释放)。')) return;
114
+ const act = running ? 'stop' : 'start';
115
+ const out = await (await fetch('/api/' + kind + '/' + act, { method: 'POST', headers: {'content-type':'application/json'}, body: '{}' })).json();
116
+ const tip = act === 'stop' ? ('已关闭' + label + ',释放了内存') : ('正在启动' + label + '(约 1 分钟)');
117
+ if (r.http && running) { /* space 关闭时同步更新状态 */ }
118
+ $('#hint').textContent = (out && out.message) || tip;
119
+ if (kind === 'space' && running) { const f = document.getElementById('spaceFrame'); if (f) f.src = 'about:blank'; }
120
+ if (kind === 'phone' && running) { const img = document.getElementById('snap'); if (img) img.src = ''; }
121
+ setTimeout(checkSpace, 1500);
122
+ } catch (e) { $('#hint').textContent = '操作失败:' + e.message; }
123
+ }
124
+ show(new URLSearchParams(location.search).get('v') || 'space');
125
+ </script></body></html>