diff --git a/.gitignore b/.gitignore
index de7dc7d..db9ce6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ chats.json
# Свой на каждой машине: пути к браузеру и профилю.
config.local.json
+
+# Собирается из tray.cs командой build.cmd — в репозиторий не идёт.
+*.exe
diff --git a/README.md b/README.md
index 19cee54..e951e89 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+
+
# deepseek-api
**DeepSeek, притворяющийся Ollama.**
@@ -29,13 +31,14 @@
1. [Что понадобится](#что-понадобится)
2. [Установка по шагам](#установка-по-шагам)
-3. [Проверка, что всё работает](#проверка-что-всё-работает)
-4. [Как подключить свою программу](#как-подключить-свою-программу)
-5. [Что умеет и чего не умеет](#что-умеет-и-чего-не-умеет)
-6. [Настройка](#настройка)
-7. [Если не работает](#если-не-работает)
-8. [Безопасность](#безопасность)
-9. [Как это устроено внутри](#как-это-устроено-внутри)
+3. [Окно с индикатором](#окно-с-индикатором)
+4. [Проверка, что всё работает](#проверка-что-всё-работает)
+5. [Как подключить свою программу](#как-подключить-свою-программу)
+6. [Что умеет и чего не умеет](#что-умеет-и-чего-не-умеет)
+7. [Настройка](#настройка)
+8. [Если не работает](#если-не-работает)
+9. [Безопасность](#безопасность)
+10. [Как это устроено внутри](#как-это-устроено-внутри)
---
@@ -135,6 +138,9 @@ start.cmd
Это окно должно оставаться открытым — пока оно живо, работает и сервис.
Останов — `Ctrl+C` или просто закрыть окно.
+Чёрная консоль не обязательна: рядом лежит окно с индикатором в трее —
+см. [Окно с индикатором](#окно-с-индикатором).
+
### Шаг 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, отправка, ожидание ответа, чаты |
| `config.json` | настройки; `config.local.json` рядом перебивает их |
| `chats.json` | рабочее состояние: id живых чатов (заводится сам) |
-| `start.cmd` | запуск |
+| `start.cmd` | запуск в консоли |
+| `tray.cs`, `build.cmd` | окно с индикатором в трее; `.exe` собирается на месте |
| `test.mjs` | проверка перевода формата, браузер не нужен |
Зависимостей нет — только то, что есть в Node 22.
diff --git a/build.cmd b/build.cmd
new file mode 100644
index 0000000..84e7e4e
--- /dev/null
+++ b/build.cmd
@@ -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
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..31e9f3b
Binary files /dev/null and b/icon.png differ
diff --git a/server.mjs b/server.mjs
index 6ee50c7..d501aba 100644
--- a/server.mjs
+++ b/server.mjs
@@ -218,7 +218,7 @@ const server = createServer(async (req, res) => {
...state,
seconds: state.busy && state.since ? Math.round((Date.now() - state.since) / 1000) : 0,
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');
});
+
+ // Запущены из окна (tray.cs): оно держит нашу трубу stdin открытой. Труба
+ // закрылась — окна больше нет, и висеть дальше незачем: раньше закрытая
+ // консоль оставляла node в памяти. Браузер при этом переживает нас: он
+ // отдельный процесс и нужен человеку.
+ if (process.env.DEEPSEEK_TRAY) {
+ process.stdin.on('close', () => process.exit(0)).resume();
+ }
}
diff --git a/tray.cs b/tray.cs
new file mode 100644
index 0000000..4b0a7ee
--- /dev/null
+++ b/tray.cs
@@ -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)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;
+ }
+}