Окно с индикатором вместо чёрной консоли
Маленькое окно на C#, собирается компилятором из состава Windows: ставить нечего, зависимостей нет — как и у всего остального в проекте. Запускает node дочерним процессом, показывает его вывод, прячется в трей, красит значок по /api/state. Node теперь уходит вместе с окном: окно держит его stdin, server.mjs при DEEPSEEK_TRAY=1 слушает close на этой трубе. Job Object не подошёл бы — он забрал бы с собой и браузер, который человеку нужен. Окно смотрит на checked, а не только на ready: снимок обновляет сторож, и без этой проверки окно показывало бы «готов» через секунду после смерти браузера. Поэтому /api/state отдаёт ещё и watchdogSec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
52de3b91eb
commit
46ea8a9296
@@ -0,0 +1,335 @@
|
||||
// Окно с индикаторами вместо чёрной консоли.
|
||||
//
|
||||
// Запускает `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();
|
||||
|
||||
// Значок окна — картинка проекта, если она рядом; значок в трее всё
|
||||
// равно рисуется цветом состояния, там картинке места нет.
|
||||
var png = Path.Combine(here, "icon.png");
|
||||
if (File.Exists(png))
|
||||
try { using (var bmp = new Bitmap(png)) Icon = Icon.FromHandle(bmp.GetHicon()); }
|
||||
catch { Icon = Dot(Color.Gray); }
|
||||
else
|
||||
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<string>)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<string, object> s;
|
||||
try { s = (Dictionary<string, object>)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<string, object> d, string k)
|
||||
{
|
||||
object v;
|
||||
return d.TryGetValue(k, out v) && v is bool && (bool)v;
|
||||
}
|
||||
|
||||
static int Num(Dictionary<string, object> 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<string, object> d, string k)
|
||||
{
|
||||
object v;
|
||||
return d.TryGetValue(k, out v) ? v as string : null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- значок
|
||||
|
||||
readonly Dictionary<int, Icon> icons = new Dictionary<int, Icon>();
|
||||
|
||||
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<string, object>)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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user