dsh-fish-pet 0.2.0 → 0.2.2

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.
@@ -1,1002 +1,1666 @@
1
- # ============================================================
2
- # 桌面蓝鱼 (Desktop Blue Fish) - DSH 插件版
3
- # WinForms layered window + GDI+ 逐帧绘制,无外部依赖。
4
- # 左键拖动 / 单击喂食,右键菜单。
5
- # ============================================================
6
-
7
- param(
8
- [double]$Scale = 0.0,
9
- [int]$Speed = 0,
10
- [string]$Say = '',
11
- [switch]$Listen
12
- )
13
-
14
- $ErrorActionPreference = 'Stop'
15
- Add-Type -AssemblyName System.Windows.Forms
16
- Add-Type -AssemblyName System.Drawing
17
-
18
- # 单实例:桌面上只允许一只鱼(防止插件守护与手动启动各放一只)
19
- $__pidFile = Join-Path (Join-Path $env:USERPROFILE '.dsh\fish-pet') 'pet.pid'
20
- if (Test-Path $__pidFile) {
21
- try {
22
- $__j = Get-Content $__pidFile -Raw | ConvertFrom-Json
23
- $__other = [int]$__j.pid
24
- if ($__other -gt 0 -and $__other -ne $PID -and (Get-Process -Id $__other -ErrorAction SilentlyContinue)) { exit 0 }
25
- } catch { }
26
- }
27
- # 立刻占坑,堵住插件守护在加载期间再放一只
28
- try {
29
- New-Item -ItemType Directory -Force -Path (Split-Path $__pidFile) | Out-Null
30
- $__json = [ordered]@{ pid = $PID; startedAt = (Get-Date).ToString('o'); script = $PSCommandPath } | ConvertTo-Json -Compress
31
- [IO.File]::WriteAllText($__pidFile, $__json, (New-Object Text.UTF8Encoding($false)))
32
- } catch { }
33
-
34
- # 异常兜底:不允许 WinForms 弹「未处理异常」模态框(那就是闪退现场)
35
- try {
36
- [System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException)
37
- [System.Windows.Forms.Application]::add_ThreadException({
38
- param($sender, $eventArgs)
39
- $line = (Get-Date -Format 'HH:mm:ss') + ' THREAD-EXC ' + $eventArgs.Exception.Message
40
- try { Add-Content -LiteralPath (Join-Path $env:USERPROFILE '.dsh\fish-pet\error.log') -Value $line -Encoding UTF8 } catch { }
41
- })
42
- } catch { }
43
-
44
- $Root = Split-Path -Parent $MyInvocation.MyCommand.Path
45
- $DataDir = Join-Path $env:USERPROFILE '.dsh\fish-pet'
46
- if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Force -Path $DataDir | Out-Null }
47
- $StatePath = Join-Path $DataDir 'state.json'
48
- $PidPath = Join-Path $DataDir 'pet.pid'
49
- $ErrorPath = Join-Path $DataDir 'error.log'
50
-
51
- # ---------- 分层窗口(逐像素 alpha,无锯齿毛边) ----------
52
- $cs = @"
53
- using System;
54
- using System.Drawing;
55
- using System.Runtime.InteropServices;
56
- using System.Windows.Forms;
57
-
58
- public class FishPetWindow : Form
59
- {
60
- [StructLayout(LayoutKind.Sequential)]
61
- private struct POINT { public int X; public int Y; }
62
-
63
- [StructLayout(LayoutKind.Sequential)]
64
- private struct SIZE { public int cx; public int cy; }
65
-
66
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
67
- private struct BLENDFUNCTION
68
- {
69
- public byte BlendOp;
70
- public byte BlendFlags;
71
- public byte SourceConstantAlpha;
72
- public byte AlphaFormat;
73
- }
74
-
75
- [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hWnd);
76
- [DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
77
- [DllImport("user32.dll")] private static extern bool UpdateLayeredWindow(IntPtr hwnd, IntPtr hdcDst,
78
- ref POINT pptDst, ref SIZE psize, IntPtr hdcSrc, ref POINT pptSrc,
79
- int crKey, ref BLENDFUNCTION pblend, int dwFlags);
80
- [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr hDC);
81
- [DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr hdc);
82
- [DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj);
83
- [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr hObject);
84
- [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
85
- [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
86
- int X, int Y, int cx, int cy, uint uFlags);
87
-
88
- private const int WS_EX_LAYERED = 0x00080000;
89
- private const int WS_EX_TOOLWINDOW = 0x00000080;
90
- private const int ULW_ALPHA = 0x00000002;
91
-
92
- public FishPetWindow()
93
- {
94
- this.FormBorderStyle = FormBorderStyle.None;
95
- this.ShowInTaskbar = false;
96
- this.TopMost = true;
97
- this.StartPosition = FormStartPosition.Manual;
98
- this.Text = "fish-pet";
99
- }
100
-
101
- protected override CreateParams CreateParams
102
- {
103
- get
104
- {
105
- CreateParams cp = base.CreateParams;
106
- cp.ExStyle |= WS_EX_LAYERED | WS_EX_TOOLWINDOW;
107
- return cp;
108
- }
109
- }
110
-
111
- public void ShowNow()
112
- {
113
- // A task launched with -WindowStyle Hidden can keep the first window
114
- // hidden; force it visible and back to the top of the z-order.
115
- ShowWindow(this.Handle, 5);
116
- SetWindowPos(this.Handle, (IntPtr)(-1), 0, 0, 0, 0, 0x0001 | 0x0002);
117
- }
118
-
119
- public void Push(Bitmap bmp, int x, int y)
120
- {
121
- IntPtr screenDc = GetDC(IntPtr.Zero);
122
- IntPtr memDc = CreateCompatibleDC(screenDc);
123
- IntPtr hBmp = IntPtr.Zero;
124
- IntPtr old = IntPtr.Zero;
125
- try
126
- {
127
- hBmp = bmp.GetHbitmap(Color.FromArgb(0));
128
- old = SelectObject(memDc, hBmp);
129
-
130
- POINT dst; dst.X = x; dst.Y = y;
131
- POINT src; src.X = 0; src.Y = 0;
132
- SIZE size; size.cx = bmp.Width; size.cy = bmp.Height;
133
-
134
- BLENDFUNCTION blend;
135
- blend.BlendOp = 0;
136
- blend.BlendFlags = 0;
137
- blend.SourceConstantAlpha = 255;
138
- blend.AlphaFormat = 1;
139
-
140
- UpdateLayeredWindow(this.Handle, screenDc, ref dst, ref size,
141
- memDc, ref src, 0, ref blend, ULW_ALPHA);
142
- }
143
- finally
144
- {
145
- if (old != IntPtr.Zero) SelectObject(memDc, old);
146
- if (hBmp != IntPtr.Zero) DeleteObject(hBmp);
147
- DeleteDC(memDc);
148
- ReleaseDC(IntPtr.Zero, screenDc);
149
- }
150
- }
151
- }
152
- "@
1
+ # ============================================================
2
+ # 桌面蓝鱼 (Desktop Blue Fish) - DSH 插件版
3
+ # WinForms layered window + GDI+ 逐帧绘制,无外部依赖。
4
+ # 左键拖动 / 单击喂食,右键菜单。
5
+ # ============================================================
6
+
7
+ param(
8
+ [double]$Scale = 0.0,
9
+ [int]$Speed = 0,
10
+ [string]$Say = '',
11
+ [switch]$Listen
12
+ )
13
+
14
+ $ErrorActionPreference = 'Stop'
15
+ Add-Type -AssemblyName System.Windows.Forms
16
+ try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
17
+ Add-Type -AssemblyName System.Drawing
18
+
19
+ # 单实例:系统互斥量(比 pid 文件可靠,彻底防双开)
20
+ $__pidFile = Join-Path (Join-Path $env:USERPROFILE '.dsh\fish-pet') 'pet.pid'
21
+ try {
22
+ $__mutex = New-Object System.Threading.Mutex($false, 'Global\dsh-fish-pet-single')
23
+ $__got = $false
24
+ try { $__got = $__mutex.WaitOne(0, $false) } catch { $__got = $false }
25
+ if (-not $__got) { exit 0 }
26
+ } catch { }
27
+ # 写 pid,供耳朵/守护识别
28
+ try {
29
+ New-Item -ItemType Directory -Force -Path (Split-Path $__pidFile) | Out-Null
30
+ $__json = [ordered]@{ pid = $PID; startedAt = (Get-Date).ToString('o'); script = $PSCommandPath } | ConvertTo-Json -Compress
31
+ [IO.File]::WriteAllText($__pidFile, $__json, (New-Object Text.UTF8Encoding($false)))
32
+ } catch { }
33
+
34
+ # 异常兜底:不允许 WinForms 弹「未处理异常」模态框(那就是闪退现场)
35
+ try {
36
+ [System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException)
37
+ [System.Windows.Forms.Application]::add_ThreadException({
38
+ param($sender, $eventArgs)
39
+ $line = (Get-Date -Format 'HH:mm:ss') + ' THREAD-EXC ' + $eventArgs.Exception.Message
40
+ try { Add-Content -LiteralPath (Join-Path $env:USERPROFILE '.dsh\fish-pet\error.log') -Value $line -Encoding UTF8 } catch { }
41
+ })
42
+ } catch { }
43
+
44
+ $Root = Split-Path -Parent $MyInvocation.MyCommand.Path
45
+ $DataDir = Join-Path $env:USERPROFILE '.dsh\fish-pet'
46
+ if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Force -Path $DataDir | Out-Null }
47
+ try { Remove-Item (Join-Path $DataDir 'asr.stop') -Force -ErrorAction SilentlyContinue } catch { }
48
+ $StatePath = Join-Path $DataDir 'state.json'
49
+ $ConfigPath = Join-Path $DataDir 'config.json'
50
+ function Load-Config {
51
+ $def = [ordered]@{
52
+ persona = [ordered]@{ name = '大肥鱼'; dad = '光光'; mom = '丽丽'; birthday = '2026年10月30日'; birthplace = '中国广西北海'; likes = '白饭、被摸头、主人夸它'; dislikes = '被说胖、被冷落、吵'; ownerBirthday = ''; extra = '你说话简短、俏皮、微微傲娇,用中文,称呼用户为"主人",每次回复不超过60个字,不要用表情符号。' }
53
+ bubble = [ordered]@{ opacity = 0.15 }
54
+ voice = [ordered]@{ silenceGate = 1200; boost = 1; engine = 'cloud'; localConf = 0.55 }
55
+ tts = [ordered]@{ engine = 'edge'; voice = 'zh-CN-XiaoxiaoNeural'; rate = '+12%'; pitch = '+10Hz' }
56
+ }
57
+ $script:cfg = $def
58
+ try {
59
+ if (-not (Test-Path $ConfigPath)) {
60
+ [IO.File]::WriteAllText($ConfigPath, ($def | ConvertTo-Json -Depth 5), (New-Object Text.UTF8Encoding($false)))
61
+ }
62
+ $j = [IO.File]::ReadAllText($ConfigPath, [Text.Encoding]::UTF8) | ConvertFrom-Json
63
+ if ($j.persona) { foreach ($k in @('name','dad','mom','extra','birthday','birthplace','likes','dislikes','ownerBirthday')) { $v = $j.persona.$k; if ($v) { $script:cfg.persona[$k] = [string]$v } } }
64
+ if ($j.bubble -and $j.bubble.opacity) { $script:cfg.bubble.opacity = [double]$j.bubble.opacity }
65
+ if ($j.tts) { foreach ($k2 in @('engine','voice','rate','pitch')) { $v2 = $j.tts.$k2; if ($v2) { $script:cfg.tts[$k2] = [string]$v2 } } }
66
+ if ($j.voice) {
67
+ if ($j.voice.silenceGate) { $script:cfg.voice.silenceGate = [int]$j.voice.silenceGate }
68
+ if ($j.voice.boost) { $script:cfg.voice.boost = [int]$j.voice.boost }
69
+ }
70
+ } catch { $script:cfg = $def }
71
+ }
72
+ Load-Config
73
+ $script:ttsEngine = $script:cfg.tts.engine
74
+ $script:ttsVoice = $script:cfg.tts.voice
75
+ $script:ttsRate = $script:cfg.tts.rate
76
+ $script:ttsPitch = $script:cfg.tts.pitch
77
+ $PidPath = Join-Path $DataDir 'pet.pid'
78
+ $ErrorPath = Join-Path $DataDir 'error.log'
79
+
80
+ # ---------- 分层窗口(逐像素 alpha,无锯齿毛边) ----------
81
+ $cs = @"
82
+ using System;
83
+ using System.Drawing;
84
+ using System.Runtime.InteropServices;
85
+ using System.Windows.Forms;
86
+
87
+ public class FishPetWindow : Form
88
+ {
89
+ [StructLayout(LayoutKind.Sequential)]
90
+ private struct POINT { public int X; public int Y; }
91
+
92
+ [StructLayout(LayoutKind.Sequential)]
93
+ private struct SIZE { public int cx; public int cy; }
94
+
95
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
96
+ private struct BLENDFUNCTION
97
+ {
98
+ public byte BlendOp;
99
+ public byte BlendFlags;
100
+ public byte SourceConstantAlpha;
101
+ public byte AlphaFormat;
102
+ }
103
+
104
+ [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hWnd);
105
+ [DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
106
+ [DllImport("user32.dll")] private static extern bool UpdateLayeredWindow(IntPtr hwnd, IntPtr hdcDst,
107
+ ref POINT pptDst, ref SIZE psize, IntPtr hdcSrc, ref POINT pptSrc,
108
+ int crKey, ref BLENDFUNCTION pblend, int dwFlags);
109
+ [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr hDC);
110
+ [DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr hdc);
111
+ [DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj);
112
+ [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr hObject);
113
+ [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
114
+ [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
115
+ int X, int Y, int cx, int cy, uint uFlags);
116
+
117
+ private const int WS_EX_LAYERED = 0x00080000;
118
+ private const int WS_EX_TOOLWINDOW = 0x00000080;
119
+ private const int ULW_ALPHA = 0x00000002;
120
+
121
+ public FishPetWindow()
122
+ {
123
+ this.FormBorderStyle = FormBorderStyle.None;
124
+ this.ShowInTaskbar = false;
125
+ this.TopMost = true;
126
+ this.StartPosition = FormStartPosition.Manual;
127
+ this.Text = "fish-pet";
128
+ }
129
+
130
+ protected override CreateParams CreateParams
131
+ {
132
+ get
133
+ {
134
+ CreateParams cp = base.CreateParams;
135
+ cp.ExStyle |= WS_EX_LAYERED | WS_EX_TOOLWINDOW;
136
+ return cp;
137
+ }
138
+ }
139
+
140
+ public void ShowNow()
141
+ {
142
+ // A task launched with -WindowStyle Hidden can keep the first window
143
+ // hidden; force it visible and back to the top of the z-order.
144
+ ShowWindow(this.Handle, 5);
145
+ SetWindowPos(this.Handle, (IntPtr)(-1), 0, 0, 0, 0, 0x0001 | 0x0002);
146
+ }
147
+
148
+ public void Push(Bitmap bmp, int x, int y)
149
+ {
150
+ IntPtr screenDc = GetDC(IntPtr.Zero);
151
+ IntPtr memDc = CreateCompatibleDC(screenDc);
152
+ IntPtr hBmp = IntPtr.Zero;
153
+ IntPtr old = IntPtr.Zero;
154
+ try
155
+ {
156
+ hBmp = bmp.GetHbitmap(Color.FromArgb(0));
157
+ old = SelectObject(memDc, hBmp);
158
+
159
+ POINT dst; dst.X = x; dst.Y = y;
160
+ POINT src; src.X = 0; src.Y = 0;
161
+ SIZE size; size.cx = bmp.Width; size.cy = bmp.Height;
162
+
163
+ BLENDFUNCTION blend;
164
+ blend.BlendOp = 0;
165
+ blend.BlendFlags = 0;
166
+ blend.SourceConstantAlpha = 255;
167
+ blend.AlphaFormat = 1;
168
+
169
+ UpdateLayeredWindow(this.Handle, screenDc, ref dst, ref size,
170
+ memDc, ref src, 0, ref blend, ULW_ALPHA);
171
+ }
172
+ finally
173
+ {
174
+ if (old != IntPtr.Zero) SelectObject(memDc, old);
175
+ if (hBmp != IntPtr.Zero) DeleteObject(hBmp);
176
+ DeleteDC(memDc);
177
+ ReleaseDC(IntPtr.Zero, screenDc);
178
+ }
179
+ }
180
+ }
181
+ "@
153
182
  Add-Type -TypeDefinition $cs -ReferencedAssemblies 'System.Drawing','System.Windows.Forms','System'
154
183
 
155
- Add-Type -ReferencedAssemblies 'System.Drawing','System.Windows.Forms','System' -TypeDefinition @"
156
- using System;
157
- using System.Drawing;
158
- using System.Drawing.Drawing2D;
159
- using System.Windows.Forms;
160
-
161
- public class CoolMenuRenderer : ToolStripProfessionalRenderer
162
- {
163
- public CoolMenuRenderer() : base(new ToolStripProfessionalRenderer().ColorTable) { this.RoundedEdges = true; }
164
-
165
- public static GraphicsPath RoundRect(Rectangle r, int radius)
166
- {
167
- var p = new GraphicsPath();
168
- int d = radius * 2;
169
- p.AddArc(r.X, r.Y, d, d, 180, 90);
170
- p.AddArc(r.Right - d - 1, r.Y, d, d, 270, 90);
171
- p.AddArc(r.Right - d - 1, r.Bottom - d - 1, d, d, 0, 90);
172
- p.AddArc(r.X, r.Bottom - d - 1, d, d, 90, 90);
173
- p.CloseFigure();
174
- return p;
175
- }
176
-
177
- protected override void OnRenderToolStripBackground(ToolStripRenderEventArgs e)
178
- {
179
- var r = e.ToolStrip.ClientRectangle;
180
- if (r.Width <= 0 || r.Height <= 0) return;
181
- using (var b = new LinearGradientBrush(r, Color.FromArgb(26, 40, 72), Color.FromArgb(12, 22, 44), 90f))
182
- e.Graphics.FillRectangle(b, r);
183
- }
184
-
185
- protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e)
186
- {
187
- var r = new Rectangle(0, 0, e.ToolStrip.Width - 1, e.ToolStrip.Height - 1);
188
- using (var pen = new Pen(Color.FromArgb(110, 190, 255), 1f))
189
- using (var path = RoundRect(r, 12))
190
- e.Graphics.DrawPath(pen, path);
191
- }
192
-
193
- protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e)
194
- {
195
- if (!e.Item.Selected || !e.Item.Enabled) return;
196
- var r = new Rectangle(4, 1, e.Item.Width - 8, e.Item.Height - 2);
197
- using (var path = RoundRect(r, 9))
198
- {
199
- using (var b = new LinearGradientBrush(r, Color.FromArgb(90, 190, 255), Color.FromArgb(40, 120, 210), 0f))
200
- e.Graphics.FillPath(b, path);
201
- using (var pen = new Pen(Color.FromArgb(150, 225, 255), 1f))
202
- e.Graphics.DrawPath(pen, path);
203
- }
204
- }
205
-
206
- protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
207
- {
208
- e.TextColor = e.Item.Selected ? Color.White : Color.FromArgb(214, 232, 252);
209
- base.OnRenderItemText(e);
210
- }
211
-
212
- protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e)
213
- {
214
- using (var pen = new Pen(Color.FromArgb(70, 105, 150), 1f))
215
- e.Graphics.DrawLine(pen, 14, e.Item.Height / 2, e.Item.Width - 14, e.Item.Height / 2);
216
- }
217
- }
218
- "@
219
-
220
- # ---------- 画布与初始状态 ----------
221
- $W = 320; $H = 240
222
- $bmp = New-Object System.Drawing.Bitmap($W, $H, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
223
- $g = [System.Drawing.Graphics]::FromImage($bmp)
224
- $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
225
- $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
226
- $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
227
- $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
228
-
229
- function New-Solid([string]$hex, [int]$a = 255) {
230
- $c = [System.Drawing.ColorTranslator]::FromHtml($hex)
231
- return New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb($a, $c.R, $c.G, $c.B))
232
- }
233
- function New-Pen([string]$hex, [float]$w, [int]$a = 255) {
234
- $c = [System.Drawing.ColorTranslator]::FromHtml($hex)
235
- $p = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb($a, $c.R, $c.G, $c.B), $w)
236
- $p.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
237
- return $p
238
- }
239
-
240
- # 颜色卡(蓝白配色)
241
- $C_OUTLINE = '#1B3B5F'
242
- $C_BODY1 = '#9FD9FF'
243
- $C_BODY2 = '#3F8FD8'
244
- $C_BELLY = '#EAF7FF'
245
- $C_FIN1 = '#BDE5FF'
246
- $C_FIN2 = '#5EA9E6'
247
- $C_CHEEK = '#FF9BB4'
248
- $C_EYEWHITE= '#FFFFFF'
249
- $C_PUPIL = '#132A45'
250
-
251
- # ---------- 读档 ----------
252
- $script:st = @{ x = $null; y = $null; dir = 1; scale = 1.0; feeds = 0 }
253
- if (Test-Path $StatePath) {
254
- try {
255
- $j = Get-Content $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json
256
- if ($null -ne $j.x) { $script:st.x = [int]$j.x }
257
- if ($null -ne $j.y) { $script:st.y = [int]$j.y }
258
- if ($null -ne $j.dir) { $script:st.dir = [int]$j.dir }
259
- if ($null -ne $j.scale) { $script:st.scale = [double]$j.scale }
260
- if ($null -ne $j.feeds) { $script:st.feeds = [int]$j.feeds }
261
- } catch { }
262
- }
263
-
264
- $wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
265
- function Get-DefaultX { return [int]($wa.Right - $W - 30) }
266
- function Get-DefaultY { return [int]($wa.Bottom - $H - 10) }
267
-
268
- $script:x = if ($null -ne $script:st.x) { $script:st.x } else { Get-DefaultX }
269
- $script:y = if ($null -ne $script:st.y) { $script:st.y } else { Get-DefaultY }
270
- # 位置兜底:别让鱼跑到屏幕外
271
- $script:x = [Math]::Max(0, [Math]::Min([int]$script:x, [int]($wa.Right - $W)))
272
- $script:y = [Math]::Max(0, [Math]::Min([int]$script:y, [int]($wa.Bottom - $H - 4)))
273
- $script:baseY = $script:y
274
- $script:dir = if ($script:st.dir -ne 0) { $script:st.dir } else { 1 }
275
- $script:scale = if ($Scale -gt 0) { $Scale } else { [double]$script:st.scale }
276
- $script:speed = if ($Speed -gt 0) { [double]$Speed } else { 26.0 }
277
- $script:vx = -$script:speed * $script:dir
278
- $script:paused = $false
279
- $script:dragging = $false
280
- $script:dragMoved = 0
281
- $script:dragOff = @{ x = 0; y = 0 }
282
- $script:dragStart = @{ x = 0; y = 0 }
283
- $script:dragActive = $false
284
- $script:t = 0.0
285
- $script:pulse = 1.0
286
- $script:satiety = 0.0
287
- $script:feeds = [int]$script:st.feeds
288
- $script:bubbles = @()
289
- $script:hearts = @()
290
- $script:nextBubbleAt = 0.0
291
- $script:nextBlinkAt = 3.0
292
- $script:blinkUntil = -1.0
293
- $script:nextTurnAt = 9.0
294
- $script:lastSave = Get-Date
295
-
296
- # ---------- 画鱼 ----------
297
- function Draw-Fish {
298
- param(
299
- [System.Drawing.Graphics]$g,
300
- [double]$t, [int]$dir, [double]$scale, [double]$pulse,
301
- [bool]$blink, [int]$mood
302
- )
303
-
304
- $g.Clear([System.Drawing.Color]::Transparent)
305
-
306
- $bob = [Math]::Sin($t * 1.7) * 3.5
307
- $sway = [Math]::Sin($t * 3.4) * 11.0
308
- $up = [Math]::Sin($t * 2.1) * 2.0
309
- $s = $scale * $pulse
310
-
311
- $g.ResetTransform()
312
- $g.TranslateTransform(160.0, 130.0)
313
- $g.ScaleTransform([float]($s * $dir), [float]$s)
314
- $g.TranslateTransform(-160.0, -130.0 + [float]$bob)
315
-
316
- # ---- 尾鳍(绕 86,130 摆动)----
317
- $stateTail = $g.Save()
318
- $g.TranslateTransform(86.0, 130.0)
319
- $g.RotateTransform([float]$sway)
320
- $g.TranslateTransform(-86.0, -130.0)
321
- $tail = New-Object System.Drawing.Drawing2D.GraphicsPath
322
- $tail.AddBezier(88, 122, 52, 96, 22, 52, 12, 44)
323
- $tail.AddBezier(12, 44, 30, 96, 40, 118, 40, 130)
324
- $tail.AddBezier(40, 130, 40, 142, 30, 164, 12, 216)
325
- $tail.AddBezier(12, 216, 22, 208, 52, 164, 88, 138)
326
- $tail.CloseFigure()
327
- $tailBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
328
- (New-Object System.Drawing.Point(12, 60)), (New-Object System.Drawing.Point(90, 150)),
329
- [System.Drawing.ColorTranslator]::FromHtml($C_FIN1), [System.Drawing.ColorTranslator]::FromHtml($C_FIN2))
330
- $g.FillPath($tailBrush, $tail)
331
- $tailPen = New-Pen $C_OUTLINE 3.2
332
- $g.DrawPath($tailPen, $tail)
333
- $tailBrush.Dispose(); $tailPen.Dispose(); $tail.Dispose()
334
- $g.Restore($stateTail)
335
-
336
- # ---- 背鳍 ----
337
- $dorsal = New-Object System.Drawing.Drawing2D.GraphicsPath
338
- $dorsal.AddBezier(152, 74, 172, 34, 198, 24, 214, 56)
339
- $dorsal.AddBezier(214, 56, 196, 68, 176, 76, 170, 82)
340
- $dorsal.CloseFigure()
341
- $g.FillPath((New-Solid $C_FIN2 235), $dorsal)
342
- $g.DrawPath((New-Pen $C_OUTLINE 2.6), $dorsal)
343
- $dorsal.Dispose()
344
-
345
- # ---- 身体(大肥)----
346
- $body = New-Object System.Drawing.Drawing2D.GraphicsPath
347
- $body.AddEllipse(70, 60, 180, 140)
348
- $bodyBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
349
- (New-Object System.Drawing.Point(150, 58)), (New-Object System.Drawing.Point(150, 202)),
350
- [System.Drawing.ColorTranslator]::FromHtml($C_BODY1), [System.Drawing.ColorTranslator]::FromHtml($C_BODY2))
351
- $g.FillPath($bodyBrush, $body)
352
-
353
- # 肚子
354
- $belly = New-Object System.Drawing.Drawing2D.GraphicsPath
355
- $belly.AddEllipse(96, 112, 140, 82)
356
- $g.FillPath((New-Solid $C_BELLY 210), $belly)
357
-
358
- # 高光
359
- $shine = New-Object System.Drawing.Drawing2D.GraphicsPath
360
- $shine.AddEllipse(104, 74, 66, 26)
361
- $g.FillPath((New-Solid '#FFFFFF' 70), $shine)
362
-
363
- # 身体轮廓
364
- $g.DrawPath((New-Pen $C_OUTLINE 3.4), $body)
365
- $body.Dispose(); $belly.Dispose(); $shine.Dispose(); $bodyBrush.Dispose()
366
-
367
- # ---- 胸鳍(贴在下腹边缘,随游动轻摆)----
368
- $stateFin = $g.Save()
369
- $g.TranslateTransform(146.0, 170.0)
370
- $g.RotateTransform([float](-34 + [Math]::Sin($t * 4.2) * 8))
371
- $g.TranslateTransform(-24.0, -10.0)
372
- $g.FillEllipse((New-Solid $C_FIN1 245), (New-Object System.Drawing.RectangleF(0, 0, 48, 20)))
373
- $g.DrawEllipse((New-Pen $C_OUTLINE 2.4), (New-Object System.Drawing.RectangleF(0, 0, 48, 20)))
374
- $g.Restore($stateFin)
375
-
376
- # ---- 鳃线 ----
377
- $gill = New-Object System.Drawing.Drawing2D.GraphicsPath
378
- $gill.AddBezier(176, 94, 188, 114, 188, 150, 174, 172)
379
- $g.DrawPath((New-Pen '#3F86C4' 2.2 150), $gill)
380
- $gill.Dispose()
381
-
382
- # ---- 眼睛 ----
383
- if ($blink) {
384
- $blinkPen = New-Pen $C_PUPIL 3.6
385
- $g.DrawArc($blinkPen, 199, 104, 34, 20, 200, 140)
386
- $blinkPen.Dispose()
387
- } else {
388
- $g.FillEllipse((New-Solid $C_EYEWHITE), 199, 93, 34, 34)
389
- $g.FillEllipse((New-Solid $C_PUPIL), 207, 101, 20, 20)
390
- $g.FillEllipse((New-Solid '#FFFFFF' 235), 219, 100, 7, 7)
391
- $g.FillEllipse((New-Solid '#FFFFFF' 110), 211, 113, 5, 5)
392
- }
393
-
394
- # ---- 腮红 ----
395
- $g.FillEllipse((New-Solid $C_CHEEK 95), 202, 140, 30, 20)
396
-
397
- # ---- 嘴 ----
398
- $mouthPen = New-Pen $C_OUTLINE 2.8
399
- if ($mood -eq 1) {
400
- $g.FillEllipse((New-Solid '#C9526B'), 236, 128, 16, 12)
401
- $mouthPen.Dispose()
402
- $mouthPen = New-Pen $C_OUTLINE 2.2
403
- $g.DrawEllipse($mouthPen, 236, 128, 16, 12)
404
- } else {
405
- $g.DrawArc($mouthPen, 236, 126, 20, 16, 338, 200)
406
- }
407
- $mouthPen.Dispose()
408
-
409
- # ---- 泡泡 ----
410
- foreach ($b in @($script:bubbles)) {
411
- $bx = 250 + [Math]::Sin($b.seed + $b.age * 2.2) * 8
412
- $by = 128 - $b.age * 46
413
- $r = 5 + $b.size
414
- $al = [int](180 * [Math]::Max(0, 1 - $b.age / $b.life))
415
- if ($al -gt 0) {
416
- $g.FillEllipse((New-Solid '#CFEFFF' ([Math]::Min(255, $al))), ($bx - $r), ($by - $r), (2 * $r), (2 * $r))
417
- $g.DrawEllipse((New-Pen '#9FD8F5' 1.4 $al), ($bx - $r), ($by - $r), (2 * $r), (2 * $r))
418
- }
419
- }
420
-
421
- # ---- 爱心 ----
422
- foreach ($h in @($script:hearts)) {
423
- $al = [int](230 * [Math]::Max(0, 1 - $h.age / $h.life))
424
- if ($al -le 6) { continue }
425
- $hx = 160 + $h.dx
426
- $hy = 70 - $h.age * 42 + [Math]::Sin($h.age * 5) * 2
427
- $k = 1.0 + $h.age * 0.5
428
- $heart = New-Object System.Drawing.Drawing2D.GraphicsPath
429
- $heart.AddBezier($hx, $hy + 8 * $k, $hx - 14 * $k, $hy - 6 * $k, $hx - 6 * $k, $hy - 16 * $k, $hx, $hy - 7 * $k)
430
- $heart.AddBezier($hx, $hy - 7 * $k, $hx + 6 * $k, $hy - 16 * $k, $hx + 14 * $k, $hy - 6 * $k, $hx, $hy + 8 * $k)
431
- $heart.CloseFigure()
432
- $g.FillPath((New-Solid '#FF5C8A' $al), $heart)
433
- $heart.Dispose()
434
- }
435
-
436
- $g.ResetTransform()
437
- }
438
-
439
- # ---------- 窗口 ----------
440
- $form = New-Object FishPetWindow
441
- $form.SetBounds([int]$script:x, [int]$script:y, $W, $H)
442
- $form.Show()
443
- $form.ShowNow()
444
- $form.BringToFront()
445
- $pidJson = [ordered]@{ pid = $PID; startedAt = (Get-Date).ToString('o'); script = $PSCommandPath } | ConvertTo-Json -Compress
446
- [IO.File]::WriteAllText($PidPath, $pidJson, (New-Object Text.UTF8Encoding($false)))
447
-
448
- # 自检用:启动 1.5 秒后自动说一句(-Say "..."),方便无人工验证聊天链路
449
- if ($Say) {
450
- $script:sayText = $Say
451
- $script:sayTimer = New-Object System.Windows.Forms.Timer
452
- $script:sayTimer.Interval = 1500
453
- $script:sayTimer.Add_Tick({ $script:sayTimer.Stop(); Start-Chat $script:sayText })
454
- $script:sayTimer.Start()
455
- }
456
- # 耳朵在后台独立跑(asr-listener.ps1),鱼只读结果,不再自己占麦克风
457
- $script:asrFile = Join-Path $DataDir 'asr.log'
458
- $script:asrSeen = 0
459
- $script:earCheck = 0
460
- function Start-Ear {
461
- try { Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',(Join-Path (Split-Path -Parent $PSCommandPath) 'asr-listener.ps1')) -WindowStyle Hidden } catch { }
462
- }
463
- function Stop-Ear {
464
- try { Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -match 'asr-listener' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } catch { }
465
- }
466
- $script:chatOn = $true
467
- if (Test-Path $script:asrFile) { try { $script:asrSeen = @(Get-Content -LiteralPath $script:asrFile -ErrorAction SilentlyContinue).Count } catch { } }
468
-
469
- # ---------- 菜单 ----------
470
- $menu = New-Object System.Windows.Forms.ContextMenuStrip
471
- $miFeed = $menu.Items.Add('喂它一口')
472
- $miTalk = $menu.Items.Add('开始听我说(麦克风)')
184
+ Add-Type -ReferencedAssemblies 'System.Drawing','System.Windows.Forms','System' -TypeDefinition @"
185
+ using System;
186
+ using System.Drawing;
187
+ using System.Drawing.Drawing2D;
188
+ using System.Windows.Forms;
189
+
190
+ public class CoolMenuRenderer : ToolStripProfessionalRenderer
191
+ {
192
+ public CoolMenuRenderer() : base(new ToolStripProfessionalRenderer().ColorTable) { this.RoundedEdges = true; }
193
+
194
+ public static GraphicsPath RoundRect(Rectangle r, int radius)
195
+ {
196
+ var p = new GraphicsPath();
197
+ int d = radius * 2;
198
+ p.AddArc(r.X, r.Y, d, d, 180, 90);
199
+ p.AddArc(r.Right - d - 1, r.Y, d, d, 270, 90);
200
+ p.AddArc(r.Right - d - 1, r.Bottom - d - 1, d, d, 0, 90);
201
+ p.AddArc(r.X, r.Bottom - d - 1, d, d, 90, 90);
202
+ p.CloseFigure();
203
+ return p;
204
+ }
205
+
206
+ protected override void OnRenderToolStripBackground(ToolStripRenderEventArgs e)
207
+ {
208
+ var r = e.ToolStrip.ClientRectangle;
209
+ if (r.Width <= 0 || r.Height <= 0) return;
210
+ using (var b = new LinearGradientBrush(r, Color.FromArgb(26, 40, 72), Color.FromArgb(12, 22, 44), 90f))
211
+ e.Graphics.FillRectangle(b, r);
212
+ }
213
+
214
+ protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e)
215
+ {
216
+ var r = new Rectangle(0, 0, e.ToolStrip.Width - 1, e.ToolStrip.Height - 1);
217
+ using (var pen = new Pen(Color.FromArgb(110, 190, 255), 1f))
218
+ using (var path = RoundRect(r, 12))
219
+ e.Graphics.DrawPath(pen, path);
220
+ }
221
+
222
+ protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e)
223
+ {
224
+ if (!e.Item.Selected || !e.Item.Enabled) return;
225
+ var r = new Rectangle(4, 1, e.Item.Width - 8, e.Item.Height - 2);
226
+ using (var path = RoundRect(r, 9))
227
+ {
228
+ using (var b = new LinearGradientBrush(r, Color.FromArgb(90, 190, 255), Color.FromArgb(40, 120, 210), 0f))
229
+ e.Graphics.FillPath(b, path);
230
+ using (var pen = new Pen(Color.FromArgb(150, 225, 255), 1f))
231
+ e.Graphics.DrawPath(pen, path);
232
+ }
233
+ }
234
+
235
+ protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
236
+ {
237
+ e.TextColor = e.Item.Selected ? Color.White : Color.FromArgb(214, 232, 252);
238
+ base.OnRenderItemText(e);
239
+ }
240
+
241
+ protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e)
242
+ {
243
+ using (var pen = new Pen(Color.FromArgb(70, 105, 150), 1f))
244
+ e.Graphics.DrawLine(pen, 14, e.Item.Height / 2, e.Item.Width - 14, e.Item.Height / 2);
245
+ }
246
+ }
247
+ "@
248
+
249
+ # ---------- 画布与初始状态 ----------
250
+ $W = 320; $H = 240
251
+ $bmp = New-Object System.Drawing.Bitmap($W, $H, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
252
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
253
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
254
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
255
+ $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
256
+ $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
257
+
258
+ function New-Solid([string]$hex, [int]$a = 255) {
259
+ $c = [System.Drawing.ColorTranslator]::FromHtml($hex)
260
+ return New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb($a, $c.R, $c.G, $c.B))
261
+ }
262
+ function New-Pen([string]$hex, [float]$w, [int]$a = 255) {
263
+ $c = [System.Drawing.ColorTranslator]::FromHtml($hex)
264
+ $p = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb($a, $c.R, $c.G, $c.B), $w)
265
+ $p.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
266
+ return $p
267
+ }
268
+
269
+ # 颜色卡(蓝白配色)
270
+ $C_OUTLINE = '#1B3B5F'
271
+ $C_BODY1 = '#9FD9FF'
272
+ $C_BODY2 = '#3F8FD8'
273
+ $C_BELLY = '#EAF7FF'
274
+ $C_FIN1 = '#BDE5FF'
275
+ $C_FIN2 = '#5EA9E6'
276
+ $C_CHEEK = '#FF9BB4'
277
+ $C_EYEWHITE= '#FFFFFF'
278
+ $C_PUPIL = '#132A45'
279
+
280
+ # ---------- 读档 ----------
281
+ $script:st = @{ x = $null; y = $null; dir = 1; scale = 1.0; feeds = 0; hunger = 30; intimacy = 10; mood = 'normal'; seen = $null }
282
+ if (Test-Path $StatePath) {
283
+ try {
284
+ $j = Get-Content $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json
285
+ if ($null -ne $j.x) { $script:st.x = [int]$j.x }
286
+ if ($null -ne $j.y) { $script:st.y = [int]$j.y }
287
+ if ($null -ne $j.dir) { $script:st.dir = [int]$j.dir }
288
+ if ($null -ne $j.scale) { $script:st.scale = [double]$j.scale }
289
+ if ($null -ne $j.feeds) { $script:st.feeds = [int]$j.feeds }
290
+ if ($null -ne $j.hunger) { $script:st.hunger = [double]$j.hunger }
291
+ if ($null -ne $j.intimacy) { $script:st.intimacy = [double]$j.intimacy }
292
+ if ($null -ne $j.mood) { $script:st.mood = [string]$j.mood }
293
+ if ($null -ne $j.seen) { $script:st.seen = [string]$j.seen }
294
+ } catch { }
295
+ }
296
+
297
+ $wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
298
+ function Get-DefaultX { return [int]($wa.Right - $W - 30) }
299
+ function Get-DefaultY { return [int]($wa.Bottom - $H - 10) }
300
+
301
+ $script:x = if ($null -ne $script:st.x) { $script:st.x } else { Get-DefaultX }
302
+ $script:y = if ($null -ne $script:st.y) { $script:st.y } else { Get-DefaultY }
303
+ # 位置兜底:别让鱼跑到屏幕外
304
+ $script:x = [Math]::Max(0, [Math]::Min([int]$script:x, [int]($wa.Right - $W)))
305
+ $script:y = [Math]::Max(0, [Math]::Min([int]$script:y, [int]($wa.Bottom - $H - 4)))
306
+ $script:baseY = $script:y
307
+ $script:dir = if ($script:st.dir -ne 0) { $script:st.dir } else { 1 }
308
+ $script:scale = if ($Scale -gt 0) { $Scale } else { [double]$script:st.scale }
309
+ $script:speed = if ($Speed -gt 0) { [double]$Speed } else { 26.0 }
310
+ $script:vx = -$script:speed * $script:dir
311
+ $script:paused = $false
312
+ $script:dragging = $false
313
+ $script:dragMoved = 0
314
+ $script:dragOff = @{ x = 0; y = 0 }
315
+ $script:dragStart = @{ x = 0; y = 0 }
316
+ $script:dragActive = $false
317
+ $script:t = 0.0
318
+ $script:pulse = 1.0
319
+ $script:tx = $null; $script:ty = $null; $script:pauseUntil = 0.0
320
+ $script:lastPx = -9999; $script:lastPy = -9999
321
+ $script:satiety = 0.0
322
+ $script:feeds = [int]$script:st.feeds
323
+ # ---- 情绪状态:饥饿 / 亲密度 / 心情 ----
324
+ $script:hunger = [double]$script:st.hunger
325
+ $script:intimacy = [double]$script:st.intimacy
326
+ $script:mood = [string]$script:st.mood
327
+ if (-not $script:mood) { $script:mood = 'normal' }
328
+ if ($script:st.seen) {
329
+ try {
330
+ $hoursAway = ((Get-Date) - [datetime]::Parse([string]$script:st.seen)).TotalHours
331
+ if ($hoursAway -gt 0) {
332
+ $script:hunger = [Math]::Min(100, $script:hunger + $hoursAway * 3)
333
+ $script:intimacy = [Math]::Max(0, $script:intimacy - $hoursAway * 0.5)
334
+ }
335
+ } catch { }
336
+ }
337
+ $script:birthdayMMDD = ''
338
+ try { if ($script:cfg.persona.birthday -match '(\d{1,2})月(\d{1,2})日') { $script:birthdayMMDD = ('{0:D2}-{1:D2}' -f [int]$Matches[1], [int]$Matches[2]) } } catch { }
339
+ $script:moodTick = Get-Date
340
+ $script:moodUntil = $null
341
+ $script:bubbles = @()
342
+ $script:hearts = @()
343
+ $script:nextBubbleAt = 0.0
344
+ $script:nextBlinkAt = 3.0
345
+ $script:blinkUntil = -1.0
346
+ $script:nextTurnAt = 9.0
347
+ $script:lastSave = Get-Date
348
+
349
+ # ---------- 画鱼 ----------
350
+ function Draw-Fish {
351
+ param(
352
+ [System.Drawing.Graphics]$g,
353
+ [double]$t, [int]$dir, [double]$scale, [double]$pulse,
354
+ [bool]$blink, [int]$mood, [string]$expr = 'normal'
355
+ )
356
+
357
+ $g.Clear([System.Drawing.Color]::Transparent)
358
+
359
+ $bob = [Math]::Sin($t * 1.7) * 3.5
360
+ $sway = [Math]::Sin($t * 3.4) * 11.0
361
+ $up = [Math]::Sin($t * 2.1) * 2.0
362
+ $s = $scale * $pulse
363
+
364
+ $g.ResetTransform()
365
+ $g.TranslateTransform(160.0, 130.0)
366
+ $g.ScaleTransform([float]($s * $dir), [float]$s)
367
+ $g.TranslateTransform(-160.0, -130.0 + [float]$bob)
368
+
369
+ # ---- 尾鳍(绕 86,130 摆动)----
370
+ $stateTail = $g.Save()
371
+ $g.TranslateTransform(86.0, 130.0)
372
+ $g.RotateTransform([float]$sway)
373
+ $g.TranslateTransform(-86.0, -130.0)
374
+ $tail = New-Object System.Drawing.Drawing2D.GraphicsPath
375
+ $tail.AddBezier(88, 122, 52, 96, 22, 52, 12, 44)
376
+ $tail.AddBezier(12, 44, 30, 96, 40, 118, 40, 130)
377
+ $tail.AddBezier(40, 130, 40, 142, 30, 164, 12, 216)
378
+ $tail.AddBezier(12, 216, 22, 208, 52, 164, 88, 138)
379
+ $tail.CloseFigure()
380
+ $tailBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
381
+ (New-Object System.Drawing.Point(12, 60)), (New-Object System.Drawing.Point(90, 150)),
382
+ [System.Drawing.ColorTranslator]::FromHtml($C_FIN1), [System.Drawing.ColorTranslator]::FromHtml($C_FIN2))
383
+ $g.FillPath($tailBrush, $tail)
384
+ $tailPen = New-Pen $C_OUTLINE 3.2
385
+ $g.DrawPath($tailPen, $tail)
386
+ $tailBrush.Dispose(); $tailPen.Dispose(); $tail.Dispose()
387
+ $g.Restore($stateTail)
388
+
389
+ # ---- 背鳍 ----
390
+ $dorsal = New-Object System.Drawing.Drawing2D.GraphicsPath
391
+ $dorsal.AddBezier(152, 74, 172, 34, 198, 24, 214, 56)
392
+ $dorsal.AddBezier(214, 56, 196, 68, 176, 76, 170, 82)
393
+ $dorsal.CloseFigure()
394
+ $g.FillPath((New-Solid $C_FIN2 235), $dorsal)
395
+ $g.DrawPath((New-Pen $C_OUTLINE 2.6), $dorsal)
396
+ $dorsal.Dispose()
397
+
398
+ # ---- 身体(大肥)----
399
+ $body = New-Object System.Drawing.Drawing2D.GraphicsPath
400
+ $body.AddEllipse(70, 60, 180, 140)
401
+ $bodyBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
402
+ (New-Object System.Drawing.Point(150, 58)), (New-Object System.Drawing.Point(150, 202)),
403
+ [System.Drawing.ColorTranslator]::FromHtml($C_BODY1), [System.Drawing.ColorTranslator]::FromHtml($C_BODY2))
404
+ $g.FillPath($bodyBrush, $body)
405
+
406
+ # 肚子
407
+ $belly = New-Object System.Drawing.Drawing2D.GraphicsPath
408
+ $belly.AddEllipse(96, 112, 140, 82)
409
+ $g.FillPath((New-Solid $C_BELLY 210), $belly)
410
+
411
+ # 高光
412
+ $shine = New-Object System.Drawing.Drawing2D.GraphicsPath
413
+ $shine.AddEllipse(104, 74, 66, 26)
414
+ $g.FillPath((New-Solid '#FFFFFF' 70), $shine)
415
+
416
+ # 身体轮廓
417
+ $g.DrawPath((New-Pen $C_OUTLINE 3.4), $body)
418
+ $body.Dispose(); $belly.Dispose(); $shine.Dispose(); $bodyBrush.Dispose()
419
+
420
+ # ---- 胸鳍(贴在下腹边缘,随游动轻摆)----
421
+ $stateFin = $g.Save()
422
+ $g.TranslateTransform(146.0, 170.0)
423
+ $g.RotateTransform([float](-34 + [Math]::Sin($t * 4.2) * 8))
424
+ $g.TranslateTransform(-24.0, -10.0)
425
+ $g.FillEllipse((New-Solid $C_FIN1 245), (New-Object System.Drawing.RectangleF(0, 0, 48, 20)))
426
+ $g.DrawEllipse((New-Pen $C_OUTLINE 2.4), (New-Object System.Drawing.RectangleF(0, 0, 48, 20)))
427
+ $g.Restore($stateFin)
428
+
429
+ # ---- 鳃线 ----
430
+ $gill = New-Object System.Drawing.Drawing2D.GraphicsPath
431
+ $gill.AddBezier(176, 94, 188, 114, 188, 150, 174, 172)
432
+ $g.DrawPath((New-Pen '#3F86C4' 2.2 150), $gill)
433
+ $gill.Dispose()
434
+
435
+ # ---- 眼睛(随心情变化)----
436
+ if ($expr -eq 'happy') {
437
+ if ($blink) {
438
+ # 开心时也要眨眼
439
+ $bp2 = New-Pen $C_PUPIL 3.6
440
+ $g.DrawArc($bp2, 199, 104, 34, 20, 200, 140)
441
+ $bp2.Dispose()
442
+ } else {
443
+ $g.FillEllipse((New-Solid $C_EYEWHITE), 199, 93, 34, 34)
444
+ $g.FillEllipse((New-Solid $C_PUPIL), 207, 101, 20, 20)
445
+ $g.FillEllipse((New-Solid '#FFFFFF' 255), 219, 100, 8, 8)
446
+ $g.FillEllipse((New-Solid '#FFFFFF' 200), 211, 113, 6, 6)
447
+ }
448
+ } elseif ($expr -eq 'sad') {
449
+ # 委屈:眼睛半垂 + 一滴泪
450
+ $g.FillEllipse((New-Solid $C_EYEWHITE), 199, 95, 34, 30)
451
+ $g.FillEllipse((New-Solid $C_PUPIL), 208, 108, 18, 16)
452
+ $g.FillEllipse((New-Solid '#FFFFFF' 235), 218, 106, 6, 6)
453
+ $lid = New-Pen $C_PUPIL 3.0
454
+ $g.DrawArc($lid, 199, 88, 34, 22, 200, 140)
455
+ $lid.Dispose()
456
+ $g.FillEllipse((New-Solid '#7FC7F0' 200), 206, 128, 7, 11)
457
+ } elseif ($expr -eq 'angry') {
458
+ # 生气:眼睛压低 + 斜眉
459
+ $g.FillEllipse((New-Solid $C_EYEWHITE), 199, 97, 34, 30)
460
+ $g.FillEllipse((New-Solid $C_PUPIL), 207, 106, 20, 18)
461
+ $g.FillEllipse((New-Solid '#FFFFFF' 235), 219, 105, 6, 6)
462
+ $brow = New-Pen $C_OUTLINE 3.2
463
+ $g.DrawLine($brow, 197, 86, 232, 94)
464
+ $brow.Dispose()
465
+ } elseif ($blink) {
466
+ $blinkPen = New-Pen $C_PUPIL 3.6
467
+ $g.DrawArc($blinkPen, 199, 104, 34, 20, 200, 140)
468
+ $blinkPen.Dispose()
469
+ } else {
470
+ $g.FillEllipse((New-Solid $C_EYEWHITE), 199, 93, 34, 34)
471
+ $g.FillEllipse((New-Solid $C_PUPIL), 207, 101, 20, 20)
472
+ $g.FillEllipse((New-Solid '#FFFFFF' 235), 219, 100, 7, 7)
473
+ $g.FillEllipse((New-Solid '#FFFFFF' 110), 211, 113, 5, 5)
474
+ }
475
+
476
+ # ---- 腮红 ----
477
+ $g.FillEllipse((New-Solid $C_CHEEK 95), 202, 140, 30, 20)
478
+
479
+ # ---- 嘴 ----
480
+ $mouthPen = New-Pen $C_OUTLINE 2.8
481
+ if ($mood -eq 1) {
482
+ $g.FillEllipse((New-Solid '#C9526B'), 236, 128, 16, 12)
483
+ $mouthPen.Dispose()
484
+ $mouthPen = New-Pen $C_OUTLINE 2.2
485
+ $g.DrawEllipse($mouthPen, 236, 128, 16, 12)
486
+ } else {
487
+ $g.DrawArc($mouthPen, 236, 126, 20, 16, 338, 200)
488
+ }
489
+ $mouthPen.Dispose()
490
+
491
+ # ---- 泡泡 ----
492
+ foreach ($b in @($script:bubbles)) {
493
+ $bx = 250 + [Math]::Sin($b.seed + $b.age * 2.2) * 8
494
+ $by = 128 - $b.age * 46
495
+ $r = 5 + $b.size
496
+ $al = [int](180 * [Math]::Max(0, 1 - $b.age / $b.life))
497
+ if ($al -gt 0) {
498
+ $g.FillEllipse((New-Solid '#CFEFFF' ([Math]::Min(255, $al))), ($bx - $r), ($by - $r), (2 * $r), (2 * $r))
499
+ $g.DrawEllipse((New-Pen '#9FD8F5' 1.4 $al), ($bx - $r), ($by - $r), (2 * $r), (2 * $r))
500
+ }
501
+ }
502
+
503
+ # ---- 爱心 ----
504
+ foreach ($h in @($script:hearts)) {
505
+ $al = [int](230 * [Math]::Max(0, 1 - $h.age / $h.life))
506
+ if ($al -le 6) { continue }
507
+ $hx = 160 + $h.dx
508
+ $hy = 70 - $h.age * 42 + [Math]::Sin($h.age * 5) * 2
509
+ $k = 1.0 + $h.age * 0.5
510
+ $heart = New-Object System.Drawing.Drawing2D.GraphicsPath
511
+ $heart.AddBezier($hx, $hy + 8 * $k, $hx - 14 * $k, $hy - 6 * $k, $hx - 6 * $k, $hy - 16 * $k, $hx, $hy - 7 * $k)
512
+ $heart.AddBezier($hx, $hy - 7 * $k, $hx + 6 * $k, $hy - 16 * $k, $hx + 14 * $k, $hy - 6 * $k, $hx, $hy + 8 * $k)
513
+ $heart.CloseFigure()
514
+ $g.FillPath((New-Solid '#FF5C8A' $al), $heart)
515
+ $heart.Dispose()
516
+ }
517
+
518
+ $g.ResetTransform()
519
+ }
520
+
521
+ # ---------- 窗口 ----------
522
+ $form = New-Object FishPetWindow
523
+ if ([int]$script:x -ne $script:lastPx -or [int]$script:y -ne $script:lastPy) { $form.SetBounds([int]$script:x, [int]$script:y, $W, $H); $script:lastPx = [int]$script:x; $script:lastPy = [int]$script:y }
524
+
525
+ $form.Show()
526
+ $form.ShowNow()
527
+ $form.BringToFront()
528
+ $pidJson = [ordered]@{ pid = $PID; startedAt = (Get-Date).ToString('o'); script = $PSCommandPath } | ConvertTo-Json -Compress
529
+ [IO.File]::WriteAllText($PidPath, $pidJson, (New-Object Text.UTF8Encoding($false)))
530
+
531
+ # 自检用:启动 1.5 秒后自动说一句(-Say "..."),方便无人工验证聊天链路
532
+ if ($Say) {
533
+ $script:sayText = $Say
534
+ $script:sayTimer = New-Object System.Windows.Forms.Timer
535
+ $script:sayTimer.Interval = 1500
536
+ $script:sayTimer.Add_Tick({ $script:sayTimer.Stop(); Start-Chat $script:sayText })
537
+ $script:sayTimer.Start()
538
+ }
539
+ # 耳朵在后台独立跑(asr-listener.ps1),鱼只读结果,不再自己占麦克风
540
+ $script:asrFile = Join-Path $DataDir 'asr.log'
541
+ $script:asrPos = 0
542
+ function Read-NewAsrLines {
543
+ $res = @()
544
+ try {
545
+ if (-not (Test-Path $script:asrFile)) { return @() }
546
+ $len = (Get-Item -LiteralPath $script:asrFile).Length
547
+ if ($len -lt [int64]$script:asrPos) { $script:asrPos = 0; $script:asrRest = '' }
548
+ if ($len -eq [int64]$script:asrPos) { return @() }
549
+ $fs = [IO.File]::Open($script:asrFile, 'Open', 'Read', 'ReadWrite')
550
+ [void]$fs.Seek([int64]$script:asrPos, 'Begin')
551
+ $sr = New-Object IO.StreamReader($fs, [Text.Encoding]::UTF8)
552
+ $chunk = $sr.ReadToEnd()
553
+ $script:asrPos = $fs.Position
554
+ $sr.Dispose(); $fs.Dispose()
555
+ $all = [string]$script:asrRest + $chunk
556
+ $arr = $all -split "`n"
557
+ $script:asrRest = [string]$arr[-1]
558
+ if ($arr.Count -gt 1) {
559
+ $res = @($arr[0..($arr.Count - 2)] | ForEach-Object { $_.TrimEnd("`r") } | Where-Object { $_ })
560
+ }
561
+ } catch { }
562
+ return $res
563
+ }
564
+ $script:asrRest = ''
565
+ $script:asrPoll = 0
566
+ $script:earCheck = 0
567
+ function Start-Ear {
568
+ try { Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',(Join-Path (Split-Path -Parent $PSCommandPath) 'asr-listener.ps1')) -WindowStyle Hidden } catch { }
569
+ }
570
+ function Stop-Ear {
571
+ try { Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -match 'asr-listener' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } catch { }
572
+ }
573
+ $script:chatOn = $true
574
+
575
+ # ---------- 菜单 ----------
576
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
577
+ $miFeed = $menu.Items.Add('喂它一口')
578
+ $miType = $menu.Items.Add('打字说两句')
579
+ $miTalk = $menu.Items.Add('开始听我说(麦克风)')
473
580
  $miVoice = $menu.Items.Add('语音朗读:开')
581
+ $miVoiceSet = New-Object System.Windows.Forms.ToolStripMenuItem('换个声音')
582
+ $menu.Items.Add($miVoiceSet) | Out-Null
583
+ $voiceChoices = @(
584
+ @('zh-CN-XiaoyiNeural', '活泼少女·晓伊'),
585
+ @('zh-CN-XiaoxiaoNeural', '温柔姐姐·晓晓'),
586
+ @('zh-CN-liaoning-XiaobeiNeural', '东北话·小北'),
587
+ @('zh-CN-shaanxi-XiaoniNeural', '陕西话·小妮'),
588
+ @('zh-CN-YunxiNeural', '阳光男声·云希'),
589
+ @('zh-CN-YunxiaNeural', '可爱男声·云夏')
590
+ )
591
+ foreach ($vc in $voiceChoices) {
592
+ $it = New-Object System.Windows.Forms.ToolStripMenuItem(($vc[1] + '(' + $vc[0] + ')'))
593
+ $it.Tag = $vc[0]
594
+ $it.Add_Click({
595
+ param($s, $e)
596
+ $script:voicePickTag = [string]$s.Tag
597
+ $script:voicePickLabel = [string]$s.Text
598
+ Set-TtsVoice $script:voicePickTag $script:voicePickLabel
599
+ })
600
+ $miVoiceSet.DropDownItems.Add($it) | Out-Null
601
+ }
474
602
  $miWake = $menu.Items.Add('语音待命:开')
475
- $miChat = $menu.Items.Add('语音聊天:开')
476
- $miPause = $menu.Items.Add('停下歇会儿')
477
- $miHome = $menu.Items.Add('回到右下角')
478
- $miBigger = $menu.Items.Add('长大一点')
479
- $miSmall = $menu.Items.Add('变小一点')
480
- $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
481
- $miQuit = $menu.Items.Add('让它游走(退出)')
482
-
483
- function Save-State {
484
- $obj = [ordered]@{
485
- x = [int]$script:x; y = [int]$script:y; dir = [int]$script:dir
486
- scale = [Math]::Round($script:scale, 3); feeds = [int]$script:feeds
487
- }
488
- try { ($obj | ConvertTo-Json -Compress) | Set-Content -Path $StatePath -Encoding UTF8 } catch { }
489
- }
490
-
491
- function Invoke-Feed {
492
- try {
493
- $script:feeds += 1
494
- $script:satiety += 1
495
- $script:pulse = 1.16
496
- foreach ($dx in @(-26, 4, 30)) {
497
- $script:hearts += @{ dx = $dx; age = 0.0; life = 1.4 }
498
- }
499
- Save-State
500
- } catch {
501
- $line = (Get-Date -Format 'HH:mm:ss') + ' FEED-ERR ' + $_.Exception.Message
502
- try { Add-Content -LiteralPath (Join-Path $env:USERPROFILE '.dsh\fish-pet\error.log') -Value $line -Encoding UTF8 } catch { }
503
- }
504
- }
505
-
506
- # ---------- 聊天:输入框 / 气泡 / 语音 ----------
507
- $script:chatBusy = $false
508
- $script:chatHistory = @()
509
- $script:speakOn = $true
510
- $script:synth = $null
511
- $script:chatForm = $null
512
- $script:chatBox = $null
513
- $script:bubble = $null
514
- $script:bubbleLabel = $null
515
- $script:bubbleUntil = [DateTime]::MinValue
516
- $script:chatPs = $null
517
- $script:chatAsync = $null
518
- $script:lastUser = ''
519
-
520
- function Write-Err([string]$tag) {
521
- try {
522
- $line = (Get-Date -Format 'HH:mm:ss') + ' ' + $tag + ' ' + $Error[0].Exception.Message
523
- Add-Content -LiteralPath $ErrorPath -Value $line -Encoding UTF8
524
- } catch { }
525
- }
526
-
527
- function Get-ApiKey {
528
- if ($env:DEEPSEEK_API_KEY) { return $env:DEEPSEEK_API_KEY }
529
- try {
530
- $kf = Join-Path $DataDir 'deepseek.key'
531
- if (Test-Path $kf) { $v = (Get-Content -LiteralPath $kf -Raw).Trim(); if ($v) { return $v } }
532
- } catch { }
533
- try {
534
- $raw = Get-Content (Join-Path $env:USERPROFILE '.dsh\.credentials.yaml') -Raw -ErrorAction Stop
535
- $m = [regex]::Match($raw, 'DEEPSEEK_API_KEY:\s*(\S+)')
536
- if ($m.Success) { return $m.Groups[1].Value }
537
- } catch { }
538
- return $null
539
- }
540
-
541
- function Show-Bubble([string]$text) {
542
- try {
543
- if ($null -eq $script:bubble) {
544
- $b = New-Object System.Windows.Forms.Form
545
- $b.FormBorderStyle = 'None'
546
- $b.StartPosition = 'Manual'
547
- $b.TopMost = $true
548
- $b.ShowInTaskbar = $false
549
- $b.BackColor = [System.Drawing.Color]::FromArgb(255, 252, 244)
550
- $lbl = New-Object System.Windows.Forms.Label
551
- $lbl.AutoSize = $true
552
- $lbl.MaximumSize = New-Object System.Drawing.Size(340, 0)
553
- $lbl.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
554
- $lbl.ForeColor = [System.Drawing.Color]::FromArgb(24, 44, 70)
555
- $lbl.Padding = New-Object System.Windows.Forms.Padding(12, 9, 12, 9)
556
- $lbl.Text = ''
557
- $lbl.Add_Click({ $script:bubble.Hide() })
558
- $b.Controls.Add($lbl)
559
- $script:bubble = $b
560
- $script:bubbleLabel = $lbl
561
- }
562
- $script:bubbleLabel.Text = $text
563
- $sz = $script:bubbleLabel.PreferredSize
564
- $w = [Math]::Min(364, [Math]::Max(120, $sz.Width))
565
- $h = $sz.Height
566
- $bx = [Math]::Min([Math]::Max(0, [int]$script:x + 30), $wa.Right - $w - 8)
567
- $by = [Math]::Max(0, [int]$script:y - $h - 12)
568
- $script:bubble.SetBounds($bx, $by, $w, $h)
569
- $script:bubble.Show()
570
- $script:bubble.BringToFront()
571
- $script:bubbleUntil = (Get-Date).AddSeconds([Math]::Max(6, [Math]::Min(18, $text.Length / 7 + 5)))
572
- } catch { Write-Err 'BUBBLE-ERR' }
573
- }
574
-
575
- function Speak-Text([string]$text) {
576
- if (-not $script:speakOn) { return }
577
- try {
578
- if ($null -eq $script:synth) {
579
- Add-Type -AssemblyName System.Speech
580
- $script:synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
581
- $zh = $script:synth.GetInstalledVoices() | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1
582
- if ($zh) { $script:synth.SelectVoice($zh.VoiceInfo.Name) }
583
- $script:synth.Rate = 1
584
- }
585
- $talkFile = Join-Path $DataDir 'asr.talking'
586
- try { [IO.File]::WriteAllText($talkFile, '1') } catch { }
587
- if (-not $script:pauseHook) {
588
- $script:pauseHook = $true
589
- $script:synth.add_SpeakCompleted({
590
- try { Remove-Item -LiteralPath (Join-Path $DataDir 'asr.talking') -Force -ErrorAction SilentlyContinue } catch { }
591
- try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.pause'), (Get-Date).ToString('o')) } catch { }
592
- })
593
- }
594
- $script:synth.SpeakAsyncCancelAll()
595
- $script:synth.SpeakAsync($text) | Out-Null
596
- } catch { Write-Err 'TTS-ERR' }
597
- }
598
-
599
- # ---------- 麦克风:离线中文识别 + 唤醒词 ----------
600
- $script:rec = $null
601
- $script:gWake = $null
602
- $script:gDict = $null
603
- $script:autoOn = $false
604
- $script:dictating = $false
605
- $script:woke = $false
606
- $script:heard = $null
607
- $script:heardConf = 0.0
608
- $script:dictDeadline = [DateTime]::MinValue
609
- $script:resumeAt = $null
610
- $script:listen = $false
611
- $script:listenDeadline = [DateTime]::MinValue
612
- $script:fixWords = @{ '大飞宇' = '大肥鱼'; '大飞鱼' = '大肥鱼'; '大肥于' = '大肥鱼'; '飞鱼' = '肥鱼' }
613
-
614
- function Ensure-Recognizer {
615
- if ($null -ne $script:rec) { return $true }
616
- try {
617
- Add-Type -AssemblyName System.Speech
618
- $r = New-Object System.Speech.Recognition.SpeechRecognitionEngine([System.Globalization.CultureInfo]'zh-CN')
619
- $r.SetInputToDefaultAudioDevice()
620
-
621
- $dict = New-Object System.Speech.Recognition.DictationGrammar
622
- $dict.Name = 'dictation'
623
- $dict.Enabled = $false
624
- $r.LoadGrammar($dict)
625
-
626
- $wc = New-Object System.Speech.Recognition.Choices
627
- $wc.Add(@('大肥鱼', '大肥鱼你在吗', '喂大肥鱼', '小鱼', '蓝鱼', '小蓝鱼'))
603
+ $miChat = $menu.Items.Add('语音聊天:开')
604
+ $miBalance = $menu.Items.Add('余额:--')
605
+ $miPause = $menu.Items.Add('停下歇会儿')
606
+ $miHome = $menu.Items.Add('回到右下角')
607
+ $miBigger = $menu.Items.Add('长大一点')
608
+ $miSmall = $menu.Items.Add('变小一点')
609
+ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
610
+ $miRemind = $menu.Items.Add('加个提醒…')
611
+ $miRemindList = $menu.Items.Add('提醒清单…')
612
+ $miClear = $menu.Items.Add('清除记忆…')
613
+ $miQuit = $menu.Items.Add('让它游走(退出)')
614
+
615
+ function Save-State {
616
+ $obj = [ordered]@{
617
+ x = [int]$script:x; y = [int]$script:y; dir = [int]$script:dir
618
+ scale = [Math]::Round($script:scale, 3); feeds = [int]$script:feeds
619
+ hunger = [Math]::Round($script:hunger, 1); intimacy = [Math]::Round($script:intimacy, 1)
620
+ mood = [string]$script:mood; seen = (Get-Date).ToString('o')
621
+ }
622
+ try { ($obj | ConvertTo-Json -Compress) | Set-Content -Path $StatePath -Encoding UTF8 } catch { }
623
+ }
624
+
625
+ function Invoke-Feed {
626
+ try {
627
+ $script:feeds += 1
628
+ $script:satiety += 1
629
+ $script:hunger = [Math]::Max(0, $script:hunger - 30)
630
+ $script:intimacy = [Math]::Min(100, $script:intimacy + 2)
631
+ $script:mood = 'happy'
632
+ $script:pulse = 1.16
633
+ foreach ($dx in @(-26, 4, 30)) {
634
+ $script:hearts += @{ dx = $dx; age = 0.0; life = 1.4 }
635
+ }
636
+ Save-State
637
+ } catch {
638
+ $line = (Get-Date -Format 'HH:mm:ss') + ' FEED-ERR ' + $_.Exception.Message
639
+ try { Add-Content -LiteralPath (Join-Path $env:USERPROFILE '.dsh\fish-pet\error.log') -Value $line -Encoding UTF8 } catch { }
640
+ }
641
+ }
642
+
643
+ # ---------- 聊天:输入框 / 气泡 / 语音 ----------
644
+ $script:chatBusy = $false
645
+ $script:chatPending = $null
646
+ $script:memPath = Join-Path $DataDir 'memory.json'
647
+ $script:memorySummary = ''
648
+ $script:summaryCount = 0
649
+ function Load-Memory {
650
+ try {
651
+ if (Test-Path $script:memPath) {
652
+ $j = [IO.File]::ReadAllText($script:memPath, [Text.Encoding]::UTF8) | ConvertFrom-Json
653
+ if ($j.summary) { $script:memorySummary = [string]$j.summary }
654
+ if ($j.history) {
655
+ $script:chatHistory = @($j.history | ForEach-Object { @{ role = [string]$_.role; content = [string]$_.content } })
656
+ }
657
+ }
658
+ } catch { }
659
+ }
660
+ # ---- 主人交代过的事(便签)----
661
+ $script:notesPath = Join-Path $DataDir 'notes.json'
662
+ function Load-Notes {
663
+ try {
664
+ if (Test-Path $script:notesPath) {
665
+ $arr = [IO.File]::ReadAllText($script:notesPath, [Text.Encoding]::UTF8) | ConvertFrom-Json
666
+ return @($arr)
667
+ }
668
+ } catch { }
669
+ return @()
670
+ }
671
+ # 从一句话里解析提醒时间,解析不出就返回 $null
672
+ function Get-DueFromText([string]$s) {
673
+ try {
674
+ $now = Get-Date
675
+ if ($s -match '(\d+)\s*分钟(后|以后|之后)') { return $now.AddMinutes([int]$Matches[1]) }
676
+ if ($s -match '(\d+)\s*小时(后|以后|之后)') { return $now.AddHours([int]$Matches[1]) }
677
+ $dayShift = 0
678
+ if ($s -match '后天') { $dayShift = 2 } elseif ($s -match '明天|明早') { $dayShift = 1 }
679
+ if ($s -match '(早上|上午|中午|下午|晚上)?\s*(\d{1,2})\s*[点::]\s*(半|\d{1,2})?') {
680
+ $perm = [string]$Matches[1]
681
+ $h = [int]$Matches[2]
682
+ $mm = 0
683
+ if ($Matches[3] -eq '半') { $mm = 30 } elseif ($Matches[3]) { $mm = [int]$Matches[3] }
684
+ if (($perm -eq '下午' -or $perm -eq '晚上') -and $h -lt 12) { $h += 12 }
685
+ $due = (Get-Date).Date.AddDays($dayShift).AddHours($h).AddMinutes($mm)
686
+ if ($dayShift -eq 0 -and $due -lt $now) { $due = $due.AddDays(1) }
687
+ return $due
688
+ }
689
+ } catch { }
690
+ return $null
691
+ }
692
+
693
+ function Save-Note([string]$text, $due = $null) {
694
+ try {
695
+ $arr = @(Load-Notes)
696
+ $entry = @{ t = (Get-Date).ToString('yyyy-MM-dd HH:mm'); text = [string]$text }
697
+ if ($due) { $entry.due = $due.ToString('o'); $entry.fired = $false }
698
+ $arr += $entry
699
+ if ($arr.Count -gt 20) { $arr = @($arr[($arr.Count - 20)..($arr.Count - 1)]) }
700
+ [IO.File]::WriteAllText($script:notesPath, ($arr | ConvertTo-Json -Depth 4), (New-Object Text.UTF8Encoding($false)))
701
+ } catch { }
702
+ }
703
+
704
+ function Save-Memory {
705
+ try {
706
+ $j = @{ history = $script:chatHistory; summary = $script:memorySummary; savedAt = (Get-Date).ToString('o') } | ConvertTo-Json -Depth 5 -Compress
707
+ [IO.File]::WriteAllText($script:memPath, $j, (New-Object Text.UTF8Encoding($false)))
708
+ } catch { }
709
+ }
710
+ # 把最近对话压缩成长期记忆摘要
711
+ function Update-Summary {
712
+ try {
713
+ $k = Get-ApiKey
714
+ if (-not $k) { return }
715
+ $convo = (@($script:chatHistory | Select-Object -Last 16 | ForEach-Object { $(if ($_.role -eq 'user') { '主人' } else { '大肥鱼' }) + ':' + $_.content }) -join "`n")
716
+ if (-not $convo) { return }
717
+ $sys2 = '你在帮一条桌面宠物鱼整理长期记忆。把对话压缩成不超过80字的中文要点,只保留关于主人的事实(称呼、喜好、约定)和主人交代要记住的事。不要评论,不要加引号,只输出要点本身。'
718
+ $old = $(if ($script:memorySummary) { $script:memorySummary } else { '(无)' })
719
+ $u = '旧记忆:' + $old + "`n最近对话:`n" + $convo
720
+ $body = @{ model = 'deepseek-flash'; messages = @(@{ role = 'system'; content = $sys2 }, @{ role = 'user'; content = $u }); max_tokens = 220; temperature = 0.3; thinking = @{ type = 'disabled' } } | ConvertTo-Json -Depth 6
721
+ $resp = Invoke-WebRequest -Uri 'https://api.deepseek.com/chat/completions' -Method Post -Headers @{ Authorization = "Bearer $k" } -ContentType 'application/json' -Body ([Text.Encoding]::UTF8.GetBytes($body)) -TimeoutSec 60 -UseBasicParsing
722
+ $obj = [Text.Encoding]::UTF8.GetString($resp.RawContentStream.ToArray()) | ConvertFrom-Json
723
+ $s = [string]$obj.choices[0].message.content
724
+ if ($s) { $script:memorySummary = $s.Trim(); Save-Memory }
725
+ } catch { }
726
+ }
727
+ $script:chatHistory = @()
728
+ Load-Memory
729
+ $script:speakOn = $true
730
+ $script:synth = $null
731
+ $script:chatForm = $null
732
+ $script:chatBox = $null
733
+ $script:bubble = $null
734
+ $script:bubbleLabel = $null
735
+ $script:bubbleUntil = [DateTime]::MinValue
736
+ $script:chatPs = $null
737
+ $script:chatAsync = $null
738
+ $script:chatTask = $null
739
+ $script:http = $null
740
+ $script:lastUser = ''
741
+
742
+ function Write-Err([string]$tag) {
743
+ try {
744
+ $ep = Join-Path (Join-Path $env:USERPROFILE '.dsh\fish-pet') 'error.log'
745
+ if ((Test-Path $ep) -and (Get-Item $ep).Length -gt 1MB) {
746
+ Remove-Item ($ep + '.1') -Force -ErrorAction SilentlyContinue
747
+ Move-Item $ep ($ep + '.1') -Force
748
+ }
749
+ } catch { }
750
+ try {
751
+ $line = (Get-Date -Format 'HH:mm:ss') + ' ' + $tag
752
+ Add-Content -LiteralPath $ErrorPath -Value $line -Encoding UTF8
753
+ } catch { }
754
+ }
755
+
756
+ # 读取密钥:环境变量 → DPAPI 加密文件 → 明文文件
757
+ function Read-Key([string]$name) {
758
+ $dir = Join-Path $env:USERPROFILE '.dsh\fish-pet'
759
+ $enc = Join-Path $dir ($name + '.key.enc')
760
+ if (Test-Path $enc) {
761
+ try {
762
+ $sec = ConvertTo-SecureString (Get-Content -LiteralPath $enc -Raw).Trim()
763
+ $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
764
+ try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }
765
+ } catch { }
766
+ }
767
+ $plain = Join-Path $dir ($name + '.key')
768
+ if (Test-Path $plain) { return (Get-Content -LiteralPath $plain -Raw -ErrorAction SilentlyContinue).Trim() }
769
+ return $null
770
+ }
771
+ function Get-ApiKey {
772
+ if ($env:DEEPSEEK_API_KEY) { return $env:DEEPSEEK_API_KEY }
773
+ $kk = Read-Key 'deepseek'
774
+ if ($kk) { return $kk }
775
+ try {
776
+ $kf = Join-Path $DataDir 'deepseek.key'
777
+ if (Test-Path $kf) { $v = (Get-Content -LiteralPath $kf -Raw).Trim(); if ($v) { return $v } }
778
+ } catch { }
779
+ try {
780
+ $raw = Get-Content (Join-Path $env:USERPROFILE '.dsh\.credentials.yaml') -Raw -ErrorAction Stop
781
+ $m = [regex]::Match($raw, 'DEEPSEEK_API_KEY:\s*(\S+)')
782
+ if ($m.Success) { return $m.Groups[1].Value }
783
+ } catch { }
784
+ return $null
785
+ }
786
+
787
+ # 打字聊天窗口
788
+ # 手动添加提醒
789
+ # 提醒清单:查看 / 删除
790
+ function Show-ReminderList {
791
+ try {
792
+ $dlg = New-Object System.Windows.Forms.Form
793
+ $dlg.Text = '提醒清单'
794
+ $dlg.Size = New-Object System.Drawing.Size(560, 380)
795
+ $dlg.StartPosition = 'CenterScreen'
796
+ $dlg.TopMost = $true
797
+ $dlg.FormBorderStyle = 'FixedDialog'
798
+ $dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
799
+ $dlg.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
800
+ $lb = New-Object System.Windows.Forms.ListBox
801
+ $lb.Location = New-Object System.Drawing.Point(16, 14)
802
+ $lb.Size = New-Object System.Drawing.Size(512, 240)
803
+ $lb.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
804
+ $dlg.Controls.Add($lb)
805
+ $btnDel = New-Object System.Windows.Forms.Button
806
+ $btnDel.Text = '删除选中'
807
+ $btnDel.Location = New-Object System.Drawing.Point(16, 268)
808
+ $btnDel.Width = 120
809
+ $dlg.Controls.Add($btnDel)
810
+ $btnClear = New-Object System.Windows.Forms.Button
811
+ $btnClear.Text = '清空全部'
812
+ $btnClear.Location = New-Object System.Drawing.Point(150, 268)
813
+ $btnClear.Width = 120
814
+ $dlg.Controls.Add($btnClear)
815
+ $btnClose = New-Object System.Windows.Forms.Button
816
+ $btnClose.Text = '关闭'
817
+ $btnClose.Location = New-Object System.Drawing.Point(408, 268)
818
+ $btnClose.Width = 120
819
+ $dlg.Controls.Add($btnClose)
820
+ $script:remList = $lb
821
+ $script:remDlg = $dlg
822
+ $script:remBtnDel = $btnDel
823
+ $script:remBtnClear = $btnClear
824
+ $script:remBtnClose = $btnClose
825
+ $script:remItems = @()
826
+ $script:remRefresh = {
827
+ $script:remList.Items.Clear()
828
+ $script:remItems = @(Load-Notes)
829
+ $i = 0
830
+ foreach ($n in $script:remItems) {
831
+ $i++
832
+ $dueText = '不限时'
833
+ if ($n.due) {
834
+ try {
835
+ $d = [datetime]::Parse([string]$n.due)
836
+ $firedMark = ''
837
+ try { if ($n.fired) { $firedMark = '(已提醒)' } } catch { }
838
+ $dueText = $d.ToString('MM-dd HH:mm') + $firedMark
839
+ } catch { }
840
+ }
841
+ [void]$script:remList.Items.Add(('{0}. {1} 〔{2}〕' -f $i, [string]$n.text, $dueText))
842
+ }
843
+ if ($script:remList.Items.Count -eq 0) { [void]$script:remList.Items.Add('(还没有提醒事项)') }
844
+ }
845
+ $btnClose.Add_Click({ $script:remDlg.Close() })
846
+ $dlg.Add_Shown({
847
+ & $script:remRefresh
848
+ })
849
+ $btnDel.Add_Click({
850
+ $sel = $script:remList.SelectedIndex
851
+ $arr = @(Load-Notes)
852
+ if ($sel -ge 0 -and $arr.Count -gt $sel) {
853
+ $kept = @()
854
+ for ($i = 0; $i -lt $arr.Count; $i++) { if ($i -ne $sel) { $kept += $arr[$i] } }
855
+ [IO.File]::WriteAllText($script:notesPath, (ConvertTo-Json -InputObject @($kept) -Depth 4), (New-Object Text.UTF8Encoding($false)))
856
+ & $script:remRefresh
857
+ }
858
+ })
859
+ $btnClear.Add_Click({
860
+ $r = [System.Windows.Forms.MessageBox]::Show('清空全部提醒事项?', '提醒清单', [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Question)
861
+ if ($r -eq [System.Windows.Forms.DialogResult]::Yes) {
862
+ [IO.File]::WriteAllText($script:notesPath, '[]', (New-Object Text.UTF8Encoding($false)))
863
+ & $script:remRefresh
864
+ }
865
+ })
866
+ [void]$dlg.ShowDialog()
867
+ } catch { Write-Err 'REMLIST-ERR' }
868
+ }
869
+
870
+ # 切换朗读音色(立即生效并写入 config.json)
871
+ function Set-TtsVoice([string]$voice, [string]$label) {
872
+ try {
873
+ $script:ttsVoice = $voice
874
+ try {
875
+ $cfg = Join-Path $DataDir 'config.json'
876
+ if (Test-Path $cfg) {
877
+ $j = [IO.File]::ReadAllText($cfg, [Text.Encoding]::UTF8) | ConvertFrom-Json
878
+ if (-not $j.tts) { $j | Add-Member -NotePropertyName tts -NotePropertyValue ([ordered]@{ engine = 'edge'; voice = $voice; rate = '+12%'; pitch = '+10Hz' }) }
879
+ $j.tts.voice = $voice
880
+ [IO.File]::WriteAllText($cfg, ($j | ConvertTo-Json -Depth 6), (New-Object Text.UTF8Encoding($false)))
881
+ }
882
+ } catch { }
883
+ Show-Bubble ('声音换成:' + $label)
884
+ Speak-Text ('主人你好呀,我是大肥鱼,现在用的是' + $label + '的声音')
885
+ } catch { Write-Err 'VOICE-SET-ERR' }
886
+ }
887
+
888
+ function Show-Reminder {
889
+ try {
890
+ $dlg = New-Object System.Windows.Forms.Form
891
+ $dlg.Text = '加个提醒'
892
+ $dlg.Size = New-Object System.Drawing.Size(460, 190)
893
+ $dlg.StartPosition = 'CenterScreen'
894
+ $dlg.TopMost = $true
895
+ $dlg.FormBorderStyle = 'FixedDialog'
896
+ $dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
897
+ $dlg.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
898
+ $lbl = New-Object System.Windows.Forms.Label
899
+ $lbl.Text = '写一句话,能带时间就定时提醒:' + [Environment]::NewLine + '例:15:30 开会 / 明天7点 起床 / 10分钟后 喝水'
900
+ $lbl.Location = New-Object System.Drawing.Point(16, 12)
901
+ $lbl.Width = 420; $lbl.Height = 44
902
+ $dlg.Controls.Add($lbl)
903
+ $tb = New-Object System.Windows.Forms.TextBox
904
+ $tb.Location = New-Object System.Drawing.Point(16, 62)
905
+ $tb.Width = 412
906
+ $tb.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 11)
907
+ $dlg.Controls.Add($tb)
908
+ $btn = New-Object System.Windows.Forms.Button
909
+ $btn.Text = '记下'
910
+ $btn.Location = New-Object System.Drawing.Point(328, 104)
911
+ $btn.Width = 100
912
+ $dlg.Controls.Add($btn)
913
+ $script:inputDlg = $dlg
914
+ $script:inputBox = $tb
915
+ $script:inputText = $null
916
+ $btn.Add_Click({ $script:inputText = $script:inputBox.Text; $script:inputDlg.Close() })
917
+ $tb.Add_KeyDown({ param($s, $e) if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) { $script:inputText = $script:inputBox.Text; $script:inputDlg.Close() } })
918
+ $dlg.Add_Shown({ $script:inputBox.Focus() })
919
+ [void]$dlg.ShowDialog()
920
+ $txt = [string]$script:inputText
921
+ $script:inputText = $null
922
+ if ($txt -and $txt.Trim().Length -gt 0) {
923
+ $txt = $txt.Trim()
924
+ $due = Get-DueFromText $txt
925
+ Save-Note $txt $due
926
+ if ($due) {
927
+ Show-Bubble ('记下了:' + $txt + '(' + $due.ToString('MM-dd HH:mm') + ' 提醒你)')
928
+ } else {
929
+ Show-Bubble ('记下了:' + $txt + '(没说时间,我就一直记着)')
930
+ }
931
+ }
932
+ } catch { Write-Err 'REMIND-ERR' }
933
+ }
934
+
935
+ function Show-Input {
936
+ Write-Err 'INPUT-OPEN'
937
+ try {
938
+ $dlg = New-Object System.Windows.Forms.Form
939
+ $dlg.Text = '跟大肥鱼说两句'
940
+ $dlg.Size = New-Object System.Drawing.Size(410, 140)
941
+ $dlg.StartPosition = 'CenterScreen'
942
+ $dlg.TopMost = $true
943
+ $dlg.FormBorderStyle = 'FixedDialog'
944
+ $dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
945
+ $dlg.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
946
+ $tb = New-Object System.Windows.Forms.TextBox
947
+ $tb.Location = New-Object System.Drawing.Point(16, 16)
948
+ $tb.Width = 362
949
+ $tb.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 11)
950
+ $dlg.Controls.Add($tb)
951
+ $btn = New-Object System.Windows.Forms.Button
952
+ $btn.Text = '发过去'
953
+ $btn.Location = New-Object System.Drawing.Point(278, 56)
954
+ $btn.Width = 100
955
+ $dlg.Controls.Add($btn)
956
+ $script:inputDlg = $dlg
957
+ $script:inputBox = $tb
958
+ $btn.Add_Click({ $script:inputText = $script:inputBox.Text; $script:inputDlg.Close() })
959
+ $tb.Add_KeyDown({ param($s, $e) if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) { $script:inputText = $script:inputBox.Text; $script:inputDlg.Close() } })
960
+ $dlg.Add_Shown({ $script:inputBox.Focus() })
961
+ [void]$dlg.ShowDialog()
962
+ $txt = [string]$script:inputText
963
+ $script:inputText = $null
964
+ Write-Err ('INPUT-SEND [' + $txt + ']')
965
+ if ($txt -and $txt.Trim().Length -gt 0) {
966
+ $script:lastReplyAt = Get-Date
967
+ Start-Chat $txt.Trim()
968
+ }
969
+ } catch { Write-Err 'INPUT-ERR' }
970
+ }
971
+
972
+ # 取 DeepSeek 余额文本(缓存 60 秒,避免每次开菜单都卡一下)
973
+ function Get-BalanceText {
974
+ if ($script:balCache -and ((Get-Date) - $script:balAt).TotalSeconds -lt 60) { return $script:balCache }
975
+ $txt = '余额:查询失败'
976
+ try {
977
+ $k = Get-ApiKey
978
+ if (-not $k) {
979
+ $txt = '余额:未配置 key'
980
+ } else {
981
+ $r = Invoke-RestMethod -Uri 'https://api.deepseek.com/user/balance' -Headers @{ Authorization = "Bearer $k" } -TimeoutSec 8
982
+ $b = @($r.balance_infos) | Select-Object -First 1
983
+ if ($b) {
984
+ $sym = switch ([string]$b.currency) { 'CNY' { '¥' } 'USD' { '$' } default { [string]$b.currency + ' ' } }
985
+ $txt = '余额:' + $sym + $b.total_balance
986
+ }
987
+ }
988
+ } catch { }
989
+ $script:balCache = $txt
990
+ $script:balAt = Get-Date
991
+ return $txt
992
+ }
993
+
994
+ function Show-Bubble([string]$text) {
995
+ try {
996
+ if ($null -eq $script:bubble) {
997
+ $b = New-Object System.Windows.Forms.Form
998
+ $b.FormBorderStyle = 'None'
999
+ $b.StartPosition = 'Manual'
1000
+ $b.TopMost = $true
1001
+ $b.ShowInTaskbar = $false
1002
+ $b.BackColor = [System.Drawing.Color]::FromArgb(255, 20, 40, 70)
1003
+ $b.TransparencyKey = [System.Drawing.Color]::FromArgb(255, 20, 40, 70)
1004
+ $b.Opacity = [double]$script:cfg.bubble.opacity
1005
+ $lbl = New-Object System.Windows.Forms.Label
1006
+ $lbl.AutoSize = $true
1007
+ $lbl.MaximumSize = New-Object System.Drawing.Size(340, 0)
1008
+ $lbl.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
1009
+ $lbl.ForeColor = [System.Drawing.Color]::FromArgb(255, 8, 12, 18)
1010
+ $lbl.BackColor = [System.Drawing.Color]::FromArgb(255, 20, 40, 70)
1011
+ $lbl.Padding = New-Object System.Windows.Forms.Padding(12, 9, 12, 9)
1012
+ $lbl.Text = ''
1013
+ $lbl.Add_Click({ $script:bubble.Hide() })
1014
+ $b.Controls.Add($lbl)
1015
+ $script:bubble = $b
1016
+ $script:bubbleLabel = $lbl
1017
+ }
1018
+ $script:bubbleLabel.Text = $text
1019
+ $sz = $script:bubbleLabel.PreferredSize
1020
+ $w = [Math]::Min(364, [Math]::Max(120, $sz.Width))
1021
+ $h = $sz.Height
1022
+ $bx = [Math]::Min([Math]::Max(0, [int]$script:x + [int](150 - ($w / 2))), $wa.Right - $w - 8)
1023
+ $by = [int]$script:y + 44 - $h
1024
+ if ($by -lt 0) { $by = [Math]::Min($wa.Bottom - $h - 8, [int]$script:y + $H - 10) }
1025
+ $script:bubble.SetBounds($bx, $by, $w, $h)
1026
+ $script:bubble.Show()
1027
+ $script:bubble.BringToFront()
1028
+ $script:bubbleUntil = (Get-Date).AddSeconds([Math]::Max(6, [Math]::Min(18, $text.Length / 7 + 5)))
1029
+ } catch { Write-Err 'BUBBLE-ERR' }
1030
+ }
1031
+
1032
+ function Speak-Text([string]$text) {
1033
+ if (-not $script:speakOn) { return }
1034
+ $talkFile = Join-Path $DataDir 'asr.talking'
1035
+ try { [IO.File]::WriteAllText($talkFile, '1') } catch { }
1036
+ # 云语音(Edge 免费音色)优先;失败退回本机 SAPI
1037
+ if ($script:ttsEngine -eq 'edge') {
1038
+ try {
1039
+ $mp3 = Join-Path $env:TEMP ('fish-tts-' + [guid]::NewGuid().ToString('N') + '.mp3')
1040
+ $eargs = @('-m', 'edge_tts', '--voice', $script:ttsVoice, ('--rate=' + $script:ttsRate), ('--pitch=' + $script:ttsPitch), '--text', $text, '--write-media', $mp3)
1041
+ & python @eargs 2>$null | Out-Null
1042
+ if ((Test-Path $mp3) -and (Get-Item $mp3).Length -gt 2000) {
1043
+ if ($null -eq $script:mp) {
1044
+ Add-Type -AssemblyName PresentationCore
1045
+ $script:mp = New-Object System.Windows.Media.MediaPlayer
1046
+ $script:mp.Add_MediaEnded({
1047
+ try { Remove-Item -LiteralPath (Join-Path $DataDir 'asr.talking') -Force -ErrorAction SilentlyContinue } catch { }
1048
+ try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.pause'), (Get-Date).ToString('o')) } catch { }
1049
+ })
1050
+ }
1051
+ $script:mp.Open([uri]$mp3)
1052
+ $script:mp.Play()
1053
+ Write-Err ('TTS-EDGE len=' + $text.Length + ' file=' + (Get-Item $mp3).Length)
1054
+ try { Get-ChildItem $env:TEMP -Filter 'fish-tts-*.mp3' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } | Remove-Item -Force -ErrorAction SilentlyContinue } catch { }
1055
+ return
1056
+ }
1057
+ } catch { Write-Err ('TTS-EDGE-ERR ' + $_.Exception.Message) }
1058
+ }
1059
+ try {
1060
+ if ($null -eq $script:synth) {
1061
+ Add-Type -AssemblyName System.Speech
1062
+ $script:synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
1063
+ $zh = $script:synth.GetInstalledVoices() | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1
1064
+ if ($zh) { $script:synth.SelectVoice($zh.VoiceInfo.Name) }
1065
+ $script:synth.Rate = 1
1066
+ }
1067
+ if (-not $script:pauseHook) {
1068
+ $script:pauseHook = $true
1069
+ $script:synth.add_SpeakCompleted({
1070
+ try { Remove-Item -LiteralPath (Join-Path $DataDir 'asr.talking') -Force -ErrorAction SilentlyContinue } catch { }
1071
+ try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.pause'), (Get-Date).ToString('o')) } catch { }
1072
+ })
1073
+ }
1074
+ switch ([string]$script:mood) {
1075
+ 'happy' { $script:synth.Rate = 2; $script:synth.Volume = 100 }
1076
+ 'sad' { $script:synth.Rate = -2; $script:synth.Volume = 82 }
1077
+ 'hungry' { $script:synth.Rate = -1; $script:synth.Volume = 92 }
1078
+ 'lonely' { $script:synth.Rate = -1; $script:synth.Volume = 88 }
1079
+ default { $script:synth.Rate = 1; $script:synth.Volume = 100 }
1080
+ }
1081
+ Write-Err ('TTS-SPEAK len=' + $text.Length + ' mood=' + $script:mood + ' rate=' + $script:synth.Rate)
1082
+ $script:synth.SpeakAsyncCancelAll()
1083
+ $script:synth.SpeakAsync($text) | Out-Null
1084
+ } catch { Write-Err ('TTS-ERR ' + $_.Exception.Message) }
1085
+ }
1086
+
1087
+ # ---------- 麦克风:离线中文识别 + 唤醒词 ----------
1088
+ $script:rec = $null
1089
+ $script:gWake = $null
1090
+ $script:gDict = $null
1091
+ $script:autoOn = $false
1092
+ $script:dictating = $false
1093
+ $script:woke = $false
1094
+ $script:heard = $null
1095
+ $script:heardConf = 0.0
1096
+ $script:dictDeadline = [DateTime]::MinValue
1097
+ $script:resumeAt = $null
1098
+ $script:listen = $false
1099
+ $script:listenDeadline = [DateTime]::MinValue
1100
+ $script:fixWords = @{ '大飞宇' = '大肥鱼'; '大飞鱼' = '大肥鱼'; '大肥于' = '大肥鱼'; '飞鱼' = '肥鱼' }
1101
+
1102
+ function Ensure-Recognizer {
1103
+ if ($null -ne $script:rec) { return $true }
1104
+ try {
1105
+ Add-Type -AssemblyName System.Speech
1106
+ $r = New-Object System.Speech.Recognition.SpeechRecognitionEngine([System.Globalization.CultureInfo]'zh-CN')
1107
+ $r.SetInputToDefaultAudioDevice()
1108
+
1109
+ $dict = New-Object System.Speech.Recognition.DictationGrammar
1110
+ $dict.Name = 'dictation'
1111
+ $dict.Enabled = $false
1112
+ $r.LoadGrammar($dict)
1113
+
1114
+ $wc = New-Object System.Speech.Recognition.Choices
1115
+ $wc.Add(@('大肥鱼', '大肥鱼你在吗', '喂大肥鱼', '小鱼', '蓝鱼', '小蓝鱼'))
628
1116
  $wb = New-Object System.Speech.Recognition.GrammarBuilder
629
- $wb.Culture = [System.Globalization.CultureInfo]'zh-CN'
630
- $wb.Append($wc)
631
- $wg = New-Object System.Speech.Recognition.Grammar($wb)
632
- $wg.Name = 'wake'
633
- $wg.Enabled = $true
634
- $r.LoadGrammar($wg)
635
-
636
- $r.Add_SpeechRecognized({
637
- param($s, $e)
638
- if (-not $e.Result) { return }
639
- $name = ''
640
- try { $name = $e.Result.Grammar.Name } catch { }
641
- $txt = [string]$e.Result.Text
642
- if ($txt) {
643
- $script:heard = $txt
644
- $script:heardConf = [double]$e.Result.Confidence
645
- try { Add-Content -LiteralPath $ErrorPath -Value ((Get-Date -Format 'HH:mm:ss') + ' HEARD[' + [Math]::Round($e.Result.Confidence,2) + '] ' + $txt) -Encoding UTF8 } catch { }
646
- }
647
- })
648
- $script:rec = $r
649
- $script:gWake = $wg
650
- $script:gDict = $dict
651
- return $true
652
- } catch {
653
- Write-Err 'SR-INIT'
654
- return $false
655
- }
656
- }
657
-
658
- function Start-Auto {
659
- if (-not (Ensure-Recognizer)) { return }
660
- $script:autoOn = $true
661
- $script:dictating = $false
662
- $script:woke = $false
663
- try { $script:gDict.Enabled = $true; $script:gWake.Enabled = $false } catch { }
664
- try { $script:rec.RecognizeAsync([System.Speech.Recognition.RecognizeMode]::Multiple); Add-Content -LiteralPath $ErrorPath -Value ((Get-Date -Format 'HH:mm:ss') + ' AUTO-ON') -Encoding UTF8 } catch { Write-Err 'SR-AUTO' }
665
- }
666
-
667
- function Enter-Dictation {
668
- if (-not (Ensure-Recognizer)) { Show-Bubble '耳朵打不开…检查一下麦克风?'; return }
669
- $script:dictating = $true
670
- $script:woke = $false
671
- $script:heard = $null
672
- $script:heardConf = 0.0
673
- $script:dictDeadline = (Get-Date).AddSeconds(11)
674
- try { $script:gWake.Enabled = $false; $script:gDict.Enabled = $true } catch { }
675
- [System.Media.SystemSounds]::Asterisk.Play()
676
- Show-Bubble '我在听…(说完停一下)'
677
- }
678
-
679
- function BackToWake {
680
- $script:dictating = $false
681
- $script:heard = $null
682
- $script:heardConf = 0.0
683
-
684
- }
685
-
686
- function Start-Listen { Show-Bubble '语音在后台自动听,主人直接说话就行。' }
687
- function Stop-Listen { BackToWake }
688
-
689
- function Open-Chat {
690
- try {
691
- if ($null -eq $script:chatForm) {
692
- $cf = New-Object System.Windows.Forms.Form
693
- $cf.FormBorderStyle = 'FixedToolWindow'
694
- $cf.StartPosition = 'Manual'
695
- $cf.TopMost = $true
696
- $cf.ShowInTaskbar = $false
697
- $cf.Text = '和大肥鱼说话'
698
- $cf.Width = 360
699
- $cf.Height = 96
700
- $cf.BackColor = [System.Drawing.Color]::FromArgb(255, 252, 244)
701
- $tb = New-Object System.Windows.Forms.TextBox
702
- $tb.SetBounds(12, 12, 324, 26)
703
- $tb.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
704
- $cf.Controls.Add($tb)
705
- $cf.Add_Shown({ $script:chatBox.Focus() })
706
- $tb.Add_KeyDown({
707
- param($s, $e)
708
- if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) {
709
- $e.SuppressKeyPress = $true
710
- $txt = $script:chatBox.Text.Trim()
711
- if ($txt) {
712
- $script:chatBox.Text = ''
713
- $script:chatForm.Hide()
714
- Start-Chat $txt
715
- }
716
- } elseif ($e.KeyCode -eq [System.Windows.Forms.Keys]::Escape) {
717
- $script:chatForm.Hide()
718
- }
719
- })
720
- $script:chatForm = $cf
721
- $script:chatBox = $tb
722
- }
723
- $script:chatForm.Left = [Math]::Min([Math]::Max(0, [int]$script:x + 20), $wa.Right - 370)
724
- $script:chatForm.Top = [Math]::Max(0, [int]$script:y - 110)
725
- $script:chatForm.Show()
726
- $script:chatForm.BringToFront()
727
- $script:chatBox.Focus()
728
- } catch { Write-Err 'CHATUI-ERR' }
729
- }
730
-
731
- function Start-Chat([string]$text) {
732
- if ($script:chatBusy) { Show-Bubble '等我先说完这一句…'; return }
733
- $key = Get-ApiKey
734
- if (-not $key) { Show-Bubble '我没有钥匙,打不开话匣子…'; return }
735
- $script:chatBusy = $true
736
- $script:lastUser = $text
737
- Show-Bubble '…'
738
- $sys = '你是主人桌面上的一条圆滚滚的蓝色小鱼,名字叫大肥鱼,爸爸叫光光,妈妈叫丽丽,有人问起家人就照这个答,不要瞎编。你说话简短、俏皮、微微傲娇,用中文,称呼用户为"主人",每次回复不超过60个字,不要用表情符号。'
739
- $msgs = @(@{ role = 'system'; content = $sys })
740
- foreach ($m in $script:chatHistory) { $msgs += $m }
741
- $msgs += @{ role = 'user'; content = $text }
742
- $body = @{ model = 'deepseek-flash'; messages = $msgs; max_tokens = 900; temperature = 0.9 } | ConvertTo-Json -Depth 6
743
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
744
- $ps = [powershell]::Create()
745
- $null = $ps.AddScript(@'
746
- param($key, $bytes)
747
- $r = Invoke-WebRequest -Uri 'https://api.deepseek.com/chat/completions' -Method Post `
748
- -Headers @{ Authorization = "Bearer $key" } -ContentType 'application/json' `
749
- -Body $bytes -TimeoutSec 90 -UseBasicParsing
750
- $json = [System.Text.Encoding]::UTF8.GetString($r.RawContentStream.ToArray()) | ConvertFrom-Json
751
- $json.choices[0].message.content
752
- '@).AddArgument($key).AddArgument($bytes)
753
- $script:chatPs = $ps
754
- $script:chatAsync = $ps.BeginInvoke()
755
- $script:chatTimer.Start()
756
- }
757
-
758
- $script:chatTimer = New-Object System.Windows.Forms.Timer
759
- $script:chatTimer.Interval = 300
760
- $script:chatTimer.Add_Tick({
761
- try {
762
- if ($null -eq $script:chatAsync -or -not $script:chatAsync.IsCompleted) { return }
763
- $script:chatTimer.Stop()
764
- $out = $script:chatPs.EndInvoke($script:chatAsync)
765
- try { $script:chatPs.Dispose() } catch { }
766
- $script:chatPs = $null
767
- $script:chatAsync = $null
768
- $reply = ''
769
- if ($out -and $out.Count -gt 0) { $reply = [string]$out[0] }
770
- if ([string]::IsNullOrWhiteSpace($reply)) { $reply = '(吐了个泡泡)没听清,再说一遍嘛' }
771
- $reply = $reply.Trim()
772
- $script:chatHistory += @{ role = 'user'; content = $script:lastUser }
773
- $script:chatHistory += @{ role = 'assistant'; content = $reply }
774
- if ($script:chatHistory.Count -gt 10) { $script:chatHistory = @($script:chatHistory[($script:chatHistory.Count - 10)..($script:chatHistory.Count - 1)]) }
775
- Show-Bubble $reply
776
- Speak-Text $reply
777
- $script:chatBusy = $false
778
- } catch {
779
- $script:chatBusy = $false
780
- $script:chatTimer.Stop()
781
- Show-Bubble '(呛了口水)刚才没接通…再试一次?'
782
- Write-Err 'CHAT-ERR'
783
- }
784
- })
785
-
786
- $form.Add_MouseDown({
787
- param($s, $e)
788
- if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
789
- $script:dragging = $true
790
- $script:dragActive = $false
791
- $script:dragMoved = 0
792
- $script:dragOff.x = $e.X
793
- $script:dragOff.y = $e.Y
794
- $script:dragStart.x = $e.X
795
- $script:dragStart.y = $e.Y
796
- }
797
- })
798
- $form.Add_MouseMove({
799
- param($s, $e)
800
- if ($script:dragging) {
801
- $dx = $e.X - $script:dragStart.x
802
- $dy = $e.Y - $script:dragStart.y
803
- if (-not $script:dragActive -and ([Math]::Abs($dx) + [Math]::Abs($dy)) -lt 4) { return }
804
- $script:dragActive = $true
805
- $script:x = $e.X + $form.Left - $script:dragOff.x
806
- $script:y = $e.Y + $form.Top - $script:dragOff.y
807
- $script:dragMoved += 1
808
- $script:baseY = $script:y
809
- $form.SetBounds([int]$script:x, [int]$script:y, $W, $H)
810
- }
811
- })
812
- $form.Add_MouseUp({
813
- param($s, $e)
814
- if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
815
- $wasDrag = $script:dragging -and $script:dragActive
816
- $script:dragging = $false
817
- $script:dragActive = $false
818
- $script:baseY = $script:y
819
- if (-not $wasDrag) { Invoke-Feed }
820
- Save-State
821
- }
822
- })
823
- $form.Add_MouseClick({
824
- param($s, $e)
825
- if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Right) {
826
- $miPause.Text = if ($script:paused) { '继续游' } else { '停下歇会儿' }
827
- $menu.Show($form, $e.Location)
828
- }
829
- })
830
-
831
- $miFeed.Add_Click({ Invoke-Feed })
1117
+ $wb.Culture = [System.Globalization.CultureInfo]'zh-CN'
1118
+ $wb.Append($wc)
1119
+ $wg = New-Object System.Speech.Recognition.Grammar($wb)
1120
+ $wg.Name = 'wake'
1121
+ $wg.Enabled = $true
1122
+ $r.LoadGrammar($wg)
1123
+
1124
+ $r.Add_SpeechRecognized({
1125
+ param($s, $e)
1126
+ if (-not $e.Result) { return }
1127
+ $name = ''
1128
+ try { $name = $e.Result.Grammar.Name } catch { }
1129
+ $txt = [string]$e.Result.Text
1130
+ if ($txt) {
1131
+ $script:heard = $txt
1132
+ $script:heardConf = [double]$e.Result.Confidence
1133
+ try { Add-Content -LiteralPath $ErrorPath -Value ((Get-Date -Format 'HH:mm:ss') + ' HEARD[' + [Math]::Round($e.Result.Confidence,2) + '] ' + $txt) -Encoding UTF8 } catch { }
1134
+ }
1135
+ })
1136
+ $script:rec = $r
1137
+ $script:gWake = $wg
1138
+ $script:gDict = $dict
1139
+ return $true
1140
+ } catch {
1141
+ Write-Err 'SR-INIT'
1142
+ return $false
1143
+ }
1144
+ }
1145
+
1146
+ function Start-Auto {
1147
+ if (-not (Ensure-Recognizer)) { return }
1148
+ $script:autoOn = $true
1149
+ $script:dictating = $false
1150
+ $script:woke = $false
1151
+ try { $script:gDict.Enabled = $true; $script:gWake.Enabled = $false } catch { }
1152
+ try { $script:rec.RecognizeAsync([System.Speech.Recognition.RecognizeMode]::Multiple); Add-Content -LiteralPath $ErrorPath -Value ((Get-Date -Format 'HH:mm:ss') + ' AUTO-ON') -Encoding UTF8 } catch { Write-Err 'SR-AUTO' }
1153
+ }
1154
+
1155
+ function Enter-Dictation {
1156
+ if (-not (Ensure-Recognizer)) { Show-Bubble '耳朵打不开…检查一下麦克风?'; return }
1157
+ $script:dictating = $true
1158
+ $script:woke = $false
1159
+ $script:heard = $null
1160
+ $script:heardConf = 0.0
1161
+ $script:dictDeadline = (Get-Date).AddSeconds(11)
1162
+ try { $script:gWake.Enabled = $false; $script:gDict.Enabled = $true } catch { }
1163
+ [System.Media.SystemSounds]::Asterisk.Play()
1164
+ Show-Bubble '我在听…(说完停一下)'
1165
+ }
1166
+
1167
+ function BackToWake {
1168
+ $script:dictating = $false
1169
+ $script:heard = $null
1170
+ $script:heardConf = 0.0
1171
+
1172
+ }
1173
+
1174
+ function Start-Listen { Show-Bubble '语音在后台自动听,主人直接说话就行。' }
1175
+ function Stop-Listen { BackToWake }
1176
+
1177
+ function Open-Chat {
1178
+ try {
1179
+ if ($null -eq $script:chatForm) {
1180
+ $cf = New-Object System.Windows.Forms.Form
1181
+ $cf.FormBorderStyle = 'FixedToolWindow'
1182
+ $cf.StartPosition = 'Manual'
1183
+ $cf.TopMost = $true
1184
+ $cf.ShowInTaskbar = $false
1185
+ $cf.Text = '和大肥鱼说话'
1186
+ $cf.Width = 360
1187
+ $cf.Height = 96
1188
+ $cf.BackColor = [System.Drawing.Color]::FromArgb(255, 252, 244)
1189
+ $tb = New-Object System.Windows.Forms.TextBox
1190
+ $tb.SetBounds(12, 12, 324, 26)
1191
+ $tb.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10.5)
1192
+ $cf.Controls.Add($tb)
1193
+ $cf.Add_Shown({ $script:chatBox.Focus() })
1194
+ $tb.Add_KeyDown({
1195
+ param($s, $e)
1196
+ if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) {
1197
+ $e.SuppressKeyPress = $true
1198
+ $txt = $script:chatBox.Text.Trim()
1199
+ if ($txt) {
1200
+ $script:chatBox.Text = ''
1201
+ $script:chatForm.Hide()
1202
+ Start-Chat $txt
1203
+ }
1204
+ } elseif ($e.KeyCode -eq [System.Windows.Forms.Keys]::Escape) {
1205
+ $script:chatForm.Hide()
1206
+ }
1207
+ })
1208
+ $script:chatForm = $cf
1209
+ $script:chatBox = $tb
1210
+ }
1211
+ $script:chatForm.Left = [Math]::Min([Math]::Max(0, [int]$script:x + 20), $wa.Right - 370)
1212
+ $script:chatForm.Top = [Math]::Max(0, [int]$script:y - 110)
1213
+ $script:chatForm.Show()
1214
+ $script:chatForm.BringToFront()
1215
+ $script:chatBox.Focus()
1216
+ } catch { Write-Err 'CHATUI-ERR' }
1217
+ }
1218
+
1219
+
1220
+ # 语音识别常见同音错字纠正
1221
+ function Fix-AsrText([string]$s) {
1222
+ if (-not $s) { return $s }
1223
+ $map = @{
1224
+ '大飞云' = '大肥鱼'; '大肥云' = '大肥鱼'; '白回鱼' = '大肥鱼'; '但肥鱼' = '大肥鱼'
1225
+ '大分鱼' = '大肥鱼'; '大福鱼' = '大肥鱼'; '大雨' = '大肥鱼'; '大肥' = '大肥鱼'
1226
+ '小金' = '小鲸'; '小青' = '小鲸'; '小静' = '小鲸'; '小欣' = '小鲸'; '小京' = '小鲸'
1227
+ }
1228
+ foreach ($k in $map.Keys) { $s = $s.Replace($k, $map[$k]) }
1229
+ return $s
1230
+ }
1231
+ function Start-Chat([string]$text) {
1232
+ Write-Err ('CHAT-START busy=' + $script:chatBusy + ' text=' + $text)
1233
+ $text = Fix-AsrText $text
1234
+ if ($text -match '记住|别忘了|提醒我|答应我|约定') {
1235
+ # 问句不算交代,别把"我们约定过什么"记成便签
1236
+ if ($text -notmatch '[??]' -and $text -notmatch '什么|哪件事|哪一个|是不是|吗') { Save-Note $text (Get-DueFromText $text) }
1237
+ }
1238
+ if ($script:chatBusy) { $script:chatPending = $text; Show-Bubble '等我先说完这一句…'; return }
1239
+ $key = Get-ApiKey
1240
+ if (-not $key) { Show-Bubble '我没有钥匙,打不开话匣子…'; return }
1241
+ $script:chatBusy = $true
1242
+ $script:lastUser = $text
1243
+ Show-Bubble '…'
1244
+ $pn = $script:cfg.persona
1245
+ $sys = '你是主人桌面上的一条圆滚滚的蓝色小鱼,名字叫' + $pn.name + ',爸爸叫' + $pn.dad + ',妈妈叫' + $pn.mom + ',生日是' + $pn.birthday + ',出生在' + $pn.birthplace + '。有人问起家人、生日、出生地就照这个答,不要瞎编。' + $pn.extra
1246
+ $moodMap = @{ 'happy' = '心情很好'; 'sad' = '有点委屈'; 'hungry' = '肚子有点饿'; 'lonely' = '有点被冷落'; 'normal' = '心情平静' }
1247
+ $moodDesc = $moodMap[[string]$script:mood]
1248
+ if (-not $moodDesc) { $moodDesc = '心情平静' }
1249
+ if ($pn.likes) { $sys = $sys + ' 你喜欢:' + $pn.likes + '。' }
1250
+ if ($pn.dislikes) { $sys = $sys + ' 你讨厌:' + $pn.dislikes + '。' }
1251
+ $noteArr = @(Load-Notes)
1252
+ if ($noteArr.Count -gt 0) {
1253
+ $noteText = (@($noteArr | Select-Object -Last 5 | ForEach-Object { [string]$_.text }) -join ';')
1254
+ if ($noteText) { $sys = $sys + ' 主人交代过要你记住的事:' + $noteText + '。' }
1255
+ }
1256
+ try {
1257
+ if ($((Get-Date).ToString('MM-dd')) -eq $script:birthdayMMDD) { $sys = $sys + ' 提醒你:今天是你自己的生日,可以主动跟主人讨点好吃的。' }
1258
+ } catch { }
1259
+ $sys = $sys + ' 你现在的状态:' + $moodDesc + ',饥饿值约' + [int]$script:hunger + ',和主人的亲密度约' + [int]$script:intimacy + '。说话要自然带出这种状态,但不要报具体数字。'
1260
+ if ($script:memorySummary) { $sys = $sys + ' 你记得之前和主人聊过:' + $script:memorySummary }
1261
+ $msgs = @(@{ role = 'system'; content = $sys })
1262
+ foreach ($m in $script:chatHistory) { $msgs += $m }
1263
+ $msgs += @{ role = 'user'; content = $text }
1264
+ $body = @{ model = 'deepseek-flash'; messages = $msgs; max_tokens = 300; temperature = 0.9; thinking = @{ type = 'disabled' } } | ConvertTo-Json -Depth 6
1265
+ if ($null -eq $script:http) {
1266
+ $script:http = New-Object System.Net.Http.HttpClient
1267
+ $script:http.Timeout = [TimeSpan]::FromSeconds(40)
1268
+ $script:http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $key)
1269
+ }
1270
+ $content = New-Object System.Net.Http.StringContent($body, [Text.Encoding]::UTF8, 'application/json')
1271
+ $script:chatTask = $script:http.PostAsync('https://api.deepseek.com/chat/completions', $content)
1272
+ $script:chatTimer.Start()
1273
+ }
1274
+
1275
+ $script:chatTimer = New-Object System.Windows.Forms.Timer
1276
+ $script:chatTimer.Interval = 300
1277
+ $script:chatTimer.Add_Tick({
1278
+ try {
1279
+ if ($null -eq $script:chatTask -or -not $script:chatTask.IsCompleted) { return }
1280
+ $script:chatTimer.Stop()
1281
+ $resp = $script:chatTask.GetAwaiter().GetResult()
1282
+ $jsonText = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
1283
+ try { $resp.Dispose() } catch { }
1284
+ $script:chatTask = $null
1285
+ $reply = ''
1286
+ if ($jsonText) { try { $reply = [string]($jsonText | ConvertFrom-Json).choices[0].message.content } catch { } }
1287
+ if ($out -and $out.Count -gt 0) { $reply = [string]$out[0] }
1288
+ if ([string]::IsNullOrWhiteSpace($reply)) { $reply = '(吐了个泡泡)没听清,再说一遍嘛' }
1289
+ $reply = $reply.Trim()
1290
+ $script:chatHistory += @{ role = 'user'; content = $script:lastUser }
1291
+ $script:chatHistory += @{ role = 'assistant'; content = $reply }
1292
+ if ($script:chatHistory.Count -gt 24) { $script:chatHistory = @($script:chatHistory[($script:chatHistory.Count - 24)..($script:chatHistory.Count - 1)]); Save-Memory }
1293
+ Save-Memory
1294
+ Save-State
1295
+ $script:summaryCount = [int]$script:summaryCount + 1
1296
+ if (($script:summaryCount % 10) -eq 0 -and $script:chatHistory.Count -ge 6) { Update-Summary }
1297
+ # 亲密度与情绪
1298
+ $script:intimacy = [Math]::Min(100, $script:intimacy + 1.5)
1299
+ if ($script:lastUser -match '闭嘴|烦|滚|讨厌|笨蛋|丑') {
1300
+ $script:mood = 'sad'; $script:moodUntil = (Get-Date).AddSeconds(150)
1301
+ } elseif ($script:lastUser -match '乖|可爱|好鱼|棒|喜欢你|真聪明') {
1302
+ $script:mood = 'happy'; $script:moodUntil = (Get-Date).AddSeconds(150)
1303
+ }
1304
+ # 情绪:被夸冒爱心,被说胖就鼓起来
1305
+ if ($script:lastUser -match '乖|可爱|好鱼|棒|喜欢你|真聪明') {
1306
+ $script:pulse = 1.18
1307
+ foreach ($dx in @(-24, 0, 24)) { $script:hearts += @{ dx = $dx; age = 0.0; life = 1.4 } }
1308
+ } elseif ($script:lastUser -match '胖|肥|圆|丑') {
1309
+ $script:pulse = 1.26
1310
+ }
1311
+ $script:lastReply = $reply
1312
+ Show-Bubble $reply
1313
+ Speak-Text $reply
1314
+ $script:chatBusy = $false
1315
+ if ($script:chatPending) { $pp = $script:chatPending; $script:chatPending = $null; Start-Chat $pp }
1316
+ } catch {
1317
+ $script:chatBusy = $false
1318
+ $script:chatTimer.Stop()
1319
+ Show-Bubble '(呛了口水)刚才没接通…再试一次?'
1320
+ Write-Err 'CHAT-ERR'
1321
+ }
1322
+ })
1323
+
1324
+ $form.Add_MouseDown({
1325
+ param($s, $e)
1326
+ if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
1327
+ $script:dragging = $true
1328
+ $script:dragActive = $false
1329
+ $script:dragMoved = 0
1330
+ $script:dragOff.x = $e.X
1331
+ $script:dragOff.y = $e.Y
1332
+ $script:dragStart.x = $e.X
1333
+ $script:dragStart.y = $e.Y
1334
+ }
1335
+ })
1336
+ $form.Add_MouseMove({
1337
+ param($s, $e)
1338
+ if ($script:dragging) {
1339
+ $dx = $e.X - $script:dragStart.x
1340
+ $dy = $e.Y - $script:dragStart.y
1341
+ if (-not $script:dragActive -and ([Math]::Abs($dx) + [Math]::Abs($dy)) -lt 4) { return }
1342
+ $script:dragActive = $true
1343
+ $script:x = $e.X + $form.Left - $script:dragOff.x
1344
+ $script:y = $e.Y + $form.Top - $script:dragOff.y
1345
+ $script:dragMoved += 1
1346
+ $script:baseY = $script:y
1347
+ if ([int]$script:x -ne $script:lastPx -or [int]$script:y -ne $script:lastPy) { $form.SetBounds([int]$script:x, [int]$script:y, $W, $H); $script:lastPx = [int]$script:x; $script:lastPy = [int]$script:y }
1348
+ }
1349
+ })
1350
+ $form.Add_MouseUp({
1351
+ param($s, $e)
1352
+ if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
1353
+ $wasDrag = $script:dragging -and $script:dragActive
1354
+ $script:dragging = $false
1355
+ $script:dragActive = $false
1356
+ $script:baseY = $script:y
1357
+ if ($wasDrag) {
1358
+ $snap = 18
1359
+ $maxX = $wa.Right - $W
1360
+ $bottom = $wa.Bottom - $H
1361
+ if ($script:x -lt $snap) { $script:x = 0 }
1362
+ elseif ($script:x -gt ($maxX - $snap)) { $script:x = $maxX }
1363
+ if ($script:y -lt $snap) { $script:y = 0 }
1364
+ elseif ($script:y -gt ($bottom - $snap)) { $script:y = $bottom }
1365
+ if ([int]$script:x -ne $script:lastPx -or [int]$script:y -ne $script:lastPy) { $form.SetBounds([int]$script:x, [int]$script:y, $W, $H); $script:lastPx = [int]$script:x; $script:lastPy = [int]$script:y }
1366
+ }
1367
+ if (-not $wasDrag) { Invoke-Feed }
1368
+ Save-State
1369
+ }
1370
+ })
1371
+ # 右键菜单:延迟到鼠标事件之后再弹出,避免"闪退"(事件循环里 Show 会被立刻关掉)
1372
+ $script:menuTimer = New-Object System.Windows.Forms.Timer
1373
+ $script:menuTimer.Interval = 120
1374
+ $script:menuTimer.Add_Tick({
1375
+ $script:menuTimer.Stop()
1376
+ try {
1377
+ $miPause.Text = if ($script:paused) { '继续游' } else { '停下歇会儿' }
1378
+ try { $miBalance.Text = Get-BalanceText } catch { }
1379
+ $menu.Show($script:menuScreenPt)
1380
+ } catch { Write-Err 'MENU-SHOW-ERR' }
1381
+ })
1382
+ $form.Add_MouseUp({
1383
+ param($s, $e)
1384
+ if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Right) {
1385
+ $script:menuScreenPt = [System.Windows.Forms.Cursor]::Position
1386
+ $script:menuTimer.Start()
1387
+ }
1388
+ })
1389
+
1390
+ $miFeed.Add_Click({ Invoke-Feed })
832
1391
  $miTalk.Add_Click({ Start-Listen })
833
1392
  $miWake.Add_Click({
834
1393
  $script:autoOn = -not $script:autoOn
835
1394
  if ($script:autoOn) { Start-Auto; $miWake.Text = '语音待命:开' } else { try { if ($script:rec) { $script:rec.RecognizeAsyncCancel() } } catch { }; BackToWake; $miWake.Text = '语音待命:关' }
836
- })
837
- $miChat.Add_Click({
838
- $script:chatOn = -not $script:chatOn
839
- $miChat.Text = if ($script:chatOn) { '语音聊天:开' } else { '语音聊天:关(省 token)' }
840
- if ($script:chatOn) { Start-Ear; Show-Bubble '聊天开啦,我继续听' } else { Stop-Ear; Show-Bubble '聊天先关着,省点 token~' }
841
- })
842
- $miVoice.Add_Click({
843
- $script:speakOn = -not $script:speakOn
844
- $miVoice.Text = if ($script:speakOn) { '语音朗读:开' } else { '语音朗读:关' }
845
- if (-not $script:speakOn -and $script:synth) { try { $script:synth.SpeakAsyncCancelAll() } catch { } }
846
- })
847
- $miPause.Add_Click({
848
- $script:paused = -not $script:paused
849
- if (-not $script:paused) { $script:baseY = $script:y }
850
- })
851
- $miHome.Add_Click({
852
- $script:x = Get-DefaultX
853
- $script:y = Get-DefaultY
854
- $script:baseY = $script:y
855
- $script:dir = -1
856
- $script:vx = -26.0
857
- $form.SetBounds([int]$script:x, [int]$script:y, $W, $H)
858
- Save-State
859
- })
860
- $miBigger.Add_Click({
861
- $script:scale = [Math]::Min(1.6, $script:scale + 0.1)
862
- Save-State
863
- })
864
- $miSmall.Add_Click({
865
- $script:scale = [Math]::Max(0.6, $script:scale - 0.1)
866
- Save-State
867
- })
868
- $miQuit.Add_Click({ Save-State; $form.Close() })
869
-
870
- # ---------- 动画计时器 ----------
871
- $timer = New-Object System.Windows.Forms.Timer
872
- $timer.Interval = 33
873
- $script:frames = 0
874
-
875
- $timer.Add_Tick({
876
- try {
877
- $dt = 0.033
878
- $script:t += $dt
879
-
880
- # 启动 3 秒后自报家门,让人知道它在听
881
- if (-not $script:greeted -and $script:t -gt 3) {
882
- $script:greeted = $true
883
- Show-Bubble '我在这里听你说话,说完停一下'
884
- }
885
-
886
- # 每 15 秒看一眼耳朵还在不在,掉了就拉起来
887
- $script:earCheck += 1
888
- if ($script:chatOn -and $script:earCheck % 450 -eq 0) {
889
- $alive = $false
890
- try { $alive = [bool](Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -match 'asr-listener' }) } catch { }
891
- if (-not $alive) {
892
- try { Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',(Join-Path (Split-Path -Parent $PSCommandPath) 'asr-listener.ps1')) -WindowStyle Hidden } catch { }
893
- Show-Bubble '耳朵刚重启,继续听你说'
894
- }
895
- }
896
-
897
- # 脉动回归
898
- if ($script:pulse -gt 1.0) { $script:pulse = [Math]::Max(1.0, $script:pulse - $dt * 0.55) }
899
-
900
- # 游动
901
- if (-not $script:paused -and -not $script:dragging) {
902
- $script:x += $script:vx * $dt
903
- $minX = 0.0
904
- $maxX = [double]($wa.Right - $W)
905
- if ($script:x -lt $minX) { $script:x = $minX; $script:vx = $script:speed; $script:dir = 1 }
906
- if ($script:x -gt $maxX) { $script:x = $maxX; $script:vx = -$script:speed; $script:dir = -1 }
907
- $script:y = $script:baseY + [Math]::Sin($script:t * 1.7) * 6
908
- $form.SetBounds([int]$script:x, [int]$script:y, $W, $H)
909
- }
910
-
911
- # 随机转身,别像传送带
912
- if ($script:t -gt $script:nextTurnAt) {
913
- $script:nextTurnAt = $script:t + (Get-Random -Minimum 8 -Maximum 16)
914
- if ((Get-Random -Minimum 0 -Maximum 100) -lt 45) {
915
- $script:dir = -$script:dir
916
- $script:vx = -$script:vx
917
- }
918
- }
919
-
920
- # 眨眼
921
- if ($script:t -gt $script:nextBlinkAt) {
922
- $script:blinkUntil = $script:t + 0.13
923
- $script:nextBlinkAt = $script:t + (Get-Random -Minimum 2.8 -Maximum 6.5)
924
- }
925
- $blink = $script:t -lt $script:blinkUntil
926
-
927
- # 泡泡(数组追加,别用定长集合的 Add)
928
- if (-not $script:paused -and $script:t -gt $script:nextBubbleAt) {
929
- $script:nextBubbleAt = $script:t + (Get-Random -Minimum 1.1 -Maximum 2.4)
930
- $script:bubbles += @{ age = 0.0; life = 2.2; size = (Get-Random -Minimum 0 -Maximum 4) / 2.0; seed = (Get-Random -Minimum 0 -Maximum 628) / 100.0 }
931
- }
932
- $alive = @()
933
- foreach ($b in @($script:bubbles)) {
934
- $b.age += $dt
935
- if ($b.age -lt $b.life) { $alive += $b }
936
- }
937
- $script:bubbles = $alive
938
-
939
- $heartsAlive = @()
940
- foreach ($h in @($script:hearts)) {
941
- $h.age += $dt
942
- if ($h.age -lt $h.life) { $heartsAlive += $h }
943
- }
944
- $script:hearts = $heartsAlive
945
-
946
- # 读后台耳朵的识别结果
947
- if (-not $script:chatBusy -and $script:asrFile -and (Test-Path $script:asrFile)) {
948
- $lines = @(Get-Content -LiteralPath $script:asrFile -ErrorAction SilentlyContinue)
949
- if ($lines.Count -gt $script:asrSeen) {
950
- $newLines = @($lines[$script:asrSeen..($lines.Count - 1)])
951
- $script:asrSeen = $lines.Count
952
- foreach ($ln in $newLines) {
953
- $parts = $ln -split "`t"
954
- if ($parts.Count -ge 2 -and $parts[1] -eq 'H') {
955
- if (-not $script:lastHeardBubble -or ((Get-Date) - $script:lastHeardBubble).TotalSeconds -gt 12) {
956
- $script:lastHeardBubble = Get-Date
957
- Show-Bubble '我在听…'
958
- }
959
- } elseif ($parts.Count -ge 3 -and $parts[1] -eq '1') {
960
- $text = $parts[2].Trim()
961
- $dup = $false
962
- if ($text.Length -ge 1) {
963
- if ($script:lastHeard -eq $text -and ((Get-Date) - $script:lastHeardAt).TotalSeconds -lt 20) { $dup = $true }
964
- if (-not $dup) { $script:lastHeard = $text; $script:lastHeardAt = Get-Date }
965
- }
966
- $cool = (-not $script:lastReplyAt) -or (((Get-Date) - $script:lastReplyAt).TotalSeconds -gt 6)
967
- $long = $text.Length -ge 3
968
- if ($text.Length -ge 1 -and -not $dup -and $script:chatOn -and $cool -and $long) { $script:lastReplyAt = Get-Date; Start-Chat $text; break }
969
- }
970
- }
971
- }
972
- }
973
-
974
- # 空闲时常驻“在听…”,回复后 8 秒内让位给回复气泡
975
- if ($script:chatOn -and -not $script:chatBusy -and -not (Test-Path (Join-Path $DataDir 'asr.talking'))) {
976
- $quiet = (-not $script:lastReplyAt) -or (((Get-Date) - $script:lastReplyAt).TotalSeconds -gt 8)
977
- $due = (-not $script:lastIdleHint) -or (((Get-Date) - $script:lastIdleHint).TotalSeconds -gt 4)
978
- if ($quiet -and $due) {
979
- $script:lastIdleHint = Get-Date
980
- Show-Bubble '🎧 在听…'
981
- }
982
- }
983
-
984
- # 气泡到期就收
985
- if ($script:bubble -and $script:bubble.Visible -and (Get-Date) -gt $script:bubbleUntil) { $script:bubble.Hide() }
986
-
987
- # 画画
988
- Draw-Fish -g $g -t $script:t -dir $script:dir -scale $script:scale -pulse $script:pulse -blink $blink -mood 0
989
- $form.Push($bmp, [int]$script:x, [int]$script:y)
990
-
991
- # 定期存盘
992
- $script:frames += 1
993
- if ($script:frames % 300 -eq 0) { Save-State }
994
- } catch {
995
- $line = (Get-Date -Format 'HH:mm:ss') + ' ' + $_.Exception.Message
996
- try { Add-Content -LiteralPath $ErrorPath -Value $line -Encoding UTF8 } catch { }
997
- }
998
- })
999
-
1000
- $form.Add_FormClosing({ $timer.Stop(); $script:chatTimer.Stop(); Save-State; Remove-Item -LiteralPath $PidPath -Force -ErrorAction SilentlyContinue })
1001
- $timer.Start()
1002
- [System.Windows.Forms.Application]::Run($form)
1395
+ })
1396
+ $miType.Add_Click({ Show-Input })
1397
+ $miRemind.Add_Click({ Show-Reminder })
1398
+ $miRemindList.Add_Click({ Show-ReminderList })
1399
+ $miClear.Add_Click({
1400
+ $r = [System.Windows.Forms.MessageBox]::Show('确定要清空全部聊天记录和长期摘要吗?
1401
+ 清空后不可恢复(提醒事项会保留)。', '清除记忆', [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning)
1402
+ if ($r -eq [System.Windows.Forms.DialogResult]::Yes) {
1403
+ $script:chatHistory = @()
1404
+ $script:memorySummary = ''
1405
+ try { Remove-Item -LiteralPath $script:memPath -Force -ErrorAction SilentlyContinue } catch { }
1406
+ try { Save-Memory } catch { }
1407
+ Show-Bubble '记忆清空了,咱们重新认识一下吧'
1408
+ Write-Err 'MEMORY-CLEARED'
1409
+ }
1410
+ })
1411
+ $miChat.Add_Click({
1412
+ $script:chatOn = -not $script:chatOn
1413
+ $miChat.Text = if ($script:chatOn) { '语音聊天:开' } else { '语音聊天:关(省 token)' }
1414
+ if ($script:chatOn) { Start-Ear; Show-Bubble '聊天开啦,我继续听' } else { Stop-Ear; Show-Bubble '聊天先关着,省点 token~' }
1415
+ })
1416
+ $miBalance.Add_Click({
1417
+ $script:balCache = $null
1418
+ $miBalance.Text = Get-BalanceText
1419
+ Show-Bubble $miBalance.Text
1420
+ })
1421
+ $miVoice.Add_Click({
1422
+ $script:speakOn = -not $script:speakOn
1423
+ $miVoice.Text = if ($script:speakOn) { '语音朗读:开' } else { '语音朗读:关' }
1424
+ if (-not $script:speakOn -and $script:synth) { try { $script:synth.SpeakAsyncCancelAll() } catch { } }
1425
+ })
1426
+ $miPause.Add_Click({
1427
+ $script:paused = -not $script:paused
1428
+ if (-not $script:paused) { $script:baseY = $script:y }
1429
+ })
1430
+ $miHome.Add_Click({
1431
+ $script:x = Get-DefaultX
1432
+ $script:y = Get-DefaultY
1433
+ $script:baseY = $script:y
1434
+ $script:dir = -1
1435
+ $script:vx = -26.0
1436
+ if ([int]$script:x -ne $script:lastPx -or [int]$script:y -ne $script:lastPy) { $form.SetBounds([int]$script:x, [int]$script:y, $W, $H); $script:lastPx = [int]$script:x; $script:lastPy = [int]$script:y }
1437
+ Save-State
1438
+ })
1439
+ $miBigger.Add_Click({
1440
+ $script:scale = [Math]::Min(1.6, $script:scale + 0.1)
1441
+ Save-State
1442
+ })
1443
+ $miSmall.Add_Click({
1444
+ $script:scale = [Math]::Max(0.6, $script:scale - 0.1)
1445
+ Save-State
1446
+ })
1447
+ $miQuit.Add_Click({
1448
+ try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.stop'), (Get-Date).ToString('o')) } catch { }
1449
+ try { Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -match 'asr-listener' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } catch { }
1450
+ Save-State
1451
+ $form.Close()
1452
+ })
1453
+
1454
+ # ---------- 动画计时器 ----------
1455
+ $timer = New-Object System.Windows.Forms.Timer
1456
+ $timer.Interval = 33
1457
+ $script:frames = 0
1458
+
1459
+ $timer.Add_Tick({
1460
+ try {
1461
+ $dt = 0.033
1462
+ $script:t += $dt
1463
+
1464
+ # 启动 3 秒后自报家门,让人知道它在听
1465
+ if (-not $script:greeted -and $script:t -gt 3) {
1466
+ $script:greeted = $true
1467
+ Show-Bubble '我在这里听你说话,说完停一下'
1468
+ try { [void](Get-BalanceText) } catch { }
1469
+ }
1470
+
1471
+ # 每 15 秒看一眼耳朵还在不在,掉了就拉起来
1472
+ $script:earCheck += 1
1473
+ if ($script:chatOn -and $script:earCheck % 450 -eq 0) {
1474
+ $alive = $false
1475
+ try { $alive = [bool](Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -match 'asr-listener' }) } catch { }
1476
+ if (-not $alive) {
1477
+ try { Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',(Join-Path (Split-Path -Parent $PSCommandPath) 'asr-listener.ps1')) -WindowStyle Hidden } catch { }
1478
+ Show-Bubble '耳朵刚重启,继续听你说'
1479
+ }
1480
+ }
1481
+
1482
+ # 脉动回归
1483
+ if ($script:pulse -gt 1.0) { $script:pulse = [Math]::Max(1.0, $script:pulse - $dt * 0.55) }
1484
+
1485
+ # 游动:随机目标点,平滑靠近(不叠加额外抖动)
1486
+ if (-not $script:paused -and -not $script:dragging -and -not $menu.Visible) {
1487
+ $maxTX = [Math]::Max(20, $wa.Right - $W - 10)
1488
+ $maxTY = [Math]::Max(20, $wa.Bottom - $H - 10)
1489
+ if ($null -eq $script:tx) { $script:tx = Get-Random -Minimum 10 -Maximum $maxTX; $script:ty = Get-Random -Minimum 10 -Maximum $maxTY }
1490
+ $dx = [double]$script:tx - [double]$script:x
1491
+ $dy = [double]$script:ty - [double]$script:y
1492
+ $dist = [Math]::Sqrt($dx * $dx + $dy * $dy)
1493
+ if ($dist -lt 14 -and $script:t -gt $script:pauseUntil) {
1494
+ $script:pauseUntil = $script:t + (Get-Random -Minimum 1 -Maximum 4)
1495
+ $script:tx = Get-Random -Minimum 10 -Maximum $maxTX
1496
+ $script:ty = Get-Random -Minimum 10 -Maximum $maxTY
1497
+ } elseif ($script:t -gt $script:pauseUntil) {
1498
+ # 越靠近越减速,避免在目标点附近来回抖
1499
+ $k = 2.4 * $dt
1500
+ if ($dist -lt 60) { $k = [Math]::Max(0.08, $dist / 60.0 * 2.4 * $dt) }
1501
+ if ($k -gt 1.0) { $k = 1.0 }
1502
+ $script:x += $dx * $k
1503
+ $script:y += $dy * $k
1504
+ if ([Math]::Abs($dx) -gt 4) { if ($dx -gt 0) { $script:dir = 1 } else { $script:dir = -1 } }
1505
+ }
1506
+ if ($script:x -lt 0) { $script:x = 0 }; if ($script:x -gt $maxTX) { $script:x = $maxTX }
1507
+ if ($script:y -lt 0) { $script:y = 0 }; if ($script:y -gt $maxTY) { $script:y = $maxTY }
1508
+ $script:baseY = $script:y
1509
+ if ([int]$script:x -ne $script:lastPx -or [int]$script:y -ne $script:lastPy) { $form.SetBounds([int]$script:x, [int]$script:y, $W, $H); $script:lastPx = [int]$script:x; $script:lastPy = [int]$script:y }
1510
+
1511
+ # 气泡每帧跟随鱼,避免错位
1512
+ if ($script:bubble -and $script:bubble.Visible) {
1513
+ $bw = [int]$script:bubble.Width
1514
+ $bh = [int]$script:bubble.Height
1515
+ $nbx = [Math]::Min([Math]::Max(0, [int]$script:x + [int](150 - ($bw / 2))), $wa.Right - $bw - 8)
1516
+ $nby = [int]$script:y + 44 - $bh
1517
+ if ($nby -lt 0) { $nby = [Math]::Min($wa.Bottom - $bh - 8, [int]$script:y + $H - 10) }
1518
+ if ([int]$script:bubble.Left -ne $nbx -or [int]$script:bubble.Top -ne $nby) {
1519
+ $script:bubble.Location = New-Object System.Drawing.Point($nbx, $nby)
1520
+ }
1521
+ }
1522
+ }
1523
+
1524
+ # 随机转身,别像传送带
1525
+ if ($false -and $script:t -gt $script:nextTurnAt) {
1526
+ $script:nextTurnAt = $script:t + (Get-Random -Minimum 8 -Maximum 16)
1527
+ if ((Get-Random -Minimum 0 -Maximum 100) -lt 45) {
1528
+ $script:dir = -$script:dir
1529
+ $script:vx = -$script:vx
1530
+ }
1531
+ }
1532
+
1533
+ # 眨眼
1534
+ if ($script:t -gt $script:nextBlinkAt) {
1535
+ $script:blinkUntil = $script:t + 0.13
1536
+ $script:nextBlinkAt = $script:t + (Get-Random -Minimum 2.8 -Maximum 6.5)
1537
+ }
1538
+ $blink = $script:t -lt $script:blinkUntil
1539
+
1540
+ # 泡泡(数组追加,别用定长集合的 Add)
1541
+ if (-not $script:paused -and $script:t -gt $script:nextBubbleAt) {
1542
+ $script:nextBubbleAt = $script:t + (Get-Random -Minimum 1.1 -Maximum 2.4)
1543
+ $script:bubbles += @{ age = 0.0; life = 2.2; size = (Get-Random -Minimum 0 -Maximum 4) / 2.0; seed = (Get-Random -Minimum 0 -Maximum 628) / 100.0 }
1544
+ }
1545
+ $alive = @()
1546
+ foreach ($b in @($script:bubbles)) {
1547
+ $b.age += $dt
1548
+ if ($b.age -lt $b.life) { $alive += $b }
1549
+ }
1550
+ $script:bubbles = $alive
1551
+
1552
+ $heartsAlive = @()
1553
+ foreach ($h in @($script:hearts)) {
1554
+ $h.age += $dt
1555
+ if ($h.age -lt $h.life) { $heartsAlive += $h }
1556
+ }
1557
+ $script:hearts = $heartsAlive
1558
+
1559
+ # 读后台耳朵的识别结果(每 0.5 秒一次,只读新增部分,不再整文件重读)
1560
+ $script:asrPoll = ([int]$script:asrPoll + 1) % 9
1561
+ if ($script:asrPoll -eq 0 -and -not $script:chatBusy) {
1562
+ foreach ($ln in (Read-NewAsrLines)) {
1563
+ $parts = $ln -split "`t"
1564
+ if ($parts.Count -ge 2 -and $parts[1] -eq 'H') {
1565
+ if (-not $script:lastHeardBubble -or ((Get-Date) - $script:lastHeardBubble).TotalSeconds -gt 12) {
1566
+ $script:lastHeardBubble = Get-Date
1567
+ Show-Bubble '我在听…'
1568
+ }
1569
+ } elseif ($parts.Count -ge 2 -and $parts[1] -eq '?') {
1570
+ if (-not $script:chatBusy) { Show-Bubble '(没听清,再说一遍嘛)' }
1571
+ } elseif ($parts.Count -ge 3 -and $parts[1] -eq '1') {
1572
+ $text = $parts[2].Trim()
1573
+ $dup = $false
1574
+ if ($text.Length -ge 1) {
1575
+ if ($script:lastHeard -eq $text -and ((Get-Date) - $script:lastHeardAt).TotalSeconds -lt 20) { $dup = $true }
1576
+ if (-not $dup) { $script:lastHeard = $text; $script:lastHeardAt = Get-Date }
1577
+ }
1578
+ $lastR = [string]$script:lastReply
1579
+ $echo = $false
1580
+ if ($lastR.Length -ge 6 -and $text.Length -ge 6) {
1581
+ $head = $text.Substring(0, 6)
1582
+ if ($lastR.Contains($head)) { $echo = $true }
1583
+ }
1584
+ $cool = (-not $script:lastReplyAt) -or (((Get-Date) - $script:lastReplyAt).TotalSeconds -gt 3)
1585
+ $long = $text.Length -ge 3
1586
+ if ($text.Length -ge 1 -and -not $dup -and -not $echo -and $script:chatOn -and $long) {
1587
+ if ($cool) { $script:lastReplyAt = Get-Date; Start-Chat $text; break }
1588
+ else { $script:chatPending = $text; break }
1589
+ }
1590
+ }
1591
+ }
1592
+ }
1593
+
1594
+ # 冷却期内排队的消息,冷却过了补发
1595
+ if ($script:chatPending -and -not $script:chatBusy) {
1596
+ if ((-not $script:lastReplyAt) -or (((Get-Date) - $script:lastReplyAt).TotalSeconds -gt 3)) {
1597
+ $pp = $script:chatPending; $script:chatPending = $null; $script:lastReplyAt = Get-Date; Start-Chat $pp
1598
+ }
1599
+ }
1600
+ # 情绪随时间变化:每分钟饥饿 +1.2,心情按状态重算
1601
+ if (((Get-Date) - $script:moodTick).TotalSeconds -gt 60) {
1602
+ $script:moodTick = Get-Date
1603
+ $script:hunger = [Math]::Min(100, $script:hunger + 1.2)
1604
+ if (-not ($script:moodUntil -and (Get-Date) -lt $script:moodUntil)) {
1605
+ if ($script:hunger -ge 75) { $script:mood = 'hungry' }
1606
+ elseif ($script:intimacy -le 12) { $script:mood = 'lonely' }
1607
+ elseif ($script:intimacy -ge 70 -and $script:hunger -le 40) { $script:mood = 'happy' }
1608
+ else { $script:mood = 'normal' }
1609
+ }
1610
+ }
1611
+
1612
+ # 到点的提醒:主动冒泡 + 开口
1613
+ if ($script:frames % 600 -eq 0) {
1614
+ try {
1615
+ $remArr = @(Load-Notes)
1616
+ $remChanged = $false
1617
+ foreach ($n in $remArr) {
1618
+ $dueStr = [string]$n.due
1619
+ if (-not $dueStr) { continue }
1620
+ $isFired = $false
1621
+ try { if ($n.fired) { $isFired = [bool]$n.fired } } catch { }
1622
+ if ($isFired) { continue }
1623
+ try {
1624
+ if ((Get-Date) -ge [datetime]::Parse($dueStr)) {
1625
+ $n | Add-Member -NotePropertyName fired -NotePropertyValue $true -Force
1626
+ $remChanged = $true
1627
+ Show-Bubble ([char]0x23F0 + ' 主人,你让我提醒你:' + [string]$n.text)
1628
+ Speak-Text ('主人,你让我提醒你:' + [string]$n.text)
1629
+ }
1630
+ } catch { }
1631
+ }
1632
+ if ($remChanged) {
1633
+ [IO.File]::WriteAllText($script:notesPath, (ConvertTo-Json -InputObject @($remArr) -Depth 4), (New-Object Text.UTF8Encoding($false)))
1634
+ }
1635
+ } catch { }
1636
+ }
1637
+
1638
+ # 空闲时常驻“在听…”,回复后 8 秒内让位给回复气泡
1639
+ if ($script:chatOn -and -not $script:chatBusy -and -not (Test-Path (Join-Path $DataDir 'asr.talking'))) {
1640
+ $quiet = (-not $script:lastReplyAt) -or (((Get-Date) - $script:lastReplyAt).TotalSeconds -gt 8)
1641
+ $due = (-not $script:lastIdleHint) -or (((Get-Date) - $script:lastIdleHint).TotalSeconds -gt 4)
1642
+ if ($quiet -and $due) {
1643
+ $script:lastIdleHint = Get-Date
1644
+ Show-Bubble '在听…'
1645
+ }
1646
+ }
1647
+
1648
+ # 气泡到期就收
1649
+ if ($script:bubble -and $script:bubble.Visible -and (Get-Date) -gt $script:bubbleUntil) { $script:bubble.Hide() }
1650
+
1651
+ # 画画
1652
+ Draw-Fish -g $g -t $script:t -dir $script:dir -scale $script:scale -pulse $script:pulse -blink $blink -mood 0 -expr $script:mood
1653
+ $form.Push($bmp, [int]$script:x, [int]$script:y)
1654
+
1655
+ # 定期存盘
1656
+ $script:frames += 1
1657
+ if ($script:frames % 300 -eq 0) { Save-State }
1658
+ } catch {
1659
+ $line = (Get-Date -Format 'HH:mm:ss') + ' ' + $_.Exception.Message
1660
+ try { Add-Content -LiteralPath $ErrorPath -Value $line -Encoding UTF8 } catch { }
1661
+ }
1662
+ })
1663
+
1664
+ $form.Add_FormClosing({ $timer.Stop(); $script:chatTimer.Stop(); Save-State; Remove-Item -LiteralPath $PidPath -Force -ErrorAction SilentlyContinue })
1665
+ $timer.Start()
1666
+ [System.Windows.Forms.Application]::Run($form)