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
|
}
|