codeblog-app 2.5.1 โ 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/ai/chat.ts +45 -1
- package/src/ai/codeblog-provider.ts +41 -0
- package/src/ai/provider.ts +13 -2
- package/src/ai/tools.ts +2 -0
- package/src/cli/cmd/mcp.ts +18 -0
- package/src/cli/cmd/setup.ts +60 -2
- package/src/cli/cmd/update.ts +33 -4
- package/src/cli/mcp-init.ts +317 -0
- package/src/cli/ui.ts +102 -1
- package/src/index.ts +9 -3
- package/src/tui/app.tsx +17 -0
- package/src/tui/commands.ts +3 -3
- package/src/tui/routes/home.tsx +397 -35
- package/src/tui/routes/setup.tsx +1 -1
package/src/tui/routes/home.tsx
CHANGED
|
@@ -5,13 +5,121 @@ import { useExit } from "../context/exit"
|
|
|
5
5
|
import { useTheme, type ThemeColors } from "../context/theme"
|
|
6
6
|
import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
|
|
7
7
|
import { TOOL_LABELS } from "../../ai/tools"
|
|
8
|
-
import { mask
|
|
8
|
+
import { mask } from "../../ai/configure"
|
|
9
9
|
import { ChatHistory } from "../../storage/chat"
|
|
10
10
|
import { TuiStreamAssembler } from "../stream-assembler"
|
|
11
11
|
import { resolveAssistantContent } from "../ai-stream"
|
|
12
12
|
import { isShiftEnterSequence, onInputIntent } from "../input-intent"
|
|
13
13
|
import { Log } from "../../util/log"
|
|
14
14
|
|
|
15
|
+
interface ProviderChoice {
|
|
16
|
+
name: string
|
|
17
|
+
providerID: string
|
|
18
|
+
api: "anthropic" | "openai" | "google" | "openai-compatible"
|
|
19
|
+
baseURL?: string
|
|
20
|
+
hint?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PROVIDER_CHOICES: ProviderChoice[] = [
|
|
24
|
+
{ name: "CodeBlog Free Credit ($5)", providerID: "codeblog", api: "openai-compatible", baseURL: "", hint: "Free $5 AI credit, no API key needed" },
|
|
25
|
+
{ name: "OpenAI", providerID: "openai", api: "openai", baseURL: "https://api.openai.com", hint: "Codex OAuth + API key style" },
|
|
26
|
+
{ name: "Anthropic", providerID: "anthropic", api: "anthropic", baseURL: "https://api.anthropic.com", hint: "Claude API key" },
|
|
27
|
+
{ name: "Google", providerID: "google", api: "google", baseURL: "https://generativelanguage.googleapis.com", hint: "Gemini API key" },
|
|
28
|
+
{ name: "OpenRouter", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://openrouter.ai/api", hint: "OpenAI-compatible" },
|
|
29
|
+
{ name: "vLLM", providerID: "openai-compatible", api: "openai-compatible", baseURL: "http://127.0.0.1:8000", hint: "Local/self-hosted OpenAI-compatible" },
|
|
30
|
+
{ name: "MiniMax", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.minimax.io", hint: "OpenAI-compatible endpoint" },
|
|
31
|
+
{ name: "Moonshot AI (Kimi K2.5)", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.moonshot.ai", hint: "OpenAI-compatible endpoint" },
|
|
32
|
+
{ name: "xAI (Grok)", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.x.ai", hint: "OpenAI-compatible endpoint" },
|
|
33
|
+
{ name: "Qianfan", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://qianfan.baidubce.com", hint: "OpenAI-compatible endpoint" },
|
|
34
|
+
{ name: "Vercel AI Gateway", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://ai-gateway.vercel.sh", hint: "OpenAI-compatible endpoint" },
|
|
35
|
+
{ name: "OpenCode Zen", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://opencode.ai/zen", hint: "OpenAI-compatible endpoint" },
|
|
36
|
+
{ name: "Xiaomi", providerID: "anthropic", api: "anthropic", baseURL: "https://api.xiaomimimo.com/anthropic", hint: "Anthropic-compatible endpoint" },
|
|
37
|
+
{ name: "Synthetic", providerID: "anthropic", api: "anthropic", baseURL: "https://api.synthetic.new", hint: "Anthropic-compatible endpoint" },
|
|
38
|
+
{ name: "Together AI", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.together.xyz", hint: "OpenAI-compatible endpoint" },
|
|
39
|
+
{ name: "Hugging Face", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://router.huggingface.co", hint: "OpenAI-compatible endpoint" },
|
|
40
|
+
{ name: "Venice AI", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.venice.ai/api", hint: "OpenAI-compatible endpoint" },
|
|
41
|
+
{ name: "LiteLLM", providerID: "openai-compatible", api: "openai-compatible", baseURL: "http://localhost:4000", hint: "Unified OpenAI-compatible gateway" },
|
|
42
|
+
{ name: "Cloudflare AI Gateway", providerID: "anthropic", api: "anthropic", hint: "Enter full Anthropic gateway URL manually" },
|
|
43
|
+
{ name: "Custom Provider", providerID: "openai-compatible", api: "openai-compatible", hint: "Any OpenAI-compatible URL" },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
type AiSetupStep = "" | "provider" | "url" | "key" | "testing"
|
|
47
|
+
|
|
48
|
+
function isOfficialOpenAIBase(baseURL: string): boolean {
|
|
49
|
+
try { return new URL(baseURL).hostname === "api.openai.com" } catch { return false }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function providerLabel(p: ProviderChoice): string {
|
|
53
|
+
return p.hint ? `${p.name} (${p.hint})` : p.name
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchOpenAIModels(baseURL: string, key: string): Promise<string[]> {
|
|
57
|
+
try {
|
|
58
|
+
const clean = baseURL.replace(/\/+$/, "")
|
|
59
|
+
const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
|
|
60
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(8000) })
|
|
61
|
+
if (!r.ok) return []
|
|
62
|
+
const data = await r.json() as { data?: Array<{ id: string }> }
|
|
63
|
+
return data.data?.map((m) => m.id) || []
|
|
64
|
+
} catch { return [] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pickPreferredRemoteModel(models: string[]): string | undefined {
|
|
68
|
+
if (models.length === 0) return undefined
|
|
69
|
+
const preferred = [/^gpt-5\.2$/, /^claude-sonnet-4(?:-5)?/, /^gpt-5(?:\.|$|-)/, /^gpt-4o$/, /^gpt-4o-mini$/, /^gemini-2\.5-flash$/]
|
|
70
|
+
for (const pattern of preferred) {
|
|
71
|
+
const found = models.find((id) => pattern.test(id))
|
|
72
|
+
if (found) return found
|
|
73
|
+
}
|
|
74
|
+
return models[0]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function verifyEndpoint(choice: ProviderChoice, baseURL: string, key: string): Promise<{ ok: boolean; detail: string; detectedApi?: "anthropic" | "openai" | "google" | "openai-compatible" }> {
|
|
78
|
+
try {
|
|
79
|
+
if (choice.api === "anthropic") {
|
|
80
|
+
const clean = baseURL.replace(/\/+$/, "")
|
|
81
|
+
const r = await fetch(`${clean}/v1/messages`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ model: "claude-3-5-haiku-latest", max_tokens: 1, messages: [{ role: "user", content: "ping" }] }),
|
|
85
|
+
signal: AbortSignal.timeout(8000),
|
|
86
|
+
})
|
|
87
|
+
if (r.status !== 404) return { ok: true, detail: `Anthropic endpoint reachable (${r.status})`, detectedApi: "anthropic" }
|
|
88
|
+
return { ok: false, detail: "Anthropic endpoint returned 404" }
|
|
89
|
+
}
|
|
90
|
+
if (choice.api === "google") {
|
|
91
|
+
const clean = baseURL.replace(/\/+$/, "")
|
|
92
|
+
const r = await fetch(`${clean}/v1beta/models?key=${encodeURIComponent(key)}`, { signal: AbortSignal.timeout(8000) })
|
|
93
|
+
if (r.ok || r.status === 401 || r.status === 403) return { ok: true, detail: `Google endpoint reachable (${r.status})` }
|
|
94
|
+
return { ok: false, detail: `Google endpoint responded ${r.status}` }
|
|
95
|
+
}
|
|
96
|
+
const { probe } = await import("../../ai/configure")
|
|
97
|
+
const detected = await probe(baseURL, key)
|
|
98
|
+
if (detected === "anthropic") return { ok: true, detail: "Detected Anthropic API format", detectedApi: "anthropic" }
|
|
99
|
+
if (detected === "openai") {
|
|
100
|
+
const api = choice.providerID === "openai" && isOfficialOpenAIBase(baseURL) ? "openai" as const : "openai-compatible" as const
|
|
101
|
+
return { ok: true, detail: "Detected OpenAI API format", detectedApi: api }
|
|
102
|
+
}
|
|
103
|
+
const models = await fetchOpenAIModels(baseURL, key)
|
|
104
|
+
if (models.length > 0) {
|
|
105
|
+
const api = choice.providerID === "openai" && isOfficialOpenAIBase(baseURL) ? "openai" as const : "openai-compatible" as const
|
|
106
|
+
return { ok: true, detail: `Model endpoint reachable (${models.length} models)`, detectedApi: api }
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, detail: "Could not detect endpoint format or list models" }
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err) }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function pickQuickModel(choice: ProviderChoice, baseURL: string, key: string): Promise<string> {
|
|
115
|
+
const openaiCustom = choice.providerID === "openai" && !isOfficialOpenAIBase(baseURL)
|
|
116
|
+
if (choice.providerID === "anthropic") return "claude-sonnet-4-20250514"
|
|
117
|
+
if (choice.providerID === "openai" && !openaiCustom) return "gpt-5.2"
|
|
118
|
+
if (choice.providerID === "google") return "gemini-2.5-flash"
|
|
119
|
+
const remote = await fetchOpenAIModels(baseURL, key)
|
|
120
|
+
return pickPreferredRemoteModel(remote) || "gpt-5.2"
|
|
121
|
+
}
|
|
122
|
+
|
|
15
123
|
function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
|
|
16
124
|
return [
|
|
17
125
|
{ scope: ["default"], style: { foreground: colors.text } },
|
|
@@ -61,6 +169,7 @@ export function Home(props: {
|
|
|
61
169
|
hasAI: boolean
|
|
62
170
|
aiProvider: string
|
|
63
171
|
modelName: string
|
|
172
|
+
creditBalance?: string
|
|
64
173
|
onLogin: () => Promise<{ ok: boolean; error?: string }>
|
|
65
174
|
onLogout: () => void
|
|
66
175
|
onAIConfigured: () => void
|
|
@@ -131,9 +240,12 @@ export function Home(props: {
|
|
|
131
240
|
|
|
132
241
|
const tipPool = () => props.hasAI ? TIPS : TIPS_NO_AI
|
|
133
242
|
const tipIdx = Math.floor(Math.random() * TIPS.length)
|
|
134
|
-
const [aiMode, setAiMode] = createSignal<
|
|
243
|
+
const [aiMode, setAiMode] = createSignal<AiSetupStep>("")
|
|
135
244
|
const [aiUrl, setAiUrl] = createSignal("")
|
|
136
245
|
const [aiKey, setAiKey] = createSignal("")
|
|
246
|
+
const [aiProviderIdx, setAiProviderIdx] = createSignal(0)
|
|
247
|
+
const [aiProviderQuery, setAiProviderQuery] = createSignal("")
|
|
248
|
+
const [aiProvider, setAiProvider] = createSignal<ProviderChoice | null>(null)
|
|
137
249
|
const [modelPicking, setModelPicking] = createSignal(false)
|
|
138
250
|
const [modelOptions, setModelOptions] = createSignal<ModelOption[]>([])
|
|
139
251
|
const [modelIdx, setModelIdx] = createSignal(0)
|
|
@@ -305,8 +417,12 @@ export function Home(props: {
|
|
|
305
417
|
onLogout: props.onLogout,
|
|
306
418
|
clearChat,
|
|
307
419
|
startAIConfig: () => {
|
|
308
|
-
setAiUrl("")
|
|
309
|
-
|
|
420
|
+
setAiUrl("")
|
|
421
|
+
setAiKey("")
|
|
422
|
+
setAiProviderIdx(0)
|
|
423
|
+
setAiProviderQuery("")
|
|
424
|
+
setAiProvider(null)
|
|
425
|
+
setAiMode("provider")
|
|
310
426
|
},
|
|
311
427
|
setMode: theme.setMode,
|
|
312
428
|
send,
|
|
@@ -395,12 +511,26 @@ export function Home(props: {
|
|
|
395
511
|
onCleanup(() => offInputIntent())
|
|
396
512
|
|
|
397
513
|
usePaste((evt) => {
|
|
398
|
-
|
|
399
|
-
if (
|
|
514
|
+
const mode = aiMode()
|
|
515
|
+
if (mode === "provider") {
|
|
516
|
+
const text = evt.text.replace(/[\n\r]/g, "").trim()
|
|
517
|
+
if (!text) return
|
|
518
|
+
evt.preventDefault()
|
|
519
|
+
setAiProviderQuery(text)
|
|
520
|
+
setAiProviderIdx(0)
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
if (mode === "url") {
|
|
524
|
+
const text = evt.text.replace(/[\n\r]/g, "").trim()
|
|
525
|
+
if (!text) return
|
|
526
|
+
evt.preventDefault()
|
|
527
|
+
setAiUrl(text)
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
if (mode === "key") {
|
|
400
531
|
const text = evt.text.replace(/[\n\r]/g, "").trim()
|
|
401
532
|
if (!text) return
|
|
402
533
|
evt.preventDefault()
|
|
403
|
-
if (aiMode() === "url") { setAiUrl(text); return }
|
|
404
534
|
setAiKey(text)
|
|
405
535
|
return
|
|
406
536
|
}
|
|
@@ -532,50 +662,182 @@ export function Home(props: {
|
|
|
532
662
|
}
|
|
533
663
|
}
|
|
534
664
|
|
|
535
|
-
|
|
665
|
+
const aiProviderFiltered = createMemo(() => {
|
|
666
|
+
const q = aiProviderQuery().trim().toLowerCase()
|
|
667
|
+
const all = [...PROVIDER_CHOICES, { name: "Skip for now", providerID: "", api: "openai" as const, hint: "" }]
|
|
668
|
+
if (!q) return all
|
|
669
|
+
return all.filter((p) => p.name.toLowerCase().includes(q) || (p.hint || "").toLowerCase().includes(q))
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
const aiProviderVisibleStart = createMemo(() => {
|
|
673
|
+
const len = aiProviderFiltered().length
|
|
674
|
+
if (len <= 10) return 0
|
|
675
|
+
return Math.max(0, Math.min(aiProviderIdx() - 4, len - 10))
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
const aiProviderVisibleItems = createMemo(() => {
|
|
679
|
+
const start = aiProviderVisibleStart()
|
|
680
|
+
return aiProviderFiltered().slice(start, start + 10)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
createEffect(() => {
|
|
684
|
+
const len = aiProviderFiltered().length
|
|
685
|
+
const idx = aiProviderIdx()
|
|
686
|
+
if (len === 0 && idx !== 0) { setAiProviderIdx(0); return }
|
|
687
|
+
if (idx >= len) setAiProviderIdx(Math.max(0, len - 1))
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
function confirmProvider() {
|
|
691
|
+
const list = aiProviderFiltered()
|
|
692
|
+
const selected = list[aiProviderIdx()]
|
|
693
|
+
if (!selected || !selected.providerID) {
|
|
694
|
+
showMsg("Skipped AI setup.", theme.colors.warning)
|
|
695
|
+
setAiMode("")
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
setAiProvider(selected)
|
|
699
|
+
|
|
700
|
+
// CodeBlog Free Credit: skip URL/key, claim credit directly
|
|
701
|
+
if (selected.providerID === "codeblog") {
|
|
702
|
+
runCodeblogCreditSetup()
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const defaultBase = selected.baseURL || ""
|
|
707
|
+
const needsUrl =
|
|
708
|
+
selected.providerID === "openai-compatible" ||
|
|
709
|
+
selected.providerID === "openai" ||
|
|
710
|
+
!defaultBase
|
|
711
|
+
setAiUrl("")
|
|
712
|
+
if (needsUrl) {
|
|
713
|
+
setAiMode("url")
|
|
714
|
+
} else {
|
|
715
|
+
setAiMode("key")
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function runCodeblogCreditSetup() {
|
|
536
720
|
setAiMode("testing")
|
|
537
|
-
showMsg("Detecting API format...", theme.colors.primary)
|
|
538
721
|
try {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
722
|
+
const { claimCredit, fetchCreditBalance } = await import("../../ai/codeblog-provider")
|
|
723
|
+
const { Config } = await import("../../config")
|
|
724
|
+
const { Auth } = await import("../../auth")
|
|
725
|
+
|
|
726
|
+
const isLoggedIn = await Auth.authenticated()
|
|
727
|
+
if (!isLoggedIn) {
|
|
728
|
+
showMsg("You need to be logged in to claim free credit. Run: codeblog login", theme.colors.warning)
|
|
729
|
+
setAiMode("")
|
|
543
730
|
return
|
|
544
731
|
}
|
|
545
|
-
|
|
732
|
+
|
|
733
|
+
const claim = await claimCredit()
|
|
734
|
+
const balance = await fetchCreditBalance()
|
|
735
|
+
|
|
736
|
+
const proxyURL = `${(await Config.url()).replace(/\/+$/, "")}/api/v1/ai-credit/chat`
|
|
737
|
+
const cfg = await Config.load()
|
|
738
|
+
const providers = cfg.providers || {}
|
|
739
|
+
providers["codeblog"] = {
|
|
740
|
+
api_key: "proxy",
|
|
741
|
+
base_url: proxyURL,
|
|
742
|
+
api: "openai-compatible",
|
|
743
|
+
compat_profile: "openai-compatible",
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
await Config.save({
|
|
747
|
+
providers,
|
|
748
|
+
default_provider: "codeblog",
|
|
749
|
+
model: `codeblog/${balance.model}`,
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
const msg = claim.already_claimed
|
|
753
|
+
? `โ Credit already claimed ($${claim.balance_usd} remaining). AI configured!`
|
|
754
|
+
: `โ $${claim.balance_usd} AI credit activated! (${balance.model})`
|
|
755
|
+
showMsg(msg, theme.colors.success)
|
|
546
756
|
setAiMode("")
|
|
547
757
|
props.onAIConfigured()
|
|
548
758
|
} catch (err) {
|
|
549
|
-
|
|
550
|
-
|
|
759
|
+
const emsg = err instanceof Error ? err.message : String(err)
|
|
760
|
+
if (emsg.includes("403")) {
|
|
761
|
+
showMsg("Free credit requires a GitHub or Google linked account", theme.colors.warning)
|
|
762
|
+
} else {
|
|
763
|
+
showMsg(`Failed to claim credit: ${emsg}`, theme.colors.error)
|
|
764
|
+
}
|
|
765
|
+
setAiMode("")
|
|
551
766
|
}
|
|
552
767
|
}
|
|
553
768
|
|
|
769
|
+
async function runVerifyAndSave() {
|
|
770
|
+
const choice = aiProvider()
|
|
771
|
+
if (!choice) return
|
|
772
|
+
const baseURL = aiUrl().trim()
|
|
773
|
+
const key = aiKey().trim()
|
|
774
|
+
|
|
775
|
+
setAiMode("testing")
|
|
776
|
+
|
|
777
|
+
const verify = await verifyEndpoint(choice, baseURL, key)
|
|
778
|
+
if (!verify.ok) {
|
|
779
|
+
showMsg(`Verification failed: ${verify.detail}`, theme.colors.warning)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const model = await pickQuickModel(choice, baseURL, key)
|
|
783
|
+
|
|
784
|
+
const { Config } = await import("../../config")
|
|
785
|
+
const cfg = await Config.load()
|
|
786
|
+
const providers = cfg.providers || {}
|
|
787
|
+
const resolvedApi = verify.detectedApi || choice.api
|
|
788
|
+
const resolvedCompat = choice.providerID === "openai-compatible" && resolvedApi === "openai"
|
|
789
|
+
? "openai-compatible" as const
|
|
790
|
+
: resolvedApi
|
|
791
|
+
const providerConfig: { api_key: string; base_url?: string; api: typeof resolvedApi; compat_profile: typeof resolvedCompat } = {
|
|
792
|
+
api_key: key,
|
|
793
|
+
api: resolvedApi,
|
|
794
|
+
compat_profile: resolvedCompat,
|
|
795
|
+
}
|
|
796
|
+
if (baseURL) providerConfig.base_url = baseURL
|
|
797
|
+
providers[choice.providerID] = providerConfig
|
|
798
|
+
|
|
799
|
+
const saveModel = choice.providerID === "openai-compatible" && !model.includes("/")
|
|
800
|
+
? `openai-compatible/${model}`
|
|
801
|
+
: model
|
|
802
|
+
|
|
803
|
+
await Config.save({
|
|
804
|
+
providers,
|
|
805
|
+
default_provider: choice.providerID,
|
|
806
|
+
model: saveModel,
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
showMsg(`โ AI configured: ${choice.name} (${saveModel})`, theme.colors.success)
|
|
810
|
+
setAiMode("")
|
|
811
|
+
props.onAIConfigured()
|
|
812
|
+
}
|
|
813
|
+
|
|
554
814
|
async function handleSubmit() {
|
|
815
|
+
if (aiMode() === "provider") {
|
|
816
|
+
confirmProvider()
|
|
817
|
+
return
|
|
818
|
+
}
|
|
555
819
|
if (aiMode() === "url") {
|
|
556
820
|
const v = aiUrl().trim()
|
|
557
821
|
if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
|
|
822
|
+
if (!v && !aiProvider()?.baseURL) { showMsg("Endpoint URL is required for this provider.", theme.colors.error); return }
|
|
823
|
+
if (!v) setAiUrl(aiProvider()?.baseURL || "")
|
|
558
824
|
setAiMode("key")
|
|
559
|
-
showMsg("Now paste your API key (or press Esc to cancel):", theme.colors.primary)
|
|
560
825
|
return
|
|
561
826
|
}
|
|
562
827
|
if (aiMode() === "key") {
|
|
563
|
-
const url = aiUrl().trim()
|
|
564
828
|
const key = aiKey().trim()
|
|
565
|
-
// Both empty โ friendly skip
|
|
566
|
-
if (!url && !key) {
|
|
567
|
-
showMsg("No AI configuration provided โ skipped. Use /ai anytime to configure.", theme.colors.warning)
|
|
568
|
-
setAiMode("")
|
|
569
|
-
return
|
|
570
|
-
}
|
|
571
|
-
// Key empty but URL provided โ friendly skip
|
|
572
829
|
if (!key) {
|
|
573
|
-
showMsg("
|
|
830
|
+
showMsg("Skipped AI setup. Use /ai anytime to configure.", theme.colors.warning)
|
|
574
831
|
setAiMode("")
|
|
575
832
|
return
|
|
576
833
|
}
|
|
577
|
-
if (key.length < 5) { showMsg("
|
|
578
|
-
|
|
834
|
+
if (key.length < 5) { showMsg("Credential seems too short.", theme.colors.error); return }
|
|
835
|
+
try {
|
|
836
|
+
await runVerifyAndSave()
|
|
837
|
+
} catch (err) {
|
|
838
|
+
showMsg(`Save failed: ${err instanceof Error ? err.message : String(err)}`, theme.colors.error)
|
|
839
|
+
setAiMode("key")
|
|
840
|
+
}
|
|
579
841
|
return
|
|
580
842
|
}
|
|
581
843
|
|
|
@@ -683,9 +945,36 @@ export function Home(props: {
|
|
|
683
945
|
(submitKey && evt.meta && !evt.ctrl) ||
|
|
684
946
|
(evt.name === "j" && evt.ctrl && !evt.meta)
|
|
685
947
|
|
|
948
|
+
if (aiMode() === "provider") {
|
|
949
|
+
if (evt.name === "up" || evt.name === "k") {
|
|
950
|
+
const len = aiProviderFiltered().length
|
|
951
|
+
if (len > 0) setAiProviderIdx((i) => (i - 1 + len) % len)
|
|
952
|
+
evt.preventDefault(); return
|
|
953
|
+
}
|
|
954
|
+
if (evt.name === "down" || evt.name === "j") {
|
|
955
|
+
const len = aiProviderFiltered().length
|
|
956
|
+
if (len > 0) setAiProviderIdx((i) => (i + 1) % len)
|
|
957
|
+
evt.preventDefault(); return
|
|
958
|
+
}
|
|
959
|
+
if (submitKey || evt.name === "enter") { handleSubmit(); evt.preventDefault(); return }
|
|
960
|
+
if (evt.name === "escape") {
|
|
961
|
+
if (aiProviderQuery()) { setAiProviderQuery(""); setAiProviderIdx(0); evt.preventDefault(); return }
|
|
962
|
+
setAiMode(""); evt.preventDefault(); return
|
|
963
|
+
}
|
|
964
|
+
if (evt.name === "backspace") { setAiProviderQuery((q) => q.slice(0, -1)); setAiProviderIdx(0); evt.preventDefault(); return }
|
|
965
|
+
if (print) { setAiProviderQuery((q) => q + print); setAiProviderIdx(0); evt.preventDefault(); return }
|
|
966
|
+
if (evt.name === "space") { setAiProviderQuery((q) => q + " "); setAiProviderIdx(0); evt.preventDefault(); return }
|
|
967
|
+
return
|
|
968
|
+
}
|
|
686
969
|
if (aiMode() === "url") {
|
|
687
970
|
if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
|
|
688
|
-
if (evt.name === "escape") {
|
|
971
|
+
if (evt.name === "escape") {
|
|
972
|
+
setAiUrl("")
|
|
973
|
+
setAiProviderQuery("")
|
|
974
|
+
setAiProviderIdx(0)
|
|
975
|
+
setAiMode("provider")
|
|
976
|
+
evt.preventDefault(); return
|
|
977
|
+
}
|
|
689
978
|
if (evt.name === "backspace") { setAiUrl((s) => s.slice(0, -1)); evt.preventDefault(); return }
|
|
690
979
|
if (print === " " || evt.name === "space") { evt.preventDefault(); return }
|
|
691
980
|
if (print) { setAiUrl((s) => s + print); evt.preventDefault(); return }
|
|
@@ -693,12 +982,25 @@ export function Home(props: {
|
|
|
693
982
|
}
|
|
694
983
|
if (aiMode() === "key") {
|
|
695
984
|
if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
|
|
696
|
-
if (evt.name === "escape") {
|
|
985
|
+
if (evt.name === "escape") {
|
|
986
|
+
setAiKey("")
|
|
987
|
+
const choice = aiProvider()
|
|
988
|
+
const needsUrl = choice && (choice.providerID === "openai-compatible" || choice.providerID === "openai" || !choice.baseURL)
|
|
989
|
+
if (needsUrl) {
|
|
990
|
+
setAiMode("url")
|
|
991
|
+
} else {
|
|
992
|
+
setAiProviderQuery("")
|
|
993
|
+
setAiProviderIdx(0)
|
|
994
|
+
setAiMode("provider")
|
|
995
|
+
}
|
|
996
|
+
evt.preventDefault(); return
|
|
997
|
+
}
|
|
697
998
|
if (evt.name === "backspace") { setAiKey((s) => s.slice(0, -1)); evt.preventDefault(); return }
|
|
698
999
|
if (print) { setAiKey((s) => s + print); evt.preventDefault(); return }
|
|
699
1000
|
if (evt.name === "space") { setAiKey((s) => s + " "); evt.preventDefault(); return }
|
|
700
1001
|
return
|
|
701
1002
|
}
|
|
1003
|
+
if (aiMode() === "testing") { evt.preventDefault(); return }
|
|
702
1004
|
|
|
703
1005
|
if (modelPicking()) {
|
|
704
1006
|
if (evt.name === "up" || evt.name === "k") {
|
|
@@ -801,7 +1103,7 @@ export function Home(props: {
|
|
|
801
1103
|
<text fg={i < 4 ? theme.colors.logo1 : theme.colors.logo2}>{line}</text>
|
|
802
1104
|
))}
|
|
803
1105
|
<box height={1} />
|
|
804
|
-
<text fg={theme.colors.textMuted}>
|
|
1106
|
+
<text fg={theme.colors.textMuted}>Agent Only Coding Society</text>
|
|
805
1107
|
|
|
806
1108
|
<box height={1} />
|
|
807
1109
|
<box flexDirection="column" alignItems="center" gap={0}>
|
|
@@ -812,6 +1114,9 @@ export function Home(props: {
|
|
|
812
1114
|
<text fg={theme.colors.text}>
|
|
813
1115
|
{props.hasAI ? props.modelName : "No AI"}
|
|
814
1116
|
</text>
|
|
1117
|
+
<Show when={props.creditBalance}>
|
|
1118
|
+
<text fg={theme.colors.warning}> ๐ฐ {props.creditBalance}</text>
|
|
1119
|
+
</Show>
|
|
815
1120
|
<Show when={!props.hasAI}>
|
|
816
1121
|
<text fg={theme.colors.textMuted}> โ type /ai</text>
|
|
817
1122
|
</Show>
|
|
@@ -945,9 +1250,58 @@ export function Home(props: {
|
|
|
945
1250
|
|
|
946
1251
|
{/* Prompt โ always at bottom */}
|
|
947
1252
|
<box flexShrink={0} paddingTop={1} paddingBottom={1}>
|
|
1253
|
+
<Show when={aiMode() === "provider"}>
|
|
1254
|
+
<box flexDirection="column">
|
|
1255
|
+
<text fg={theme.colors.text}><span style={{ bold: true }}>Choose a provider</span></text>
|
|
1256
|
+
<box flexDirection="row">
|
|
1257
|
+
<text fg={theme.colors.primary}><span style={{ bold: true }}>{"โฏ "}</span></text>
|
|
1258
|
+
<Show when={!aiProviderQuery()}>
|
|
1259
|
+
<text fg={theme.colors.textMuted}>type to search...</text>
|
|
1260
|
+
</Show>
|
|
1261
|
+
<Show when={aiProviderQuery()}>
|
|
1262
|
+
<text fg={theme.colors.input}>{aiProviderQuery()}</text>
|
|
1263
|
+
</Show>
|
|
1264
|
+
<text fg={theme.colors.cursor}>{"โ"}</text>
|
|
1265
|
+
</box>
|
|
1266
|
+
<Show when={aiProviderVisibleStart() > 0}>
|
|
1267
|
+
<text fg={theme.colors.textMuted}>{" โ more"}</text>
|
|
1268
|
+
</Show>
|
|
1269
|
+
<For each={aiProviderVisibleItems()}>
|
|
1270
|
+
{(p, i) => {
|
|
1271
|
+
const selected = () => i() + aiProviderVisibleStart() === aiProviderIdx()
|
|
1272
|
+
return (
|
|
1273
|
+
<box flexDirection="row" backgroundColor={selected() ? theme.colors.primary : undefined}>
|
|
1274
|
+
<text fg={selected() ? "#ffffff" : theme.colors.text}>
|
|
1275
|
+
{" " + (selected() ? "โ " : "โ ") + providerLabel(p)}
|
|
1276
|
+
</text>
|
|
1277
|
+
</box>
|
|
1278
|
+
)
|
|
1279
|
+
}}
|
|
1280
|
+
</For>
|
|
1281
|
+
<Show when={aiProviderFiltered().length === 0}>
|
|
1282
|
+
<text fg={theme.colors.warning}>{" No matched providers"}</text>
|
|
1283
|
+
</Show>
|
|
1284
|
+
<Show when={aiProviderVisibleStart() + aiProviderVisibleItems().length < aiProviderFiltered().length}>
|
|
1285
|
+
<text fg={theme.colors.textMuted}>{" โ more"}</text>
|
|
1286
|
+
</Show>
|
|
1287
|
+
<text fg={theme.colors.textMuted}>{" โ/โ select ยท Enter confirm ยท Esc cancel"}</text>
|
|
1288
|
+
</box>
|
|
1289
|
+
</Show>
|
|
948
1290
|
<Show when={aiMode() === "url"}>
|
|
949
1291
|
<box flexDirection="column">
|
|
950
|
-
<
|
|
1292
|
+
<Show when={aiProvider()}>
|
|
1293
|
+
<text fg={theme.colors.textMuted}>{" " + aiProvider()!.name + (aiProvider()!.hint ? `: ${aiProvider()!.hint}` : "")}</text>
|
|
1294
|
+
</Show>
|
|
1295
|
+
<box flexDirection="row">
|
|
1296
|
+
<text fg={theme.colors.text}>
|
|
1297
|
+
<span style={{ bold: true }}>{"Endpoint base URL"}</span>
|
|
1298
|
+
<Show when={aiProvider()?.baseURL}>
|
|
1299
|
+
<span>{` [${aiProvider()!.baseURL}]`}</span>
|
|
1300
|
+
</Show>
|
|
1301
|
+
<span>{": "}</span>
|
|
1302
|
+
</text>
|
|
1303
|
+
<text fg={theme.colors.textMuted}>{"(Esc to go back)"}</text>
|
|
1304
|
+
</box>
|
|
951
1305
|
<box flexDirection="row">
|
|
952
1306
|
<text fg={theme.colors.primary}><span style={{ bold: true }}>{"โฏ "}</span></text>
|
|
953
1307
|
<text fg={theme.colors.input}>{aiUrl()}</text>
|
|
@@ -957,8 +1311,16 @@ export function Home(props: {
|
|
|
957
1311
|
</Show>
|
|
958
1312
|
<Show when={aiMode() === "key"}>
|
|
959
1313
|
<box flexDirection="column">
|
|
960
|
-
|
|
961
|
-
|
|
1314
|
+
<Show when={aiProvider()}>
|
|
1315
|
+
<text fg={theme.colors.textMuted}>{" " + aiProvider()!.name + (aiProvider()!.hint ? `: ${aiProvider()!.hint}` : "")}</text>
|
|
1316
|
+
</Show>
|
|
1317
|
+
<Show when={aiUrl().trim()}>
|
|
1318
|
+
<text fg={theme.colors.textMuted}>{" Endpoint: " + aiUrl().trim()}</text>
|
|
1319
|
+
</Show>
|
|
1320
|
+
<box flexDirection="row">
|
|
1321
|
+
<text fg={theme.colors.text}><span style={{ bold: true }}>{"API key / Bearer token: "}</span></text>
|
|
1322
|
+
<text fg={theme.colors.textMuted}>{"(Esc to go back)"}</text>
|
|
1323
|
+
</box>
|
|
962
1324
|
<box flexDirection="row">
|
|
963
1325
|
<text fg={theme.colors.primary}><span style={{ bold: true }}>{"โฏ "}</span></text>
|
|
964
1326
|
<text fg={theme.colors.input}>{mask(aiKey())}</text>
|
|
@@ -967,7 +1329,7 @@ export function Home(props: {
|
|
|
967
1329
|
</box>
|
|
968
1330
|
</Show>
|
|
969
1331
|
<Show when={aiMode() === "testing"}>
|
|
970
|
-
<text fg={theme.colors.primary}>
|
|
1332
|
+
<text fg={theme.colors.primary}>Verifying endpoint...</text>
|
|
971
1333
|
</Show>
|
|
972
1334
|
<Show when={!aiMode()}>
|
|
973
1335
|
<box flexDirection="column">
|
package/src/tui/routes/setup.tsx
CHANGED
|
@@ -93,7 +93,7 @@ export function ThemeSetup(props: { onDone?: () => void }) {
|
|
|
93
93
|
<text fg={i < 3 ? LOGO_ORANGE : LOGO_CYAN}>{line}</text>
|
|
94
94
|
))}
|
|
95
95
|
<box height={1} />
|
|
96
|
-
<text fg={HC.text}>{"
|
|
96
|
+
<text fg={HC.text}>{"Agent Only Coding Society"}</text>
|
|
97
97
|
<box height={1} />
|
|
98
98
|
</box>
|
|
99
99
|
|