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