fix(remotedevice): 媒体回传落盘 + webui SSE panic + GUI 相机跨平台

1. remotedevice 媒体落盘(核心改动)
   设备录像/照片二进制聚合后写入 <data>/device_media/<reqID>.<ext>,
   cmd_result 返回 file 路径,不再 base64 内联——10s 录像数 MB 的
   base64 会撑爆 LLM 上下文与工具结果管道。未配置目录时保持旧内联行为。
   新增 TestWSBinaryMediaToFile 覆盖。

2. webui SSE 'send on closed channel' panic(生产单日 4924 次)
   handleChatEvents 的 defer close(writeCh) 与 Subscribe 回调闭包竞态:
   handler 退出后总线仍可能异步触发回调向已关闭 channel 发送。
   改为 writer goroutine select on done 退出,不 close channel;
   defer 中等待 writerDone 保证无残余写入。
   顺带补 mockPluginMgr.StopAndUnload(3ceb69f 接口变更漏改测试)。

3. GUI camerasue 平台分支
   ffmpeg 参数原硬编码 Linux v4l2(/dev/video0),Windows 上必然失败。
   现按平台探测:win32=dshow(枚举设备名取第一个视频设备)、
   darwin=avfoundation、linux=v4l2;录像编码 Windows 交给 mp4 muxer 默认。
This commit is contained in:
JianFeeeee
2026-08-26 01:25:40 +08:00
parent 8e66b49873
commit b78fd4f342
6 changed files with 208 additions and 30 deletions

View File

@ -9,6 +9,8 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@ -207,6 +209,73 @@ func TestWSBinaryChunkUpload(t *testing.T) {
}
}
// ===== 媒体落盘:SetMediaDir 后 cmd_result 返回 file 路径而非 base64 内联 =====
func TestWSBinaryMediaToFile(t *testing.T) {
reg := NewRegistry()
token := "test-token-123"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
mediaDir := t.TempDir()
reg.SetMediaDir(mediaDir)
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-media","name":"媒体机","kind":"computer","caps":["cmd"]}}`))
if _, _, err := cli.readMsg(); err != nil { // hello_ack
t.Fatalf("read hello_ack: %v", err)
}
videoData := make([]byte, 30000)
for i := range videoData {
videoData[i] = byte(i % 253)
}
go func() {
time.Sleep(50 * time.Millisecond)
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_data_start", "req_id": "req-file-1",
"kind": "camera_video", "mime": "video/mp4",
"total": len(videoData),
}))
const chunk = 8192
for off := 0; off < len(videoData); off += chunk {
end := off + chunk
if end > len(videoData) {
end = len(videoData)
}
cli.sendBinary(videoData[off:end])
}
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_data_end", "req_id": "req-file-1", "status": "ok",
}))
}()
res, err := reg.AwaitResult("req-file-1", 5*time.Second)
if err != nil {
t.Fatalf("await result: %v", err)
}
// 落盘模式:file 字段存在且内容一致;不应再有 data_base64
fp, ok := res["file"].(string)
if !ok || fp == "" {
t.Fatalf("expected file path in result, got %v", res)
}
if _, hasB64 := res["data_base64"]; hasB64 {
t.Fatal("data_base64 should be absent in file mode")
}
if want := filepath.Join(mediaDir, "req-file-1.mp4"); fp != want {
t.Fatalf("file path = %s, want %s", fp, want)
}
got, err := os.ReadFile(fp)
if err != nil {
t.Fatalf("read media file: %v", err)
}
if string(got) != string(videoData) {
t.Fatal("media file content mismatch")
}
}
// ===== 端到端:PushData 下发音频(网关→设备 cmd_speech 协议)=====
func TestWSPushDataAudio(t *testing.T) {