-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathProgram.cs
More file actions
1967 lines (1810 loc) · 98.3 KB
/
Copy pathProgram.cs
File metadata and controls
1967 lines (1810 loc) · 98.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Collections.Concurrent;
using System.Reflection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.FileProviders;
using AIOrchestrator;
using UISupportGeneric;
using AgentBridge.Resources;
using Terminal.Gui.Input;
// First-run visibility: from the process start to the TUI drawing the console shows
// nothing — the single-file runtime extracts its embedded native libraries before Main
// runs, and in TUI mode Console output is nulled until the UI takes over. On slow
// machines that window can be long. Print a startup line immediately so the terminal
// is never silently black; the TUI clears the console when it draws.
Console.WriteLine($"AgentBridge {Assembly.GetExecutingAssembly().GetName().Version} starting — loading components, please wait...");
// Crash-safe console: when the process dies with an unhandled exception while the TUI
// owns the console, the screen is left black — and the AIOrchestrator crash handler
// below then sleeps 60 s before auto-restarting, so the user would stare at a black
// terminal the whole time (and again after a restart that crashes again). Registered
// FIRST so this handler restores a readable console and prints the error immediately;
// the AIOrchestrator handler still logs the crash to file and restarts afterwards.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
try { Console.ResetColor(); } catch { }
if (!OperatingSystem.IsWindows())
{
// Leave Terminal.Gui's alternate screen buffer (ANSI), then clear the primary.
try { Console.Out.Write("\u001b[0m\u001b[?1049l"); } catch { }
}
try { Console.Clear(); } catch { }
Console.Error.WriteLine();
Console.Error.WriteLine("AgentBridge crashed:");
Console.Error.WriteLine(e.ExceptionObject is Exception ex ? ex.ToString() : e.ExceptionObject?.ToString());
// Best-effort crash diagnostics to the project's GitHub issues — SANITIZED payload (only
// the exception type + our stack frames, never messages/user data, see CrashReporter.cs).
// Disable from the TUI (Help → Crash report) or appsettings CrashReport:Enabled.
CrashReporter.Report(e.ExceptionObject as Exception);
};
AppDomain.CurrentDomain.UnhandledException += AIOrchestrator.Utility.UnhandledException; //it catches application errors in order to prepare a log of the events that cause the crash
// Logging toggle for headless runs: the TUI settings panel switches AIOrchestrator.Log on
// interactively, but a headless service (or a test harness) needs the same switch on the
// command line — the log file lands in logs/<pid>.txt next to the executable.
if (args.Contains("--enable-log"))
AIOrchestrator.Log.IsEnabled = true;
// ═══════════════════════════════════════════════════════════════════════
// AgentBridge — OpenAI-compatible HTTP server for AgentHarness
//
// Architecture (see AIOrchestrator/docs-dev/ARCHITECTURE.md):
// Standalone clients (e.g. Giraffe AI) → HTTP endpoints → AgentHarness
// → LLM + agent tools. The server hosts the AIOrchestrator library (which is
// not directly executable) and exposes its chat pipeline as standard
// OpenAI-compatible REST endpoints, so any OpenAI SDK works unchanged.
//
// Standard endpoints (OpenAI-compatible):
// POST /v1/chat/completions (Chat Completions, streaming SSE, sessions)
// POST /v1/files (multipart upload + server-side Markdown conversion)
// GET /v1/files (list uploaded files)
// GET /v1/files/{id} (retrieve converted content)
// GET /v1/files/{id}/content (retrieve raw bytes — OpenAI Files API)
// DELETE /v1/files/{id} (delete an uploaded file — OpenAI Files API)
// GET /v1/models (agent sets + LLM providers with characteristics)
// GET /v1/models/{id} (single model details)
// POST /v1/audio/speech (text → speech, Kokoro neural TTS, returns WAV)
// POST /mcp (MCP JSON-RPC endpoint: initialize, tools/list, tools/call)
// GET /health
//
// Proprietary extensions (documented, additive, ignored by strict OpenAI clients):
// POST /v1/control (pilot: switch the LLM in use, features, reset)
// GET /v1/control (session state + platform capabilities)
// POST /v1/voice/listen (one-shot server-mic speech recognition, Windows)
// GET /v1/audio/voices (TTS voices available on this platform)
//
// Sessions: chat requests may carry session_id (extension) to keep the
// conversation history across requests; the pilot endpoint switches the LLM
// provider on the fly with a context-window check (see docs/API.md).
//
// File attachments follow the same server-side conversion rule as the Blazor
// UI (never client-side): uploaded bytes are stored as FileAttachment and
// converted to Markdown via AgentHarness.ConvertAttachmentToMarkdown
// (AllToMarkdown for documents, Z.ai GLM-OCR for images).
// ═══════════════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────────
// Command-line help — print usage and exit before the server starts.
// Any appsettings.json key is already overridable from the command line
// (WebApplication.CreateBuilder wires the command-line config provider
// with precedence over appsettings.json), so the help documents the
// app-specific keys plus that general mechanism.
// ─────────────────────────────────────────────────────────────────────
if (args.Contains("-h") || args.Contains("--help") || args.Contains("/?"))
{
Console.WriteLine("""
AgentBridge — OpenAI-compatible HTTP server for AgentHarness
Usage:
dotnet run --project AgentBridge.csproj [-- <options>]
Options (command line overrides appsettings.json; any key is overridable
with --Key:SubKey <value>):
--LLM:Provider <name> Default LLM provider: DeepSeekBridge (default), DeepSeek,
Zai, Gemini, Ollama, ExllamaV2. Per-request
overrides via the llm_provider field on /v1/chat/completions
or POST /v1/control.
--LLM:Anonymize <bool> Anonymize NameOrKey elements before sending to the LLM
(true|false)
--SkipIndexingOnStartup <bool>
Skip the DocumentsPath index build/refresh + file watcher
at startup (true|false) — use during debug/dev when no
document searches are needed (large folders index for minutes)
--no-update Disable the automatic update check at startup (default on;
use for services/CI that manage the binary themselves)
--enable-log Enable AIOrchestrator file logging (logs/<pid>.txt) — the TUI
settings toggle works only interactively; headless runs need this
--Voice:ExePath <path> Path to AIOffice.VoiceAgent.Win.exe for POST /v1/voice/listen
(default: <server dir>\voiceagent\AIOffice.VoiceAgent.Win.exe)
--Urls <address> Kestrel listening address, e.g. http://localhost:5290
--environment <name> ASP.NET environment: Development | Production
Terminal UI (default when the console is interactive):
Without flags the console opens the Qwen-Code-style terminal UI (chat,
slash commands, model/agent/voice/TTS/files/help) while the server keeps
answering API calls in the same process.
--headless Server only (no terminal UI) — for scripts/CI.
--tui Force the terminal UI (falls back to server-only
when the console is not interactive).
Endpoints: /v1/chat/completions, /v1/files[/{id}[/content]], /v1/models[/{id}],
/v1/audio/speech, /v1/audio/voices, /v1/voice/listen, /v1/control, /mcp, /health
Examples:
dotnet run --project AgentBridge.csproj -- --LLM:Provider Zai
dotnet run --project AgentBridge.csproj -- --LLM:Anonymize true
dotnet run --project AgentBridge.csproj -- --SkipIndexingOnStartup true
agent --headless (server only, e.g. as a systemd service)
""");
return 0;
}
// ─────────────────────────────────────────────────────────────────────
// Auto-update updater mode: the temp extract of a newer release runs as its own
// process; it waits for this process to exit, swaps the files (the executable last,
// .old as rollback) and restarts with the original command line. Must run before
// the server is built — see AutoUpdate.cs / docs/autoupdate.md.
// ─────────────────────────────────────────────────────────────────────
if (args.Contains("--apply-update"))
return AutoUpdate.RunUpdater(args);
// Remove leftovers of a previous update (rollback .old, stale temp area).
AutoUpdate.CleanupOnStartup();
// Content root = the executable's folder (not the CWD): the standalone exe must find
// appsettings.json even when launched from another directory (double click, services, tests).
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
ContentRootPath = AppContext.BaseDirectory
});
// The TUI (Tui.cs) is built on Terminal.Gui v2 — before changing anything about
// it, read docs-dev/TUI-DEVELOPMENT.md (local developer guide, offline reference).
// Terminal UI mode: by default the console opens the Qwen-Code-style TUI (chat +
// slash commands + voice/model/files/help) while the server keeps answering API
// calls in the same process — "CLI + API simultaneously". --headless restores the
// plain server console for scripts/CI; --tui forces the UI even when the console
// looks redirected. When the console is redirected (no interactive terminal) the
// UI is skipped automatically, so existing launchers keep working unchanged.
var forceTui = args.Contains("--tui");
var forceHeadless = args.Contains("--headless") || args.Contains("--no-gui");
var interactive = !Console.IsInputRedirected && !Console.IsOutputRedirected && !Console.IsErrorRedirected;
var useTui = forceTui || (!forceHeadless && interactive);
// Suppress library/console output when the TUI is active — SIPSorcery (SIP),
// WTelegramClient (Telegram) and other third-party libraries write directly to
// Console.Out, which would garble the Terminal.Gui screen. In DEBUG builds we
// keep the real writer so --headless / test harnesses still see output.
if (useTui)
Console.SetOut(Environment.GetCommandLineArgs().Any(a => a.Contains("DEBUG")) ? Console.Out : TextWriter.Null);
// The TUI needs a real console (cursor control, key input): forcing it on a
// redirected console would crash on the console APIs, so fall back to server-only.
if (forceTui && !interactive)
{
Console.WriteLine("Console is not interactive — --tui ignored, starting server-only (--headless).");
useTui = false;
}
// The terminal UI owns the console: suppress ASP.NET's console logging so it
// cannot garble the TUI (HTTP errors surface inside the UI itself).
if (useTui)
builder.Logging.ClearProviders();
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
// Configure the LLM provider via appsettings "LLM:Provider" (e.g. DeepSeekBridge).
// This is the DEFAULT provider; per-request and per-session overrides are handled by
// the llm_provider field (chat) / POST /v1/control — see docs/API.md "LLM switching".
var startupProvider = builder.Configuration["LLM:Provider"] ?? "DeepSeekBridge";
if (!ProviderConfigs.TryGet(startupProvider, out _))
{
Console.WriteLine($"Unknown LLM provider '{startupProvider}' — using the first configured provider.");
startupProvider = ProviderConfigs.Default.ProviderName;
}
// Anonymization flag from appsettings "LLM:Anonymize" (default false). Overridable from the
// command line with --LLM:Anonymize true — the ASP.NET config chain already gives CLI
// precedence over appsettings.json, so a single Configuration read covers both sources.
var anonymize = builder.Configuration.GetValue<bool>("LLM:Anonymize");
// Startup indexing toggle from appsettings "SkipIndexingOnStartup" (default false, CLI
// --SkipIndexingOnStartup true). When true, the DocumentsPath index is neither built nor
// refreshed and the file watcher is not started: use during debug/dev when no document
// searches are needed, to skip the multi-minute full index on large folders. MUST be set
// before Setup.Load() below — Setup.RagDocumentProcessor is created lazily on first use,
// so this early assignment is what the processor sees (see Setup.SkipIndexingOnStartup).
// The CLI flag is read DIRECTLY from args (not only from the command-line configuration
// provider, which may not surface it): "--SkipIndexingOnStartup true" / "=true" / bare flag.
var skipIndexing = false;
for (int i = 0; i < args.Length; i++)
{
if (!args[i].StartsWith("--SkipIndexingOnStartup", StringComparison.OrdinalIgnoreCase)) continue;
if (args[i].Contains('='))
bool.TryParse(args[i].Split('=', 2)[1], out skipIndexing);
else if (i + 1 < args.Length && bool.TryParse(args[i + 1], out var v))
skipIndexing = v;
else
skipIndexing = true;
break;
}
Setup.SkipIndexingOnStartup = skipIndexing || builder.Configuration.GetValue<bool>("SkipIndexingOnStartup");
// This host has no settings UI of its own: load credentials persisted by the previous
// run (Setup.Save) from %LocalAppData%\{app}\setup.json — SMTP/IMAP for EMailTool.
// LLM API keys are NOT here: they live per-provider in providers.json (edited via the
// TUI /modelsetup provider dialog or directly — see docs/providers-config.md); the
// legacy key fields of setup.json are only a fallback when a provider has no key.
// Provider selection above stays appsettings-driven (Setup.Load only restores
// Setup.ProviderConfig if the file contains ProviderName). See Setup.Load XML docs.
// The assembly was renamed AgentBridge → agent: migrate the credentials file so the
// old %LocalAppData%\AgentBridge\setup.json still applies (Setup.SetupFilePath uses
// the entry-assembly name).
try
{
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var oldSetup = Path.Combine(local, "AgentBridge", "setup.json");
var newSetup = Path.Combine(local, "agent", "setup.json");
if (File.Exists(oldSetup) && !File.Exists(newSetup))
{
Directory.CreateDirectory(Path.GetDirectoryName(newSetup)!);
File.Copy(oldSetup, newSetup);
Console.WriteLine($"Migrated setup.json: {oldSetup} → {newSetup}");
}
}
catch { /* best-effort migration */ }
Setup.Load();
// The task scheduler (TaskSchedulerTool) runs scheduled tasks as automated chats: it hands the
// host's default agent set to AgentHarness (same as a request without a model — presets +
// always-on core tools) and the configured startup provider (ProviderConfigs.Default alone is
// file-order, so leaving it null would pick the FIRST provider in providers.json instead).
AIOrchestrator.API.TaskSchedulerTool.SchedulerAgentToolNames = AgentTools.Resolve(null);
AIOrchestrator.API.TaskSchedulerTool.SchedulerProvider = startupProvider;
// Optional path to the VoiceAgent executable for POST /v1/voice/listen
// (appsettings "Voice:ExePath", CLI --Voice:ExePath). Null → server base directory.
VoiceBridge.ExePath = builder.Configuration["Voice:ExePath"];
// Preferred TTS engine (appsettings "Tts:Engine", CLI --Tts:Engine), exported to the
// plugins via PODCAST_TTS_ENGINE so they synthesize with the same engine as the host.
// The catalog lives in TtsEngineSupport (AIOrchestrator): kokoro is the only engine
// today — always available, in-process, model included in the archive. Future engines
// register there and get this selection path for free; a stale configured value falls
// back to the default and says why, so the TTS never breaks silently.
var ttsEngine = builder.Configuration["Tts:Engine"] ?? TtsEngineSupport.DefaultEngine;
if (!TtsEngineSupport.IsKnown(ttsEngine))
{
Console.WriteLine($"TTS engine '{ttsEngine}' is unknown — using {TtsEngineSupport.DefaultEngine}.");
ttsEngine = TtsEngineSupport.DefaultEngine;
}
else if (!TtsEngineSupport.IsAvailable(ttsEngine, out var ttsReason))
{
Console.WriteLine($"TTS engine '{ttsEngine}' is not available on this machine ({ttsReason}) — using {TtsEngineSupport.DefaultEngine}.");
ttsEngine = TtsEngineSupport.DefaultEngine;
}
Environment.SetEnvironmentVariable("PODCAST_TTS_ENGINE", ttsEngine);
Console.WriteLine($"TTS engine: {ttsEngine}");
var app = builder.Build();
// Tool plugins (DocumentTool, SpreadsheetTool, OfficeTool): loaded DYNAMICALLY from the
// Tools/ folder next to the executable — no project depends on a plugin. The agent sets
// pass tool names and McpToolRegistry resolves them at runtime.
_ = AgentBridge.ToolPlugins.Host;
// OfficeManager hub: tracks every agent instance in this process (sessions of any medium,
// stateless API calls, subagents) and serves them to the /OfficeManager web app over the
// WebSocket protocol (see OfficeBridge.cs). Agents created by OTHER processes forward their
// lifecycle events here via AgentHarness.ForwardGlobalProgressTo → POST /v1/office/events.
OfficeBridge.Init(startupProvider, anonymize);
// Dynamic-hash conversation correlation for stateless clients without session_id (see
// StatelessConversation.cs): wires the transcript-hash cleanup on session removal.
StatelessConversation.Init();
// SIP telephony (auto-answer + PIN, outgoing calls — see docs/sip.md): initialized from the
// "Sip" appsettings section; the server itself starts right before the launch mode below so a
// bind failure (port in use) cannot kill the HTTP API — it is reported and logged only.
SipBridge.Init(app.Configuration, startupProvider, anonymize);
// Telegram chat medium (WTelegramClient userbot — see docs/telegram.md): initialized from
// telegram.json (a standalone file next to the executable, never overwritten by updates).
// The bridge starts at boot only when Enabled=true; the login is fully automatic when the
// .session file already exists, otherwise the TUI drives the pending verification code.
TelegramBridge.Init(startupProvider, anonymize);
// Auto-update toggle: CLI --no-update > persisted state (TUI File → Auto-Update)
// > appsettings default. The persisted file lives in the OS app-data folder, so
// updates never touch it (see RELEASING.md, storage tiers).
if (!AutoUpdate.LoadState(args.Contains("--no-update")))
AutoUpdate.Enabled = app.Configuration.GetValue<bool>("AutoUpdate:Enabled", true);
// Crash reporting toggle (TUI Help → Crash report / /crashreport): persisted state in the OS
// app-data folder wins, else the appsettings default. Repo/token come from appsettings
// (CrashReport:Repo, CrashReport:Token — token optional; without it the report opens as a
// pre-filled GitHub issue the user reviews before submitting, see CrashReporter.cs).
if (!CrashReporter.LoadState())
CrashReporter.Enabled = app.Configuration.GetValue("CrashReport:Enabled", true);
CrashReporter.Repo = app.Configuration["CrashReport:Repo"] ?? CrashReporter.Repo;
CrashReporter.Token = app.Configuration["CrashReport:Token"];
app.UseCors();
// ─────────────────────────────────────────────────────────────────────
// OfficeManager (web app) — static files + duplex WebSocket hub
//
// The 16-bit office app ships in the OfficeManager/ folder next to the
// executable (same csproj copy rule as docs/) and is served at
// /OfficeManager. The browser opens a WebSocket to /ws/office: the server
// streams agent lifecycle events (employees spawning at the door, tool
// methods, conversations) and receives chat prompts / close commands —
// see OfficeBridge.cs for the wire protocol. External hosts (AIOffice app,
// voice panels) forward their agents' events to POST /v1/office/events via
// AgentHarness.ForwardGlobalProgressTo, so every agent/subagent instance,
// however it was created, is reflected in the office.
// ─────────────────────────────────────────────────────────────────────
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
var officeDir = Path.Combine(AppContext.BaseDirectory, "OfficeManager");
if (Directory.Exists(officeDir))
{
var officeFiles = new PhysicalFileProvider(officeDir);
app.UseStaticFiles(new StaticFileOptions { FileProvider = officeFiles, RequestPath = "/OfficeManager" });
// Routing treats the trailing slash as optional, so one pattern serves both /OfficeManager
// and /OfficeManager/. The page uses ABSOLUTE asset URLs (/OfficeManager/...), so both work.
var officeIndex = Path.Combine(officeDir, "index.html");
app.MapGet("/OfficeManager", () => Results.File(officeIndex, "text/html"));
}
else
{
Console.WriteLine("OfficeManager/ not found next to the executable — the web office is unavailable.");
}
app.Map("/ws/office", async (HttpContext context) =>
{
if (context.WebSockets.IsWebSocketRequest)
{
using var ws = await context.WebSockets.AcceptWebSocketAsync();
await OfficeBridge.HandleClientAsync(ws, context.RequestAborted);
}
else
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
}
});
// Agents created by OTHER processes (AIOffice app, voice panels, schedulers) forward their
// GlobalProgress stream here — the same events AgentBridge raises for its own instances.
app.MapPost("/v1/office/events", (JsonElement body) =>
{
if (body.ValueKind == JsonValueKind.Array)
foreach (var item in body.EnumerateArray()) OfficeBridge.IngestExternalEvent(item);
else
OfficeBridge.IngestExternalEvent(body);
return Results.Ok();
});
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// Agent-set ids exposed as "models" (each maps to a concrete tool set in
// ResolveAgentTypes). Derived from the AgentTools preset table plus the dynamic
// "all-files" preset, so the API surface and the TUI tool selection never drift apart.
var agentModelIds = AgentTools.AllIds;
// ─────────────────────────────────────────────────────────────────────
// POST /v1/chat/completions — OpenAI Chat Completions API
//
// Extensions over the OpenAI contract (all additive):
// session_id — keep the conversation history across requests (multi-turn).
// llm_provider — use a specific LLM provider for this request (default: appsettings).
// On session requests the response carries the session_id; switching the provider on
// a session is refused (409) when the accumulated history overflows the new provider's
// context window — the client resets the conversation via POST /v1/control first.
// ─────────────────────────────────────────────────────────────────────
app.MapPost("/v1/chat/completions", async (
HttpContext http,
[FromBody] ChatCompletionRequest request,
CancellationToken ct) =>
{
try
{
var lastUserMessage = request.Messages?
.LastOrDefault(m => m.Role == "user");
if (lastUserMessage == null)
return Results.BadRequest(new { error = "No user message found" });
var prompt = ExtractTextContent(lastUserMessage.Content);
if (string.IsNullOrWhiteSpace(prompt))
return Results.BadRequest(new { error = "User message is empty" });
var resolvedProvider = ResolveProvider(request.LlmProvider, startupProvider, out var providerError);
if (providerError != null)
return Results.BadRequest(new { error = providerError });
var provider = resolvedProvider!;
// Explicit tool list (additive extension, see ChatCompletionRequest.Tools) wins
// over the agent set resolved from `model`. A filtered-empty list (whitespace or
// unknown names only) falls back to the preset so the agent never runs with no
// tools; unknown tool names are skipped by the tool registry.
string[] agentToolNames;
if (request.Tools is { Count: > 0 })
{
agentToolNames = request.Tools
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (agentToolNames.Length == 0)
agentToolNames = ResolveAgentTypes(request.Model);
}
else
{
agentToolNames = ResolveAgentTypes(request.Model);
}
var attachments = ResolveAttachments(request.FileIds);
var maxIterations = request.MaxTokens > 0
? Math.Clamp(request.MaxTokens.Value / 100, 1, 200)
: 200;
ActiveSession? session = null;
AgentHarness? owned = null;
try
{
if (!string.IsNullOrEmpty(request.SessionId))
{
session = SessionStore.Get(request.SessionId);
if (session == null)
return Results.NotFound(new { error = $"Session '{request.SessionId}' not found. Omit session_id to start a new session, or create one via POST /v1/control." });
// Switch the LLM in use on the fly (history preserved), but refuse when the
// conversation overflows the target provider's context window.
var target = session.Orchestrator.Provider;
if (!string.Equals(target, provider, StringComparison.OrdinalIgnoreCase))
target = provider;
var fitError = ContextFitError(session, target, prompt);
if (fitError != null)
return Results.Json(fitError, statusCode: 409);
if (!string.Equals(session.Orchestrator.Provider, provider, StringComparison.OrdinalIgnoreCase))
session.Orchestrator.SwitchProvider(provider);
}
else
{
// No session → the historical stateless behaviour (one orchestrator per request),
// with one refinement: a third-party client that never sends session_id but RESENDS
// the accumulated transcript is correlated back to its conversation via the dynamic
// transcript hash (see StatelessConversation.cs), so its chat stays ONE session —
// ONE persistent employee in OfficeManager — instead of a one-shot per message.
// True one-shot requests (no prior assistant reply) keep the fresh-instance path.
var contKey = StatelessConversation.ContinuationKey(request.Messages);
var correlated = contKey != null ? StatelessConversation.Lookup(contKey) : null;
if (correlated != null)
{
// Known conversation → its session; pending transcript → start + seed it.
session = correlated.Length > 0
? SessionStore.Get(correlated)
: CreateSeededSession(request.Messages);
if (session == null)
owned = new AgentHarness(provider, anonymize);
}
else if (HasAssistantHistory(request.Messages))
{
// A multi-turn transcript we have never seen (server restart, or the first
// message was a true one-shot): start the conversation from the resent history.
session = CreateSeededSession(request.Messages);
}
else
{
owned = new AgentHarness(provider, anonymize);
}
}
// One chat at a time per conversation — both for the explicit session_id path and for
// the correlated/seeded stateless path (the finally releases the gate).
if (session != null)
await session.Gate.WaitAsync(ct);
var orchestrator = session?.Orchestrator ?? owned!;
// isLocalUser: the caller is at the desktop only when it reaches us from a loopback
// address (same machine). Remote callers (including the SIP phone bridge) get false,
// so OfficeTool's watch/desktop-only methods stay disabled for them.
var isLocalUser = http.Connection.RemoteIpAddress != null
&& System.Net.IPAddress.IsLoopback(http.Connection.RemoteIpAddress);
var result = orchestrator.ExecuteAction(prompt, agentToolNames, maxIterations: maxIterations,
attachments: attachments, isLocalUser: isLocalUser);
// Locale-neutral result codes (AgentResultCode) are rendered through the localized
// dictionary in the current system language; LLM text (Message/Error) passes through
// as-is. "No output generated" is also localized (Dictionary.NoOutputGenerated).
var content = result.Message ?? ResultText(result) ?? Dictionary.NoOutputGenerated;
var finishReason = result.Success ? "stop" : "error";
var sessionId = session?.Id;
// Keep the dynamic-hash correlation current (see StatelessConversation.cs): the
// rolling transcript hash INCLUDING this reply is recorded under the conversation,
// or marked pending when the request was a true one-shot — so the next message that
// resends the transcript is routed back to the same conversation.
if (request.Messages != null)
{
var key = StatelessConversation.FullKey(request.Messages, content);
if (sessionId != null) StatelessConversation.Record(sessionId, key);
else StatelessConversation.MarkPending(key);
}
if (request.Stream == true)
{
return Results.Stream(async stream =>
{
var model = request.Model ?? "default-agent";
foreach (var word in content.Split(' '))
{
if (ct.IsCancellationRequested) break;
var chunk = new
{
id = $"chatcmpl-{Guid.NewGuid():N}",
@object = "chat.completion.chunk",
created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
model,
choices = new[]
{
new { index = 0, delta = new { content = word + " " }, finish_reason = (string?)null }
}
};
await stream.WriteAsync(Encoding.UTF8.GetBytes($"data: {JsonSerializer.Serialize(chunk, jsonOptions)}\n\n"), ct);
await stream.FlushAsync(ct);
await Task.Delay(30, ct);
}
// Agent-attached files (done method's "attachments" field): delivered as a
// dedicated chunk carrying the standard MCP embedded-resource shape. Giraffe AI
// reads parsed.attachments from any SSE chunk, so one chunk before the end suffices.
if (result.Attachments is { Count: > 0 })
{
var attachmentsChunk = new
{
id = $"chatcmpl-{Guid.NewGuid():N}",
@object = "chat.completion.chunk",
created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
model,
attachments = result.Attachments,
choices = new[]
{
new { index = 0, delta = new { content = (string?)null }, finish_reason = (string?)null }
}
};
await stream.WriteAsync(Encoding.UTF8.GetBytes($"data: {JsonSerializer.Serialize(attachmentsChunk, jsonOptions)}\n\n"), ct);
await stream.FlushAsync(ct);
}
var finalChunk = new
{
id = $"chatcmpl-{Guid.NewGuid():N}",
@object = "chat.completion.chunk",
created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
model,
choices = new[]
{
new { index = 0, delta = new { content = "" }, finish_reason = finishReason }
}
};
await stream.WriteAsync(Encoding.UTF8.GetBytes($"data: {JsonSerializer.Serialize(finalChunk, jsonOptions)}\n\n"), ct);
await stream.WriteAsync(Encoding.UTF8.GetBytes("data: [DONE]\n\n"), ct);
await stream.FlushAsync(ct);
}, "text/event-stream");
}
var response = new
{
id = $"chatcmpl-{Guid.NewGuid():N}",
@object = "chat.completion",
created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
model = request.Model ?? "default-agent",
session_id = sessionId,
// Agent-attached files in the standard MCP embedded-resource shape (same payload
// the streaming path sends as a dedicated chunk).
attachments = result.Attachments,
choices = new[]
{
new
{
index = 0,
message = new { role = "assistant", content },
finish_reason = finishReason
}
},
usage = new
{
prompt_tokens = EstimateTokens(prompt),
completion_tokens = EstimateTokens(content),
total_tokens = EstimateTokens(prompt + content)
}
};
return Results.Ok(response);
}
finally
{
session?.Gate.Release();
owned?.Dispose();
}
}
catch (Exception ex)
{
return Results.Problem(detail: ex.Message, statusCode: 500, title: "Agent execution failed");
}
});
// ─────────────────────────────────────────────────────────────────────
// POST /v1/files — multipart upload + server-side Markdown conversion
// ─────────────────────────────────────────────────────────────────────
// Architectural model (see AIOrchestrator/docs-dev/ARCHITECTURE.md — "AgentBridge"):
// this endpoint mirrors the OpenAI Files API "upload once, reference later" pattern.
// - The multipart shape (form field `file` + `purpose`) matches the OpenAI upload call.
// - The response carries the OpenAI metadata schema (id, object, bytes, created_at,
// filename, purpose, status) plus two additive extensions used by Giraffe AI:
// `extracted_content` (the server-side Markdown) and `content_format`.
// - Conversion is always server-side (never in the browser) — same rule as the
// Blazor UI; documents go through AllToMarkdown, images through Z.ai GLM-OCR.
// - The bytes + Markdown are cached in memory so a later chat request can reference
// them by a lightweight `file_id` instead of re-sending the content.
// - The original `filename` travels as response metadata (OpenAI convention), NOT as
// YAML frontmatter in the converted content: the name is first-class state in
// FileCache/FileAttachment, and injecting it into the Markdown would pollute the
// document text. See LLMUtility.SendQuery's `supportDocuments` path for the one
// place where YAML frontmatter is appropriate (files persisted on disk).
// ─────────────────────────────────────────────────────────────────────
app.MapPost("/v1/files", async (IFormFile file, [FromQuery] string purpose = "assistants") =>
{
if (file == null || file.Length == 0)
return Results.BadRequest(new { error = "No file provided" });
if (file.Length > 25_000_000)
return Results.BadRequest(new { error = "File too large (max 25MB)" });
await using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var content = ms.ToArray();
var fileId = $"file-{Guid.NewGuid():N}";
var attachment = new FileAttachment(file.FileName, content);
var markdown = AgentHarness.ConvertAttachmentToMarkdown(attachment);
Log.LogStep($"POST /v1/files: '{file.FileName}' ({file.Length} bytes) → " +
(string.IsNullOrEmpty(markdown) ? "no markdown (unsupported/empty/unreadable)" : $"converted ({markdown.Length} chars)"));
FileCache.Store(new CachedFile
{
Id = fileId,
FileName = file.FileName,
MimeType = file.ContentType,
Content = content,
ExtractedText = markdown ?? "",
SizeBytes = file.Length,
StoredAt = DateTime.UtcNow
});
return Results.Ok(new
{
id = fileId,
@object = "file",
bytes = file.Length,
created_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
filename = file.FileName,
purpose,
status = !string.IsNullOrEmpty(markdown) ? "processed" : "unsupported",
extracted_content = markdown,
content_format = "markdown"
});
}).DisableAntiforgery();
// ─────────────────────────────────────────────────────────────────────
// GET /v1/files/{fileId}
// ─────────────────────────────────────────────────────────────────────
app.MapGet("/v1/files/{fileId}", (string fileId) =>
{
var file = FileCache.Get(fileId);
if (file == null)
return Results.NotFound(new { error = $"File '{fileId}' not found" });
return Results.Ok(new
{
id = file.Id,
@object = "file",
bytes = file.SizeBytes,
created_at = new DateTimeOffset(file.StoredAt).ToUnixTimeSeconds(),
filename = file.FileName,
status = file.ExtractedText.Length > 0 ? "processed" : "unsupported",
extracted_content = file.ExtractedText,
content_format = "markdown"
});
});
// ─────────────────────────────────────────────────────────────────────
// GET /v1/files/{fileId}/content — the original uploaded bytes (OpenAI Files API).
// Closes a documented gap (see ARCHITECTURE.md): raw retrieval complements the
// Markdown in /v1/files/{id}.
// ─────────────────────────────────────────────────────────────────────
app.MapGet("/v1/files/{fileId}/content", (string fileId) =>
{
var file = FileCache.Get(fileId);
if (file == null)
return Results.NotFound(new { error = $"File '{fileId}' not found" });
return Results.File(file.Content,
string.IsNullOrEmpty(file.MimeType) ? "application/octet-stream" : file.MimeType,
file.FileName);
});
// ─────────────────────────────────────────────────────────────────────
// DELETE /v1/files/{fileId} — file lifecycle (OpenAI Files API).
// ─────────────────────────────────────────────────────────────────────
app.MapDelete("/v1/files/{fileId}", (string fileId) =>
{
if (!FileCache.Remove(fileId))
return Results.NotFound(new { error = $"File '{fileId}' not found" });
return Results.Ok(new { id = fileId, @object = "file", deleted = true });
});
// ─────────────────────────────────────────────────────────────────────
// GET /v1/files — list uploaded files
// ─────────────────────────────────────────────────────────────────────
app.MapGet("/v1/files", () =>
{
var files = FileCache.GetAll();
return Results.Ok(new
{
@object = "list",
data = files.Select(f => new
{
id = f.Id,
@object = "file",
bytes = f.SizeBytes,
created_at = new DateTimeOffset(f.StoredAt).ToUnixTimeSeconds(),
filename = f.FileName,
status = f.ExtractedText.Length > 0 ? "processed" : "unsupported"
})
});
});
// ─────────────────────────────────────────────────────────────────────
// GET /v1/models — agent sets AND LLM providers with their characteristics
// ─────────────────────────────────────────────────────────────────────
// Two kinds of "models":
// - agent sets (default-agent, web-agent, ...): select which agent tools ExecuteAction
// instantiates via the `model` field of /v1/chat/completions;
// - LLM providers (DeepSeekBridge, Zai, ...): the actual LLMs behind the agents. The
// provider in use is switched via the llm_provider field / POST /v1/control; each
// entry carries the LLM characteristics (model_name, protocol, context_window,
// base_address) so clients can pick a provider that fits their task and context size.
// ─────────────────────────────────────────────────────────────────────
app.MapGet("/v1/models", () =>
{
var agents = agentModelIds.Select(id => new
{
id,
@object = "model",
owned_by = "ai-orchestrator",
created = 0
});
var providers = ProviderConfigs.All.Select(p => new
{
id = p.ProviderName,
@object = "model",
owned_by = "llm-provider",
created = 0,
// additive LLM characteristics (ignored by strict OpenAI clients)
provider = p.ProviderName,
model_name = p.ModelName,
protocol = p.Protocol.ToString(),
context_window = p.ContextWindow,
base_address = p.BaseAddress.ToString(),
interaction_mode = p.EffectiveAgentInteractionMode.ToString()
});
return Results.Ok(new { @object = "list", data = agents.Cast<object>().Concat(providers.Cast<object>()) });
});
// ─────────────────────────────────────────────────────────────────────
// GET /v1/models/{model} — single model details (agent set or LLM provider)
// ─────────────────────────────────────────────────────────────────────
app.MapGet("/v1/models/{model}", (string model) =>
{
var id = model.ToLowerInvariant();
if (agentModelIds.Contains(id))
{
return Results.Ok(new
{
id,
@object = "model",
owned_by = "ai-orchestrator",
created = 0
});
}
if (ProviderConfigs.TryGet(model, out var provider))
{
return Results.Ok(new
{
id = provider!.ProviderName,
@object = "model",
owned_by = "llm-provider",
created = 0,
provider = provider.ProviderName,
model_name = provider.ModelName,
protocol = provider.Protocol.ToString(),
context_window = provider.ContextWindow,
base_address = provider.BaseAddress.ToString(),
interaction_mode = provider.EffectiveAgentInteractionMode.ToString()
});
}
return Results.NotFound(new { error = $"Model '{model}' not found" });
});
// ─────────────────────────────────────────────────────────────────────
// POST /v1/audio/speech — text-to-speech (standard OpenAI endpoint)
//
// In-process Kokoro neural TTS (same engine/voices as the Windows VoiceAgent, but
// cross-platform). Returns WAV bytes. Voice names accept OpenAI names ("alloy",
// "echo", ...) or raw Kokoro ids ("if_sara", "af_heart", ...) — see /v1/audio/voices.
// Returns 501 when the model assets are not present on this platform.
// ─────────────────────────────────────────────────────────────────────
app.MapPost("/v1/audio/speech", (SpeechRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Input))
return Results.BadRequest(new { error = "input is required" });
if (!TtsEngine.IsAvailable)
return Results.Json(new { error = "tts_unavailable", detail = TtsEngine.UnavailableReason }, statusCode: 501);
var format = (request.ResponseFormat ?? "wav").ToLowerInvariant();
if (format != "wav")
return Results.BadRequest(new { error = $"Unsupported response_format '{format}'. Supported: wav." });
try
{
var audio = TtsEngine.Synthesize(request.Input, request.Voice, request.Speed, request.Lang);
return Results.Bytes(audio, "audio/wav", $"speech-{DateTime.UtcNow:yyyyMMddHHmmss}.wav");
}
catch (Exception ex)
{
return Results.Problem(detail: ex.Message, statusCode: 500, title: "TTS synthesis failed");
}
});
// ─────────────────────────────────────────────────────────────────────
// GET /v1/audio/voices — TTS voices available on this platform (proprietary)
// ─────────────────────────────────────────────────────────────────────
app.MapGet("/v1/audio/voices", () =>
{
return Results.Ok(new
{
@object = "list",
available = TtsEngine.IsAvailable,
engine = "kokoro",
detail = TtsEngine.IsAvailable ? "" : TtsEngine.UnavailableReason,
data = TtsEngine.Voices.Select(v => new { id = v, @object = "voice" })
});
});
// ─────────────────────────────────────────────────────────────────────
// POST /v1/voice/listen — one-shot speech recognition (proprietary, Windows only)
//
// Uses the server microphone through the AIOffice.VoiceAgent.Win.exe subprocess
// (the same chain as the AIOffice Voice panel). Reported unavailable (501) when the
// platform or the executable is missing. The client then falls back to text input.
// ─────────────────────────────────────────────────────────────────────
app.MapPost("/v1/voice/listen", async (VoiceListenRequest request, CancellationToken ct) =>
{
if (!VoiceBridge.IsAvailable)
return Results.Json(new { error = "voice_unavailable", detail = VoiceBridge.UnavailableReason }, statusCode: 501);
try
{
var text = await VoiceBridge.ListenOnceAsync(request.Lang, request.TimeoutSeconds ?? 15, ct);
// Speech recognition speaks the machine's language: reflect the language actually
// used (never a hardcoded default) in the response.
var lang = request.Lang ?? SystemLang.Get();
return Results.Ok(new { text, lang, provider = "voiceagent-win" });
}
catch (TimeoutException ex)
{
return Results.Json(new { error = "timeout", detail = ex.Message }, statusCode: 408);
}
catch (Exception ex)
{
return Results.Problem(detail: ex.Message, statusCode: 500, title: "Speech recognition failed");
}
});
// ─────────────────────────────────────────────────────────────────────
// GET /v1/control — read session state and/or platform capabilities (proprietary)
//
// ?session_id=... → that session's state (LLM in use, history estimate, features).
// without session → platform capabilities (TTS/voice availability, providers).
// ─────────────────────────────────────────────────────────────────────
app.MapGet("/v1/control", (string? session_id) =>
{
if (!string.IsNullOrEmpty(session_id))
{
var session = SessionStore.Get(session_id);
if (session == null)
return Results.NotFound(new { error = $"Session '{session_id}' not found" });
return Results.Ok(SessionState(session));
}
return Results.Ok(new { capabilities = BuildCapabilities() });
});
// ─────────────────────────────────────────────────────────────────────
// POST /v1/control — pilot endpoint (proprietary, extensible)
//
// The "control plane" of the server: switch the LLM currently in use for a session,
// toggle feature flags (voice, tts, ...), reset the conversation, or create a session.
// Body (all fields optional):
// { "create": true } → create a new session (returns its id)
// { "session_id": "...", "llm_provider": "Zai" } → switch the LLM in use
// { "session_id": "...", "features": { "voice": true, "tts": false } }
// { "session_id": "...", "reset_history": true }
//
// A provider switch is refused (409) when the accumulated conversation overflows the
// target provider's context window (the exact case "switch on the fly conflicts with
// the context window of the model in use"); reset the conversation and retry.
// ─────────────────────────────────────────────────────────────────────
app.MapPost("/v1/control", (ControlRequest request) =>
{
if (request.Create == true)
{
var created = SessionStore.Create(startupProvider, anonymize);
return Results.Ok(SessionState(created));
}
if (string.IsNullOrEmpty(request.SessionId))
return Results.BadRequest(new { error = "session_id is required for mutations (or use {\"create\": true} to create a new session)" });
var session = SessionStore.Get(request.SessionId);
if (session == null)
return Results.NotFound(new { error = $"Session '{request.SessionId}' not found" });
session.Gate.Wait();
try
{
if (request.ResetHistory == true)
session.Orchestrator.ResetConversation();
if (!string.IsNullOrEmpty(request.LlmProvider))
{
var target = ResolveProvider(request.LlmProvider, startupProvider, out var error)!;
if (error != null)
return Results.BadRequest(new { error });
if (!string.Equals(session.Orchestrator.Provider, target, StringComparison.OrdinalIgnoreCase))
{
var fitError = ContextFitError(session, target, "");
if (fitError != null)
return Results.Json(fitError, statusCode: 409);
session.Orchestrator.SwitchProvider(target);
}
}
if (request.Features != null)
foreach (var kv in request.Features)
session.Features[kv.Key] = kv.Value;
return Results.Ok(SessionState(session));