// Окно с индикаторами вместо чёрной консоли. // // Запускает `node server.mjs` дочерним процессом, показывает его вывод, // сворачивается в трей и красит значок по снимку из GET /api/state. // Собирается компилятором из состава Windows — см. build.cmd, ставить нечего. // // Важное про снимок: state обновляет сторож, раз в watchdogSec секунд. // Поэтому смотреть надо на поле checked, а не только на ready: иначе окно // покажет «всё хорошо» через секунду после того, как браузер убили. using System; using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.Drawing.Drawing2D; using System.IO; using System.Net; using System.Text; using System.Threading; using System.Web.Script.Serialization; using System.Windows.Forms; class Tray : Form { const string MUTEX = "deepseek-api-tray"; const string SHOW = "deepseek-api-tray-show"; static Mutex only; static EventWaitHandle wake; [STAThread] static void Main() { bool first; only = new Mutex(true, MUTEX, out first); wake = new EventWaitHandle(false, EventResetMode.AutoReset, SHOW); // Второй экземпляр не поднимает сервер (иначе EADDRINUSE), а просто // разворачивает окно первого и уходит. if (!first) { wake.Set(); return; } Application.EnableVisualStyles(); Application.Run(new Tray()); } readonly string here = AppDomain.CurrentDomain.BaseDirectory; readonly TextBox log = new TextBox(); readonly NotifyIcon tray = new NotifyIcon(); readonly System.Windows.Forms.Timer poll = new System.Windows.Forms.Timer(); readonly JavaScriptSerializer json = new JavaScriptSerializer(); Process node; int port; string lastError; bool quitting; Tray() { port = ReadPort(); Text = "deepseek-api"; Width = 900; Height = 520; StartPosition = FormStartPosition.CenterScreen; log.Multiline = true; log.ReadOnly = true; log.ScrollBars = ScrollBars.Vertical; log.Dock = DockStyle.Fill; log.BackColor = Color.FromArgb(20, 20, 20); log.ForeColor = Color.Gainsboro; log.Font = new Font("Consolas", 9f); log.BorderStyle = BorderStyle.None; Controls.Add(log); var menu = new ContextMenuStrip(); menu.Items.Add("Показать окно", null, (s, e) => Restore()); menu.Items.Add("Выход", null, (s, e) => { quitting = true; Close(); }); tray.ContextMenuStrip = menu; tray.Visible = true; tray.DoubleClick += (s, e) => Restore(); // Значок окна берём из самого exe: icon.ico вшит в него при сборке // (/win32icon), поэтому рядом ничего лежать не обязано. Значок в трее // всё равно рисуется цветом состояния, там картинке места нет. try { Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); } catch { Icon = Dot(Color.Gray); } Mark(Color.Gray, "запуск…"); poll.Interval = 1000; poll.Tick += Look; poll.Start(); // Второй экземпляр стучится через именованное событие; ждём его // в фоне и разворачиваемся. var waiter = new Thread(() => { while (wake.WaitOne()) BeginInvoke((Action)Restore); }); waiter.IsBackground = true; waiter.Start(); Start(); } // -------------------------------------------------------------- сервер void Start() { var si = new ProcessStartInfo("node", "\"" + Path.Combine(here, "server.mjs") + "\""); si.WorkingDirectory = here; si.UseShellExecute = false; si.CreateNoWindow = true; si.RedirectStandardOutput = true; si.RedirectStandardError = true; si.StandardOutputEncoding = Encoding.UTF8; si.StandardErrorEncoding = Encoding.UTF8; // Труба stdin — поводок: закроется вместе с нами, и node уйдёт сам, // даже если окно снимут диспетчером задач и OnFormClosed не сработает. si.RedirectStandardInput = true; si.EnvironmentVariables["DEEPSEEK_TRAY"] = "1"; node = new Process(); node.StartInfo = si; node.EnableRaisingEvents = true; node.OutputDataReceived += (s, e) => Say(e.Data); node.ErrorDataReceived += (s, e) => Say(e.Data); node.Exited += (s, e) => Say("[процесс node завершился]"); try { node.Start(); node.BeginOutputReadLine(); node.BeginErrorReadLine(); } catch (Exception e) { Say("Не удалось запустить node: " + e.Message); Say("Node должен быть в PATH. Проверка: node --version"); node = null; } } bool Alive { get { return node != null && !node.HasExited; } } void Say(string line) { if (line == null) return; if (InvokeRequired) { BeginInvoke((Action)Say, line); return; } // Хвоста в четверть мегабайта хватает с запасом; дальше режем, // иначе за сутки работы окно съест память на одном логе. if (log.TextLength > 250000) log.Text = log.Text.Substring(log.TextLength / 2); log.AppendText(line + Environment.NewLine); } // -------------------------------------------------------------- снимок async void Look(object sender, EventArgs e) { if (!Alive) { Mark(Color.Gray, "node не запущен"); return; } string body; try { using (var web = new WebClient()) { web.Encoding = Encoding.UTF8; body = await web.DownloadStringTaskAsync( "http://127.0.0.1:" + port + "/api/state"); } } catch { Mark(Color.Gold, "сервер не отвечает на порту " + port); return; } Dictionary s; try { s = (Dictionary)json.DeserializeObject(body); } catch { Mark(Color.Gold, "непонятный ответ сервера"); return; } bool browser = Flag(s, "browser"), ready = Flag(s, "ready"), busy = Flag(s, "busy"); int queue = Num(s, "queue"), seconds = Num(s, "seconds"); int watchdogSec = Num(s, "watchdogSec"); string error = Str(s, "error"); double stale = Age(Str(s, "checked")); if (error != null && error != lastError) tray.ShowBalloonTip(10000, "deepseek-api", error, ToolTipIcon.Warning); lastError = error; // Снимок обновляет сторож, поэтому он всегда чуть отстаёт. Три срока // сторожа без проверки — уже не «чуть», верить такому нельзя. bool fresh = watchdogSec <= 0 || (stale >= 0 && stale < watchdogSec * 3); if (busy) Mark(Color.DodgerBlue, "запрос идёт " + seconds + " с" + Waiting(queue)); else if (!fresh) Mark(Color.Gray, "снимок устарел на " + (int)stale + " с — сторож молчит"); else if (error != null) Mark(Color.Firebrick, error); else if (ready) Mark(Color.MediumSeaGreen, "готов, порт " + port + Waiting(queue)); else if (browser) Mark(Color.Gold, "браузер есть, вкладка с чатом — нет"); else Mark(Color.Firebrick, "браузер не отвечает"); } static string Waiting(int queue) { return queue > 1 ? "; в очереди " + (queue - 1) : ""; } // Возраст отметки времени в секундах; -1, если отметки нет. static double Age(string iso) { DateTime t; if (iso == null || !DateTime.TryParse(iso, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out t)) return -1; return (DateTime.UtcNow - t).TotalSeconds; } static bool Flag(Dictionary d, string k) { object v; return d.TryGetValue(k, out v) && v is bool && (bool)v; } static int Num(Dictionary d, string k) { object v; if (!d.TryGetValue(k, out v) || v == null) return 0; try { return Convert.ToInt32(v); } catch { return 0; } } static string Str(Dictionary d, string k) { object v; return d.TryGetValue(k, out v) ? v as string : null; } // --------------------------------------------------------------- значок readonly Dictionary icons = new Dictionary(); Icon Dot(Color c) { Icon icon; if (icons.TryGetValue(c.ToArgb(), out icon)) return icon; using (var bmp = new Bitmap(16, 16)) { using (var g = Graphics.FromImage(bmp)) { g.SmoothingMode = SmoothingMode.AntiAlias; using (var b = new SolidBrush(c)) g.FillEllipse(b, 1, 1, 14, 14); using (var p = new Pen(Color.FromArgb(120, 0, 0, 0))) g.DrawEllipse(p, 1, 1, 14, 14); } icon = Icon.FromHandle(bmp.GetHicon()); } icons[c.ToArgb()] = icon; return icon; } void Mark(Color c, string hint) { tray.Icon = Dot(c); // Подсказка трея обрывается на 63 символах — длинную ошибку режем сами. string text = "deepseek-api: " + hint; tray.Text = text.Length > 62 ? text.Substring(0, 59) + "..." : text; Text = "deepseek-api — " + hint; } // ----------------------------------------------------------------- окно void Restore() { Show(); WindowState = FormWindowState.Normal; Activate(); } protected override void OnResize(EventArgs e) { base.OnResize(e); if (WindowState == FormWindowState.Minimized) Hide(); } protected override void OnFormClosing(FormClosingEventArgs e) { // Крестик прячет в трей; выход — только из меню значка. Иначе легко // закрыть окно, думая, что сервис остановлен, а он остался работать. if (!quitting && e.CloseReason == CloseReason.UserClosing) { e.Cancel = true; Hide(); return; } base.OnFormClosing(e); } protected override void OnFormClosed(FormClosedEventArgs e) { poll.Stop(); tray.Visible = false; // Закрытая консоль иногда оставляла node висеть — теперь убиваем явно. // Браузер не трогаем: он живёт своей жизнью и нужен человеку. try { if (Alive) node.Kill(); } catch { } base.OnFormClosed(e); } // ----------------------------------------------------------------- порт int ReadPort() { int p = 11500; foreach (var name in new[] { "config.json", "config.local.json" }) { var path = Path.Combine(here, name); if (!File.Exists(path)) continue; try { var d = (Dictionary)json.DeserializeObject( File.ReadAllText(path, Encoding.UTF8)); if (d != null && d.ContainsKey("port")) p = Convert.ToInt32(d["port"]); } catch { } } var env = Environment.GetEnvironmentVariable("DEEPSEEK_API_PORT"); int fromEnv; if (env != null && int.TryParse(env, out fromEnv)) p = fromEnv; return p; } }