From 1f725abec29ad465f3c73c28c360818139761212 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 03 Jul 2026 05:28:17 +0800
Subject: [PATCH] Record R221 2.2.2 release candidate

---
 C1.source/privatevoice.src/services/diagnostics_service.go |  196 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 196 insertions(+), 0 deletions(-)

diff --git a/C1.source/privatevoice.src/services/diagnostics_service.go b/C1.source/privatevoice.src/services/diagnostics_service.go
new file mode 100644
index 0000000..15f79f0
--- /dev/null
+++ b/C1.source/privatevoice.src/services/diagnostics_service.go
@@ -0,0 +1,196 @@
+package services
+
+import (
+	"archive/zip"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"runtime"
+	"time"
+	"voicesnap/internal/config"
+	"voicesnap/internal/model"
+	"voicesnap/internal/modelselection"
+	"voicesnap/internal/paths"
+)
+
+type DiagnosticsService struct {
+	cfg     *config.Config
+	version string
+}
+
+func NewDiagnosticsService(cfg *config.Config, version string) *DiagnosticsService {
+	if cfg == nil {
+		cfg = config.Default()
+	}
+	return &DiagnosticsService{cfg: cfg, version: version}
+}
+
+func (s *DiagnosticsService) GetRuntimeStats() map[string]interface{} {
+	var mem runtime.MemStats
+	runtime.ReadMemStats(&mem)
+	return map[string]interface{}{
+		"version":       s.version,
+		"goos":          runtime.GOOS,
+		"goarch":        runtime.GOARCH,
+		"go_version":    runtime.Version(),
+		"goroutines":    runtime.NumGoroutine(),
+		"cpu_count":     runtime.NumCPU(),
+		"heap_alloc_mb": bytesToMiB(mem.HeapAlloc),
+		"heap_sys_mb":   bytesToMiB(mem.HeapSys),
+		"stack_sys_mb":  bytesToMiB(mem.StackSys),
+		"next_gc_mb":    bytesToMiB(mem.NextGC),
+		"num_gc":        mem.NumGC,
+		"data_dir":      paths.AppSupportDir(),
+		"models_dir":    paths.ModelsRoot(),
+	}
+}
+
+func (s *DiagnosticsService) ExportDiagnostics() (string, error) {
+	dir := filepath.Join(paths.AppSupportDir(), "diagnostics")
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return "", err
+	}
+	outPath := filepath.Join(dir, "privatevoice-diagnostics-"+time.Now().Format("20060102-150405")+".zip")
+
+	out, err := os.Create(outPath)
+	if err != nil {
+		return "", err
+	}
+	defer out.Close()
+
+	zw := zip.NewWriter(out)
+	if err := s.addJSON(zw, "runtime.json", s.GetRuntimeStats()); err != nil {
+		zw.Close()
+		return "", err
+	}
+	if err := s.addJSON(zw, "config.redacted.json", redactedConfig(s.cfg)); err != nil {
+		zw.Close()
+		return "", err
+	}
+	if err := s.addJSON(zw, "models.json", modelDiagnostics(s.cfg)); err != nil {
+		zw.Close()
+		return "", err
+	}
+	if err := addLogFiles(zw); err != nil {
+		zw.Close()
+		return "", err
+	}
+	if err := zw.Close(); err != nil {
+		return "", err
+	}
+	return outPath, nil
+}
+
+func (s *DiagnosticsService) addJSON(zw *zip.Writer, name string, value interface{}) error {
+	data, err := json.MarshalIndent(value, "", "  ")
+	if err != nil {
+		return err
+	}
+	w, err := zw.Create(name)
+	if err != nil {
+		return err
+	}
+	_, err = w.Write(append(data, '\n'))
+	return err
+}
+
+func addLogFiles(zw *zip.Writer) error {
+	for _, name := range []string{"app.log", "app.log.1", "app.log.2", "app.log.3"} {
+		path := filepath.Join(paths.AppSupportDir(), name)
+		if err := addFileIfExists(zw, path, filepath.Join("logs", name)); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func addFileIfExists(zw *zip.Writer, path, zipName string) error {
+	in, err := os.Open(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+	defer in.Close()
+
+	info, err := in.Stat()
+	if err != nil {
+		return err
+	}
+	header, err := zip.FileInfoHeader(info)
+	if err != nil {
+		return err
+	}
+	header.Name = zipName
+	header.Method = zip.Deflate
+	w, err := zw.CreateHeader(header)
+	if err != nil {
+		return err
+	}
+	_, err = io.Copy(w, in)
+	return err
+}
+
+func redactedConfig(cfg *config.Config) map[string]interface{} {
+	if cfg == nil {
+		cfg = config.Default()
+	}
+	return map[string]interface{}{
+		"HotkeyVK":           cfg.HotkeyVK,
+		"HotkeyMode":         cfg.HotkeyMode,
+		"AutoHide":           cfg.AutoHide,
+		"SoundFeedback":      cfg.SoundFeedback,
+		"HideDockIcon":       cfg.HideDockIcon,
+		"CopyToClipboard":    cfg.CopyToClipboard,
+		"DeviceNameSet":      cfg.DeviceName != "",
+		"SelectedModelID":    cfg.SelectedModelID,
+		"ModelSelectionMode": cfg.ModelSelectionMode,
+		"LanguageMode":       cfg.LanguageMode,
+		"LanguageID":         cfg.LanguageID,
+	}
+}
+
+func modelDiagnostics(cfg *config.Config) map[string]interface{} {
+	current := modelselection.Resolve(cfg, nil)
+	items := make([]map[string]interface{}, 0)
+	for _, profile := range model.ListModelProfiles() {
+		resolved, err := model.ResolveModel(profile.ID)
+		item := map[string]interface{}{
+			"model_id":      profile.ID,
+			"display_name":  profile.DisplayName,
+			"backend":       profile.BackendKind,
+			"tier":          profile.Tier,
+			"num_threads":   profile.NumThreads,
+			"current":       profile.ID == current.ModelID,
+			"supported":     model.IsModelSupportedInCurrentBuild(profile.ID),
+			"download_size": profile.ApproxSize,
+		}
+		if err != nil {
+			item["status"] = "resolve_error"
+			item["error"] = err.Error()
+		} else {
+			item["status"] = string(resolved.Status)
+			item["usable"] = resolved.IsUsable()
+			item["source_dir_kind"] = resolved.SourceDirKind
+			item["missing"] = resolved.Missing
+			item["problems"] = resolved.Problems
+		}
+		items = append(items, item)
+	}
+	return map[string]interface{}{
+		"current_model_id":      current.ModelID,
+		"selection_mode":        current.SelectionMode,
+		"effective_language_id": current.LanguageSettings.EffectiveLanguageID,
+		"fallback_reason":       current.FallbackReason,
+		"has_any_usable_model":  model.HasAnyUsableModel(),
+		"supported_build_note":  fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
+		"models":                items,
+	}
+}
+
+func bytesToMiB(value uint64) float64 {
+	return float64(value) / 1024.0 / 1024.0
+}

--
Gitblit v1.9.3