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 }