Окно с индикатором вместо чёрной консоли

Маленькое окно на 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:
Alex Cube
2026-08-26 16:47:34 +03:00
co-authored by Claude Opus 5
parent 52de3b91eb
commit 46ea8a9296
6 changed files with 414 additions and 9 deletions
+3
View File
@@ -7,3 +7,6 @@ chats.json
# Свой на каждой машине: пути к браузеру и профилю. # Свой на каждой машине: пути к браузеру и профилю.
config.local.json config.local.json
# Собирается из tray.cs командой build.cmd — в репозиторий не идёт.
*.exe
+52 -8
View File
@@ -1,3 +1,5 @@
<img src="icon.png" alt="" width="128" align="right">
# deepseek-api # deepseek-api
**DeepSeek, притворяющийся Ollama.** **DeepSeek, притворяющийся Ollama.**
@@ -29,13 +31,14 @@
1. [Что понадобится](#что-понадобится) 1. [Что понадобится](#что-понадобится)
2. [Установка по шагам](#установка-по-шагам) 2. [Установка по шагам](#установка-по-шагам)
3. [Проверка, что всё работает](#проверка-что-всё-работает) 3. [Окно с индикатором](#окно-с-индикатором)
4. [Как подключить свою программу](#как-подключить-свою-программу) 4. [Проверка, что всё работает](#проверка-что-всё-работает)
5. [Что умеет и чего не умеет](#что-умеет-и-чего-не-умеет) 5. [Как подключить свою программу](#как-подключить-свою-программу)
6. [Настройка](#настройка) 6. [Что умеет и чего не умеет](#что-умеет-и-чего-не-умеет)
7. [Если не работает](#если-не-работает) 7. [Настройка](#настройка)
8. [Безопасность](#безопасность) 8. [Если не работает](#если-не-работает)
9. [Как это устроено внутри](#как-это-устроено-внутри) 9. [Безопасность](#безопасность)
10. [Как это устроено внутри](#как-это-устроено-внутри)
--- ---
@@ -135,6 +138,9 @@ start.cmd
Это окно должно оставаться открытым — пока оно живо, работает и сервис. Это окно должно оставаться открытым — пока оно живо, работает и сервис.
Останов — `Ctrl+C` или просто закрыть окно. Останов — `Ctrl+C` или просто закрыть окно.
Чёрная консоль не обязательна: рядом лежит окно с индикатором в трее —
см. [Окно с индикатором](#окно-с-индикатором).
### Шаг 5. Войти в DeepSeek ### Шаг 5. Войти в DeepSeek
Браузер поднимается сам, но в пустом профиле вы ещё не залогинены. Дайте Браузер поднимается сам, но в пустом профиле вы ещё не залогинены. Дайте
@@ -160,6 +166,43 @@ curl http://localhost:11500/api/tags
--- ---
## Окно с индикатором
Тем, кому чёрная консоль мешает, есть маленькое окно: тот же вывод, но
сворачивается в трей, а цвет значка показывает состояние сервиса.
```cmd
build.cmd
deepseek-api.exe
```
`build.cmd` собирает `deepseek-api.exe` (около 20 КБ) компилятором C# из
состава Windows — `csc.exe`, он есть на любой машине с .NET Framework 4.
Ставить ничего не нужно, прав администратора тоже. Сам `.exe` в репозиторий
не кладётся: собирается за секунду.
Окно запускает `node server.mjs` дочерним процессом и показывает его вывод.
Крестик прячет окно в трей, выход — из меню значка. Node уходит вместе с окном
в любом случае, даже если окно снять диспетчером задач; браузер переживает —
он отдельный процесс и нужен вам.
Цвет значка:
| Цвет | Что значит |
|---|---|
| зелёный | готов, вопросы принимаются |
| синий | идёт запрос: в подсказке — сколько секунд и длина очереди |
| жёлтый | браузер есть, а вкладки с чатом нет; или сервер не отвечает |
| красный | браузер не отвечает; текст беды — в подсказке и всплывающей записке |
| серый | node не запущен или снимок состояния устарел (сторож молчит) |
Второй запуск не поднимает второй сервер, а разворачивает уже открытое окно —
заодно не будет `EADDRINUSE`.
`start.cmd` никуда не делся: он для запуска без графики.
---
## Проверка, что всё работает ## Проверка, что всё работает
Откройте **вторую** командную строку (первая занята сервисом): Откройте **вторую** командную строку (первая занята сервисом):
@@ -423,7 +466,8 @@ POST /api/chat
| `browser.mjs` | всё про браузер: CDP, разметка DeepSeek, отправка, ожидание ответа, чаты | | `browser.mjs` | всё про браузер: CDP, разметка DeepSeek, отправка, ожидание ответа, чаты |
| `config.json` | настройки; `config.local.json` рядом перебивает их | | `config.json` | настройки; `config.local.json` рядом перебивает их |
| `chats.json` | рабочее состояние: id живых чатов (заводится сам) | | `chats.json` | рабочее состояние: id живых чатов (заводится сам) |
| `start.cmd` | запуск | | `start.cmd` | запуск в консоли |
| `tray.cs`, `build.cmd` | окно с индикатором в трее; `.exe` собирается на месте |
| `test.mjs` | проверка перевода формата, браузер не нужен | | `test.mjs` | проверка перевода формата, браузер не нужен |
Зависимостей нет — только то, что есть в Node 22. Зависимостей нет — только то, что есть в Node 22.
+15
View File
@@ -0,0 +1,15 @@
@echo off
rem ASCII only - cmd.exe reads .cmd in the system codepage, not UTF-8.
rem Builds deepseek-api.exe from tray.cs with the C# compiler shipped in Windows.
setlocal
set CSC=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe
if not exist "%CSC%" set CSC=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe
if not exist "%CSC%" (
echo csc.exe not found. .NET Framework 4.x is required.
exit /b 1
)
"%CSC%" /nologo /target:winexe /optimize+ /out:"%~dp0deepseek-api.exe" ^
/r:System.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll ^
/r:System.Web.Extensions.dll "%~dp0tray.cs"
if errorlevel 1 exit /b 1
echo Built: %~dp0deepseek-api.exe
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+9 -1
View File
@@ -218,7 +218,7 @@ const server = createServer(async (req, res) => {
...state, ...state,
seconds: state.busy && state.since ? Math.round((Date.now() - state.since) / 1000) : 0, seconds: state.busy && state.since ? Math.round((Date.now() - state.since) / 1000) : 0,
host: cfg.host, port: cfg.port, cdpPort: cfg.cdpPort, host: cfg.host, port: cfg.port, cdpPort: cfg.cdpPort,
watchdog: !!cfg.watchdog, watchdog: !!cfg.watchdog, watchdogSec: cfg.watchdogSec,
}); });
} }
@@ -308,4 +308,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
} }
console.log('Остановить — Ctrl+C'); console.log('Остановить — Ctrl+C');
}); });
// Запущены из окна (tray.cs): оно держит нашу трубу stdin открытой. Труба
// закрылась — окна больше нет, и висеть дальше незачем: раньше закрытая
// консоль оставляла node в памяти. Браузер при этом переживает нас: он
// отдельный процесс и нужен человеку.
if (process.env.DEEPSEEK_TRAY) {
process.stdin.on('close', () => process.exit(0)).resume();
}
} }
+335
View File
@@ -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;
}
}