Skip to content

Commit 44a7d60

Browse files
authored
Merge pull request #83 from eanzhao/feat/nyxid-llm-onboarding-and-picker
feat(auth): NyxID-first LLM onboarding + AI Services picker
2 parents eae3b3f + c902230 commit 44a7d60

17 files changed

Lines changed: 2005 additions & 316 deletions

README.md

Lines changed: 156 additions & 128 deletions
Large diffs are not rendered by default.

README_zh.md

Lines changed: 324 additions & 0 deletions
Large diffs are not rendered by default.

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Aexon 文档总览
22

3+
> Aexon 现在有两条主线:**本地 coding agent**(继承自 Claude Code)和 **NyxID / Aevatar / Chrono-Storage 三件套 CLI**。这套 `docs/` 主要沉淀前一条线 —— 跟上游 Claude Code 的对照、核心链路的移植。三件套集成这条线直接看仓库根的 [README.md](../README.md)
4+
35
这套文档主要回答三件事:
46

57
1. `claude-code/` 里那份 TypeScript 快照,整体是怎么跑起来的。

src/Aexon.Cli/ChatClientFactory.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ public static ChatClientBootstrap Create(
2121
string model,
2222
NyxIdRoutingContext? nyxIdRouting = null)
2323
{
24+
// When the user selected a NyxID AI Service (proxy slug) as their
25+
// default, we treat it as an OpenAI-compatible provider and point
26+
// the OpenAI SDK at `/api/v1/proxy/s/{slug}/v1/` instead of the
27+
// gateway-native `/api/v1/llm/openai/v1/` path.
28+
if (nyxIdRouting?.ProxyServiceSlug is { Length: > 0 })
29+
{
30+
return CreateOpenAICompatibleProxyService(
31+
model,
32+
nyxIdRouting!);
33+
}
34+
2435
return provider switch
2536
{
2637
AiProvider.Ollama => CreateOllama(model),
@@ -51,6 +62,28 @@ private static ChatClientBootstrap CreateOpenAIWithNyxId(
5162
NyxIdRoutingContext routing)
5263
{
5364
var proxyBaseUrl = NyxIdProxyEndpoints.Resolve(routing.BaseUrl, AiProvider.OpenAI);
65+
return CreateOpenAICompatible(model, routing, proxyBaseUrl, "OpenAI");
66+
}
67+
68+
private static ChatClientBootstrap CreateOpenAICompatibleProxyService(
69+
string model,
70+
NyxIdRoutingContext routing)
71+
{
72+
var proxyBaseUrl = NyxIdProxyEndpoints.ResolveProxyServiceEndpoint(
73+
routing.BaseUrl,
74+
routing.ProxyServiceSlug!);
75+
var displayName = string.IsNullOrWhiteSpace(routing.ProxyServiceLabel)
76+
? $"NyxID proxy '{routing.ProxyServiceSlug}'"
77+
: $"{routing.ProxyServiceLabel} (proxy '{routing.ProxyServiceSlug}')";
78+
return CreateOpenAICompatible(model, routing, proxyBaseUrl, displayName);
79+
}
80+
81+
private static ChatClientBootstrap CreateOpenAICompatible(
82+
string model,
83+
NyxIdRoutingContext routing,
84+
Uri proxyBaseUrl,
85+
string displayName)
86+
{
5487
var httpClient = new HttpClient(
5588
new NyxIdProxyAuthenticationHandler(routing.TokenProvider)
5689
{
@@ -68,7 +101,7 @@ private static ChatClientBootstrap CreateOpenAIWithNyxId(
68101
return new ChatClientBootstrap(
69102
new OwnedChatClient(client.GetChatClient(model).AsIChatClient(), httpClient),
70103
routing.HasStoredCredentials,
71-
BuildNyxIdStartupSummary("OpenAI", proxyBaseUrl, routing.HasStoredCredentials));
104+
BuildNyxIdStartupSummary(displayName, proxyBaseUrl, routing.HasStoredCredentials));
72105
}
73106

74107
private static ChatClientBootstrap CreateOllama(string model)
@@ -147,4 +180,6 @@ private static string BuildNyxIdStartupSummary(
147180
internal sealed record NyxIdRoutingContext(
148181
string BaseUrl,
149182
NyxIdTokenProvider TokenProvider,
150-
bool HasStoredCredentials);
183+
bool HasStoredCredentials,
184+
string? ProxyServiceSlug = null,
185+
string? ProxyServiceLabel = null);

src/Aexon.Cli/Program.cs

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,65 @@ public static async Task<int> Main(string[] args)
120120
contextProvider.PermissionContext.Mode = resumedMode;
121121
await contextProvider.LoadMemoryAsync();
122122
var storedCredentials = nyxIdCredentialStore.Load();
123+
124+
// First-run onboarding: unless the user is about to run a setup-fixing
125+
// subcommand (login/logout/llm/aevatar/storage), explicitly overrode
126+
// the provider/model, picked Ollama, or is resuming an existing
127+
// session, refuse to boot without a usable NyxID default. If we have
128+
// a TTY we walk the picker inline; otherwise we print guidance and
129+
// exit non-zero so CI doesn't hang on a prompt.
130+
var invocationBypassesOnboarding =
131+
IsSetupFixingSubcommand(options.InitialPrompt) ||
132+
!string.IsNullOrWhiteSpace(options.Provider) ||
133+
!string.IsNullOrWhiteSpace(options.Model) ||
134+
resumed != null;
135+
136+
if (!invocationBypassesOnboarding)
137+
{
138+
if (storedCredentials == null)
139+
{
140+
Console.Error.WriteLine(
141+
" 尚未登录 NyxID。请先运行 `aexon login`(或 `/login`),完成后再执行本次命令。");
142+
return 1;
143+
}
144+
145+
if (string.IsNullOrWhiteSpace(storedCredentials.DefaultProvider) &&
146+
string.IsNullOrWhiteSpace(storedCredentials.DefaultProxySlug))
147+
{
148+
var canPromptInteractively = !options.PrintMode && !Console.IsInputRedirected;
149+
if (!canPromptInteractively)
150+
{
151+
Console.Error.WriteLine(
152+
" 尚未为 aexon 选择默认 LLM provider。请先运行 `aexon llm`(交互式挑选)\n" +
153+
" 或 `aexon llm use <provider> [model]`(非交互),再重跑本次命令。");
154+
return 1;
155+
}
156+
157+
using var onboardingStatusClient = new NyxIdLlmStatusClient(nyxIdTokenProvider);
158+
using var onboardingKeysClient = new NyxIdKeysClient(nyxIdTokenProvider);
159+
Console.WriteLine(" 尚未选择默认 LLM provider,进入首次配置流程:");
160+
var updated = await NyxIdProviderPicker.RunAsync(
161+
nyxIdCredentialStore,
162+
onboardingStatusClient,
163+
onboardingKeysClient,
164+
storedCredentials,
165+
Console.WriteLine,
166+
new SpectreProviderPickerUi(),
167+
CancellationToken.None);
168+
169+
if (updated == null ||
170+
(string.IsNullOrWhiteSpace(updated.DefaultProvider) &&
171+
string.IsNullOrWhiteSpace(updated.DefaultProxySlug)))
172+
{
173+
Console.Error.WriteLine(
174+
" 未能完成 provider 选择。之后可以随时运行 `aexon llm` 重试。");
175+
return 1;
176+
}
177+
178+
storedCredentials = updated;
179+
}
180+
}
181+
123182
var defaultProviderFromStore = string.IsNullOrWhiteSpace(storedCredentials?.DefaultProvider)
124183
? null
125184
: storedCredentials!.DefaultProvider;
@@ -128,11 +187,35 @@ public static async Task<int> Main(string[] args)
128187
!string.IsNullOrWhiteSpace(storedCredentials?.DefaultModel)
129188
? storedCredentials!.DefaultModel
130189
: null;
131-
var sessionTarget = AiProviderSelection.ResolveSessionTarget(
132-
options.Provider ?? defaultProviderFromStore,
133-
options.Model ?? defaultModelFromStore,
134-
resumed?.SourceSession.Provider,
135-
resumed?.Model);
190+
191+
// An explicit --provider / --model flag always beats a stored proxy
192+
// default: the user asked for something specific, so don't silently
193+
// reroute them through an AI Service. Absent overrides, the proxy
194+
// slug forces OpenAI-compat routing via /api/v1/proxy/s/{slug}/v1/.
195+
var activeProxySlug = string.IsNullOrWhiteSpace(options.Provider) &&
196+
!string.IsNullOrWhiteSpace(storedCredentials?.DefaultProxySlug)
197+
? storedCredentials!.DefaultProxySlug
198+
: null;
199+
var activeProxyLabel = activeProxySlug != null
200+
? storedCredentials?.DefaultProxyLabel
201+
: null;
202+
203+
AiSessionTarget sessionTarget;
204+
if (activeProxySlug != null)
205+
{
206+
sessionTarget = AiProviderSelection.ResolveSessionTargetForProxyService(
207+
options.Model,
208+
defaultModelFromStore,
209+
resumed?.Model);
210+
}
211+
else
212+
{
213+
sessionTarget = AiProviderSelection.ResolveSessionTarget(
214+
options.Provider ?? defaultProviderFromStore,
215+
options.Model ?? defaultModelFromStore,
216+
resumed?.SourceSession.Provider,
217+
resumed?.Model);
218+
}
136219
var aiProvider = sessionTarget.Provider;
137220
var model = sessionTarget.Model;
138221

@@ -155,7 +238,9 @@ public static async Task<int> Main(string[] args)
155238
: new NyxIdRoutingContext(
156239
nyxIdSettings.ActiveBaseUrl,
157240
nyxIdTokenProvider,
158-
nyxIdSettings.HasStoredCredentials);
241+
nyxIdSettings.HasStoredCredentials,
242+
ProxyServiceSlug: activeProxySlug,
243+
ProxyServiceLabel: activeProxyLabel);
159244
using var chatClientRuntime = ChatClientRuntime.Create(
160245
aiProvider,
161246
model,
@@ -264,12 +349,14 @@ await journal.SeedAsync(
264349
agentRuntimeOptions,
265350
agentSettings.Settings.BackgroundRunConcurrency);
266351
var nyxIdLlmStatusClient = new NyxIdLlmStatusClient(nyxIdTokenProvider);
352+
var nyxIdKeysClient = new NyxIdKeysClient(nyxIdTokenProvider);
267353
var aevatarSettingsStore = new AevatarChatSettingsStore();
268354
var commandRegistry = BuildCommandRegistry(
269355
skillLoader.Load(workingDirectory),
270356
nyxIdAuthService,
271357
nyxIdCredentialStore,
272358
nyxIdLlmStatusClient,
359+
nyxIdKeysClient,
273360
nyxIdSettings.ActiveBaseUrl,
274361
nyxIdTokenProvider,
275362
aevatarSettingsStore,
@@ -740,6 +827,7 @@ private static CommandRegistry BuildCommandRegistry(
740827
NyxIdAuthService nyxIdAuthService,
741828
NyxIdCredentialStore nyxIdCredentialStore,
742829
NyxIdLlmStatusClient nyxIdLlmStatusClient,
830+
NyxIdKeysClient nyxIdKeysClient,
743831
string nyxIdBaseUrl,
744832
NyxIdTokenProvider nyxIdTokenProvider,
745833
AevatarChatSettingsStore aevatarSettingsStore,
@@ -767,7 +855,7 @@ private static CommandRegistry BuildCommandRegistry(
767855
registry.Register(new FastCommand());
768856
registry.Register(new HelpCommand());
769857
registry.Register(new InitCommand());
770-
registry.Register(new LlmCommand(nyxIdCredentialStore, nyxIdLlmStatusClient));
858+
registry.Register(new LlmCommand(nyxIdCredentialStore, nyxIdLlmStatusClient, nyxIdKeysClient));
771859
registry.Register(new LoginCommand(nyxIdAuthService, nyxIdCredentialStore, nyxIdBaseUrl));
772860
registry.Register(new LogoutCommand(nyxIdAuthService, nyxIdCredentialStore));
773861
registry.Register(new MailboxCommand());
@@ -849,6 +937,31 @@ AEXON_CHAT_LOGGING Enable MEAI pipeline logging on the console (1/true)
849937
"storage",
850938
};
851939

940+
/// <summary>
941+
/// Returns true when <paramref name="initialPrompt"/> dispatches one of
942+
/// the setup-fixing subcommands (<c>/login</c>, <c>/logout</c>,
943+
/// <c>/llm</c>, <c>/aevatar</c>, <c>/storage</c>). The onboarding gate
944+
/// bails out in that case — we must let users reach those commands even
945+
/// if they haven't signed in yet, otherwise they'd have no way to fix
946+
/// their setup.
947+
/// </summary>
948+
private static bool IsSetupFixingSubcommand(string? initialPrompt)
949+
{
950+
if (string.IsNullOrWhiteSpace(initialPrompt))
951+
return false;
952+
953+
var trimmed = initialPrompt.TrimStart();
954+
if (trimmed.Length == 0 || trimmed[0] != '/')
955+
return false;
956+
957+
var end = 1;
958+
while (end < trimmed.Length && trimmed[end] != ' ' && trimmed[end] != '\t')
959+
end++;
960+
961+
var head = trimmed[1..end];
962+
return TopLevelSubcommands.Contains(head);
963+
}
964+
852965
/// <summary>
853966
/// Lets users invoke slash commands without the leading slash as a top-level
854967
/// subcommand (`aexon login`, `aexon llm use anthropic`). Only the first

0 commit comments

Comments
 (0)