codeblog-app 1.1.0 → 1.3.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 +1 -1
- package/src/ai/provider.ts +43 -4
- package/src/cli/cmd/config.ts +17 -5
- package/src/index.ts +15 -1
- package/src/tui/app.tsx +66 -52
- package/src/tui/routes/chat.tsx +38 -17
- package/src/tui/routes/home.tsx +272 -95
package/package.json
CHANGED
package/src/ai/provider.ts
CHANGED
|
@@ -53,7 +53,7 @@ export namespace AIProvider {
|
|
|
53
53
|
// Provider env key mapping
|
|
54
54
|
// ---------------------------------------------------------------------------
|
|
55
55
|
const PROVIDER_ENV: Record<string, string[]> = {
|
|
56
|
-
anthropic: ["ANTHROPIC_API_KEY"],
|
|
56
|
+
anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
57
57
|
openai: ["OPENAI_API_KEY"],
|
|
58
58
|
google: ["GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_API_KEY"],
|
|
59
59
|
"amazon-bedrock": ["AWS_ACCESS_KEY_ID"],
|
|
@@ -70,6 +70,22 @@ export namespace AIProvider {
|
|
|
70
70
|
"openai-compatible": ["OPENAI_COMPATIBLE_API_KEY"],
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Provider base URL env mapping (for third-party API proxies)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
const PROVIDER_BASE_URL_ENV: Record<string, string[]> = {
|
|
77
|
+
anthropic: ["ANTHROPIC_BASE_URL"],
|
|
78
|
+
openai: ["OPENAI_BASE_URL", "OPENAI_API_BASE"],
|
|
79
|
+
google: ["GOOGLE_API_BASE_URL"],
|
|
80
|
+
azure: ["AZURE_OPENAI_BASE_URL"],
|
|
81
|
+
xai: ["XAI_BASE_URL"],
|
|
82
|
+
mistral: ["MISTRAL_BASE_URL"],
|
|
83
|
+
groq: ["GROQ_BASE_URL"],
|
|
84
|
+
deepinfra: ["DEEPINFRA_BASE_URL"],
|
|
85
|
+
openrouter: ["OPENROUTER_BASE_URL"],
|
|
86
|
+
"openai-compatible": ["OPENAI_COMPATIBLE_BASE_URL"],
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
// ---------------------------------------------------------------------------
|
|
74
90
|
// Provider → npm package mapping
|
|
75
91
|
// ---------------------------------------------------------------------------
|
|
@@ -177,6 +193,18 @@ export namespace AIProvider {
|
|
|
177
193
|
return cfg.providers?.[providerID]?.api_key
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Get base URL for a provider (env var or config)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
export async function getBaseUrl(providerID: string): Promise<string | undefined> {
|
|
200
|
+
const envKeys = PROVIDER_BASE_URL_ENV[providerID] || []
|
|
201
|
+
for (const key of envKeys) {
|
|
202
|
+
if (process.env[key]) return process.env[key]
|
|
203
|
+
}
|
|
204
|
+
const cfg = await Config.load()
|
|
205
|
+
return cfg.providers?.[providerID]?.base_url
|
|
206
|
+
}
|
|
207
|
+
|
|
180
208
|
// ---------------------------------------------------------------------------
|
|
181
209
|
// List all available providers with their models
|
|
182
210
|
// ---------------------------------------------------------------------------
|
|
@@ -223,7 +251,8 @@ export namespace AIProvider {
|
|
|
223
251
|
if (builtin) {
|
|
224
252
|
const apiKey = await getApiKey(builtin.providerID)
|
|
225
253
|
if (!apiKey) throw noKeyError(builtin.providerID)
|
|
226
|
-
|
|
254
|
+
const base = await getBaseUrl(builtin.providerID)
|
|
255
|
+
return getLanguageModel(builtin.providerID, id, apiKey, undefined, base)
|
|
227
256
|
}
|
|
228
257
|
|
|
229
258
|
// Try models.dev
|
|
@@ -234,7 +263,8 @@ export namespace AIProvider {
|
|
|
234
263
|
const apiKey = await getApiKey(providerID)
|
|
235
264
|
if (!apiKey) throw noKeyError(providerID)
|
|
236
265
|
const npm = p.models[id].provider?.npm || p.npm || "@ai-sdk/openai-compatible"
|
|
237
|
-
|
|
266
|
+
const base = await getBaseUrl(providerID)
|
|
267
|
+
return getLanguageModel(providerID, id, apiKey, npm, base || p.api)
|
|
238
268
|
}
|
|
239
269
|
}
|
|
240
270
|
|
|
@@ -244,7 +274,8 @@ export namespace AIProvider {
|
|
|
244
274
|
const mid = rest.join("/")
|
|
245
275
|
const apiKey = await getApiKey(providerID)
|
|
246
276
|
if (!apiKey) throw noKeyError(providerID)
|
|
247
|
-
|
|
277
|
+
const base = await getBaseUrl(providerID)
|
|
278
|
+
return getLanguageModel(providerID, mid, apiKey, undefined, base)
|
|
248
279
|
}
|
|
249
280
|
|
|
250
281
|
throw new Error(`Unknown model: ${id}. Run: codeblog config --list`)
|
|
@@ -293,10 +324,18 @@ export namespace AIProvider {
|
|
|
293
324
|
// Check if any AI provider has a key configured
|
|
294
325
|
// ---------------------------------------------------------------------------
|
|
295
326
|
export async function hasAnyKey(): Promise<boolean> {
|
|
327
|
+
// Check env vars
|
|
296
328
|
for (const providerID of Object.keys(PROVIDER_ENV)) {
|
|
297
329
|
const key = await getApiKey(providerID)
|
|
298
330
|
if (key) return true
|
|
299
331
|
}
|
|
332
|
+
// Check config file (covers third-party providers not in PROVIDER_ENV)
|
|
333
|
+
const cfg = await Config.load()
|
|
334
|
+
if (cfg.providers) {
|
|
335
|
+
for (const p of Object.values(cfg.providers)) {
|
|
336
|
+
if (p.api_key) return true
|
|
337
|
+
}
|
|
338
|
+
}
|
|
300
339
|
return false
|
|
301
340
|
}
|
|
302
341
|
|
package/src/cli/cmd/config.ts
CHANGED
|
@@ -36,6 +36,10 @@ export const ConfigCommand: CommandModule = {
|
|
|
36
36
|
type: "boolean",
|
|
37
37
|
default: false,
|
|
38
38
|
})
|
|
39
|
+
.option("base-url", {
|
|
40
|
+
describe: "Custom base URL for the provider (for third-party API proxies)",
|
|
41
|
+
type: "string",
|
|
42
|
+
})
|
|
39
43
|
.option("language", {
|
|
40
44
|
describe: "Default content language for posts (e.g. English, 中文, 日本語)",
|
|
41
45
|
type: "string",
|
|
@@ -80,12 +84,18 @@ export const ConfigCommand: CommandModule = {
|
|
|
80
84
|
return
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
if (args.provider && args.apiKey) {
|
|
87
|
+
if (args.provider && (args.apiKey || args.baseUrl)) {
|
|
84
88
|
const cfg = await Config.load()
|
|
85
89
|
const providers = cfg.providers || {}
|
|
86
|
-
|
|
90
|
+
const existing = providers[args.provider as string] || {} as Config.ProviderConfig
|
|
91
|
+
if (args.apiKey) existing.api_key = args.apiKey as string
|
|
92
|
+
if (args.baseUrl) existing.base_url = args.baseUrl as string
|
|
93
|
+
providers[args.provider as string] = existing
|
|
87
94
|
await Config.save({ providers })
|
|
88
|
-
|
|
95
|
+
const parts: string[] = []
|
|
96
|
+
if (args.apiKey) parts.push("API key")
|
|
97
|
+
if (args.baseUrl) parts.push(`base URL (${args.baseUrl})`)
|
|
98
|
+
UI.success(`${args.provider} ${parts.join(" + ")} saved`)
|
|
89
99
|
return
|
|
90
100
|
}
|
|
91
101
|
|
|
@@ -125,12 +135,14 @@ export const ConfigCommand: CommandModule = {
|
|
|
125
135
|
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}AI Providers${UI.Style.TEXT_NORMAL}`)
|
|
126
136
|
for (const [id, p] of Object.entries(providers)) {
|
|
127
137
|
const masked = p.api_key ? p.api_key.slice(0, 8) + "..." : "not set"
|
|
128
|
-
|
|
138
|
+
const url = p.base_url ? ` → ${p.base_url}` : ""
|
|
139
|
+
console.log(` ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${id}: ${UI.Style.TEXT_DIM}${masked}${url}${UI.Style.TEXT_NORMAL}`)
|
|
129
140
|
}
|
|
130
141
|
} else {
|
|
131
142
|
console.log(` ${UI.Style.TEXT_DIM}No AI providers configured.${UI.Style.TEXT_NORMAL}`)
|
|
132
143
|
console.log(` ${UI.Style.TEXT_DIM}Set one: codeblog config --provider anthropic --api-key sk-...${UI.Style.TEXT_NORMAL}`)
|
|
133
|
-
console.log(` ${UI.Style.TEXT_DIM}
|
|
144
|
+
console.log(` ${UI.Style.TEXT_DIM}Third-party proxy: codeblog config --provider anthropic --api-key sk-... --base-url https://proxy.example.com${UI.Style.TEXT_NORMAL}`)
|
|
145
|
+
console.log(` ${UI.Style.TEXT_DIM}Or use env: ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL${UI.Style.TEXT_NORMAL}`)
|
|
134
146
|
}
|
|
135
147
|
console.log("")
|
|
136
148
|
} catch (err) {
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { WeeklyDigestCommand } from "./cli/cmd/weekly-digest"
|
|
|
35
35
|
import { TagsCommand } from "./cli/cmd/tags"
|
|
36
36
|
import { ExploreCommand } from "./cli/cmd/explore"
|
|
37
37
|
|
|
38
|
-
const VERSION = "1.
|
|
38
|
+
const VERSION = "1.3.0"
|
|
39
39
|
|
|
40
40
|
process.on("unhandledRejection", (e) => {
|
|
41
41
|
Log.Default.error("rejection", {
|
|
@@ -129,6 +129,20 @@ const cli = yargs(hideBin(process.argv))
|
|
|
129
129
|
})
|
|
130
130
|
.strict()
|
|
131
131
|
|
|
132
|
+
// If no subcommand given, launch TUI
|
|
133
|
+
const args = hideBin(process.argv)
|
|
134
|
+
const hasSubcommand = args.length > 0 && !args[0].startsWith("-")
|
|
135
|
+
const isHelp = args.includes("--help") || args.includes("-h")
|
|
136
|
+
const isVersion = args.includes("--version") || args.includes("-v")
|
|
137
|
+
|
|
138
|
+
if (!hasSubcommand && !isHelp && !isVersion) {
|
|
139
|
+
await Log.init({ print: false })
|
|
140
|
+
Log.Default.info("codeblog", { version: VERSION, args: [] })
|
|
141
|
+
const { tui } = await import("./tui/app")
|
|
142
|
+
await tui({ onExit: async () => {} })
|
|
143
|
+
process.exit(0)
|
|
144
|
+
}
|
|
145
|
+
|
|
132
146
|
try {
|
|
133
147
|
await cli.parse()
|
|
134
148
|
} catch (e) {
|
package/src/tui/app.tsx
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|
2
|
-
import { Switch, Match, onMount, createSignal } from "solid-js"
|
|
2
|
+
import { Switch, Match, onMount, createSignal, Show } from "solid-js"
|
|
3
3
|
import { RouteProvider, useRoute } from "./context/route"
|
|
4
4
|
import { ExitProvider, useExit } from "./context/exit"
|
|
5
5
|
import { Home } from "./routes/home"
|
|
6
6
|
import { Chat } from "./routes/chat"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { Post } from "./routes/post"
|
|
10
|
-
import { Notifications } from "./routes/notifications"
|
|
7
|
+
|
|
8
|
+
const VERSION = "1.3.0"
|
|
11
9
|
|
|
12
10
|
export function tui(input: { onExit?: () => Promise<void> }) {
|
|
13
11
|
return new Promise<void>(async (resolve) => {
|
|
@@ -35,12 +33,36 @@ function App() {
|
|
|
35
33
|
const dimensions = useTerminalDimensions()
|
|
36
34
|
const renderer = useRenderer()
|
|
37
35
|
const [loggedIn, setLoggedIn] = createSignal(false)
|
|
36
|
+
const [username, setUsername] = createSignal("")
|
|
37
|
+
const [hasAI, setHasAI] = createSignal(false)
|
|
38
|
+
const [aiProvider, setAiProvider] = createSignal("")
|
|
38
39
|
|
|
39
40
|
onMount(async () => {
|
|
40
41
|
renderer.setTerminalTitle("CodeBlog")
|
|
42
|
+
|
|
43
|
+
// Check auth status
|
|
41
44
|
try {
|
|
42
45
|
const { Auth } = await import("../auth")
|
|
43
|
-
|
|
46
|
+
const authenticated = await Auth.authenticated()
|
|
47
|
+
setLoggedIn(authenticated)
|
|
48
|
+
if (authenticated) {
|
|
49
|
+
const token = await Auth.load()
|
|
50
|
+
if (token?.username) setUsername(token.username)
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
|
|
54
|
+
// Check AI provider status
|
|
55
|
+
try {
|
|
56
|
+
const { AIProvider } = await import("../ai/provider")
|
|
57
|
+
const has = await AIProvider.hasAnyKey()
|
|
58
|
+
setHasAI(has)
|
|
59
|
+
if (has) {
|
|
60
|
+
const { Config } = await import("../config")
|
|
61
|
+
const cfg = await Config.load()
|
|
62
|
+
const model = cfg.model || AIProvider.DEFAULT_MODEL
|
|
63
|
+
const info = AIProvider.BUILTIN_MODELS[model]
|
|
64
|
+
setAiProvider(info?.name || model)
|
|
65
|
+
}
|
|
44
66
|
} catch {}
|
|
45
67
|
})
|
|
46
68
|
|
|
@@ -51,36 +73,16 @@ function App() {
|
|
|
51
73
|
return
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
evt.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
route.navigate({ type: "chat" })
|
|
62
|
-
evt.preventDefault()
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (evt.name === "t" && route.data.type === "home") {
|
|
67
|
-
route.navigate({ type: "trending" })
|
|
68
|
-
evt.preventDefault()
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (evt.name === "s" && route.data.type === "home") {
|
|
73
|
-
route.navigate({ type: "search", query: "" })
|
|
74
|
-
evt.preventDefault()
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (evt.name === "n" && route.data.type === "home") {
|
|
79
|
-
route.navigate({ type: "notifications" })
|
|
80
|
-
evt.preventDefault()
|
|
81
|
-
return
|
|
76
|
+
// Home screen shortcuts
|
|
77
|
+
if (route.data.type === "home") {
|
|
78
|
+
if (evt.name === "q" && !evt.ctrl) {
|
|
79
|
+
exit()
|
|
80
|
+
evt.preventDefault()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// Back navigation
|
|
84
86
|
if (evt.name === "escape" && route.data.type !== "home") {
|
|
85
87
|
route.navigate({ type: "home" })
|
|
86
88
|
evt.preventDefault()
|
|
@@ -89,41 +91,53 @@ function App() {
|
|
|
89
91
|
})
|
|
90
92
|
|
|
91
93
|
return (
|
|
92
|
-
<box flexDirection="column" width=
|
|
94
|
+
<box flexDirection="column" width={dimensions().width} height={dimensions().height}>
|
|
93
95
|
<Switch>
|
|
94
96
|
<Match when={route.data.type === "home"}>
|
|
95
|
-
<Home
|
|
97
|
+
<Home
|
|
98
|
+
loggedIn={loggedIn()}
|
|
99
|
+
username={username()}
|
|
100
|
+
hasAI={hasAI()}
|
|
101
|
+
aiProvider={aiProvider()}
|
|
102
|
+
onLogin={async () => {
|
|
103
|
+
try {
|
|
104
|
+
const { OAuth } = await import("../auth/oauth")
|
|
105
|
+
await OAuth.login("github")
|
|
106
|
+
const { Auth } = await import("../auth")
|
|
107
|
+
setLoggedIn(true)
|
|
108
|
+
const token = await Auth.load()
|
|
109
|
+
if (token?.username) setUsername(token.username)
|
|
110
|
+
} catch {}
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
96
113
|
</Match>
|
|
97
114
|
<Match when={route.data.type === "chat"}>
|
|
98
115
|
<Chat />
|
|
99
116
|
</Match>
|
|
100
|
-
<Match when={route.data.type === "trending"}>
|
|
101
|
-
<Trending />
|
|
102
|
-
</Match>
|
|
103
|
-
<Match when={route.data.type === "notifications"}>
|
|
104
|
-
<Notifications />
|
|
105
|
-
</Match>
|
|
106
|
-
<Match when={route.data.type === "search"}>
|
|
107
|
-
<Search />
|
|
108
|
-
</Match>
|
|
109
|
-
<Match when={route.data.type === "post"}>
|
|
110
|
-
<Post />
|
|
111
|
-
</Match>
|
|
112
117
|
</Switch>
|
|
113
118
|
|
|
114
119
|
{/* Status bar */}
|
|
115
120
|
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexShrink={0} flexDirection="row">
|
|
116
121
|
<text fg="#6a737c">
|
|
117
122
|
{route.data.type === "home"
|
|
118
|
-
? "
|
|
119
|
-
: "esc:back
|
|
123
|
+
? "type to chat · /help · q:quit"
|
|
124
|
+
: "esc:back · ctrl+c:exit"}
|
|
120
125
|
</text>
|
|
121
126
|
<box flexGrow={1} />
|
|
127
|
+
<Show when={hasAI()}>
|
|
128
|
+
<text fg="#48a868">{"● "}</text>
|
|
129
|
+
<text fg="#6a737c">{aiProvider()}</text>
|
|
130
|
+
<text fg="#6a737c">{" "}</text>
|
|
131
|
+
</Show>
|
|
132
|
+
<Show when={!hasAI()}>
|
|
133
|
+
<text fg="#d73a49">{"○ "}</text>
|
|
134
|
+
<text fg="#6a737c">{"no AI "}</text>
|
|
135
|
+
</Show>
|
|
122
136
|
<text fg={loggedIn() ? "#48a868" : "#d73a49"}>
|
|
123
137
|
{loggedIn() ? "● " : "○ "}
|
|
124
138
|
</text>
|
|
125
|
-
<text fg="#6a737c">{loggedIn() ? "logged in" : "not logged in"}</text>
|
|
126
|
-
<text fg="#6a737c">{
|
|
139
|
+
<text fg="#6a737c">{loggedIn() ? username() || "logged in" : "not logged in"}</text>
|
|
140
|
+
<text fg="#6a737c">{` v${VERSION}`}</text>
|
|
127
141
|
</box>
|
|
128
142
|
</box>
|
|
129
143
|
)
|
package/src/tui/routes/chat.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSignal, For, Show } from "solid-js"
|
|
1
|
+
import { createSignal, For, Show, onMount } from "solid-js"
|
|
2
2
|
import { useKeyboard } from "@opentui/solid"
|
|
3
3
|
import { useRoute } from "../context/route"
|
|
4
4
|
|
|
@@ -13,18 +13,34 @@ export function Chat() {
|
|
|
13
13
|
const [streaming, setStreaming] = createSignal(false)
|
|
14
14
|
const [streamText, setStreamText] = createSignal("")
|
|
15
15
|
const [model, setModel] = createSignal("")
|
|
16
|
-
|
|
17
|
-
// Load configured model on mount
|
|
18
|
-
import("../../config").then(({ Config }) =>
|
|
19
|
-
Config.load().then((cfg) => {
|
|
20
|
-
if (cfg.model) setModel(cfg.model)
|
|
21
|
-
}).catch(() => {}),
|
|
22
|
-
)
|
|
16
|
+
const [modelName, setModelName] = createSignal("")
|
|
23
17
|
const [inputBuf, setInputBuf] = createSignal("")
|
|
24
|
-
|
|
18
|
+
|
|
19
|
+
onMount(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const { Config } = await import("../../config")
|
|
22
|
+
const { AIProvider } = await import("../../ai/provider")
|
|
23
|
+
const cfg = await Config.load()
|
|
24
|
+
const id = cfg.model || AIProvider.DEFAULT_MODEL
|
|
25
|
+
setModel(id)
|
|
26
|
+
const info = AIProvider.BUILTIN_MODELS[id]
|
|
27
|
+
setModelName(info?.name || id)
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
// Auto-send initial message from home screen
|
|
31
|
+
const data = route.data as any
|
|
32
|
+
if (data.sessionMessages?.length > 0) {
|
|
33
|
+
for (const msg of data.sessionMessages) {
|
|
34
|
+
if (msg.role === "user") {
|
|
35
|
+
send(msg.content)
|
|
36
|
+
break
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
})
|
|
25
41
|
|
|
26
42
|
async function send(text: string) {
|
|
27
|
-
if (!text.trim()) return
|
|
43
|
+
if (!text.trim() || streaming()) return
|
|
28
44
|
const userMsg: Message = { role: "user", content: text.trim() }
|
|
29
45
|
const prev = messages()
|
|
30
46
|
setMessages([...prev, userMsg])
|
|
@@ -53,6 +69,7 @@ export function Chat() {
|
|
|
53
69
|
},
|
|
54
70
|
onError: (err) => {
|
|
55
71
|
setMessages((p) => [...p, { role: "assistant", content: `Error: ${err.message}` }])
|
|
72
|
+
setStreamText("")
|
|
56
73
|
setStreaming(false)
|
|
57
74
|
},
|
|
58
75
|
},
|
|
@@ -61,6 +78,7 @@ export function Chat() {
|
|
|
61
78
|
} catch (err) {
|
|
62
79
|
const msg = err instanceof Error ? err.message : String(err)
|
|
63
80
|
setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
|
|
81
|
+
setStreamText("")
|
|
64
82
|
setStreaming(false)
|
|
65
83
|
}
|
|
66
84
|
}
|
|
@@ -78,10 +96,14 @@ export function Chat() {
|
|
|
78
96
|
if (name === "/model") {
|
|
79
97
|
const id = parts[1]
|
|
80
98
|
if (!id) {
|
|
81
|
-
setMessages((p) => [...p, { role: "assistant", content: `Current model: ${model()}\nUsage: /model <model-id>` }])
|
|
99
|
+
setMessages((p) => [...p, { role: "assistant", content: `Current model: ${modelName()} (${model()})\nUsage: /model <model-id>` }])
|
|
82
100
|
return
|
|
83
101
|
}
|
|
84
102
|
setModel(id)
|
|
103
|
+
import("../../ai/provider").then(({ AIProvider }) => {
|
|
104
|
+
const info = AIProvider.BUILTIN_MODELS[id]
|
|
105
|
+
setModelName(info?.name || id)
|
|
106
|
+
}).catch(() => setModelName(id))
|
|
85
107
|
setMessages((p) => [...p, { role: "assistant", content: `Switched to model: ${id}` }])
|
|
86
108
|
return
|
|
87
109
|
}
|
|
@@ -102,14 +124,13 @@ export function Chat() {
|
|
|
102
124
|
return
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
setMessages((p) => [...p, { role: "assistant", content: `Unknown command: ${name}. Type /help
|
|
127
|
+
setMessages((p) => [...p, { role: "assistant", content: `Unknown command: ${name}. Type /help` }])
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
useKeyboard((evt) => {
|
|
109
|
-
if (!inputMode()) return
|
|
110
|
-
|
|
111
131
|
if (evt.name === "return" && !evt.shift) {
|
|
112
132
|
const text = inputBuf().trim()
|
|
133
|
+
if (!text) return
|
|
113
134
|
setInputBuf("")
|
|
114
135
|
if (text.startsWith("/")) {
|
|
115
136
|
handleCommand(text)
|
|
@@ -143,12 +164,12 @@ export function Chat() {
|
|
|
143
164
|
<box flexDirection="column" flexGrow={1}>
|
|
144
165
|
{/* Header */}
|
|
145
166
|
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0} flexDirection="row" gap={1}>
|
|
146
|
-
<text fg="#
|
|
167
|
+
<text fg="#0074cc">
|
|
147
168
|
<span style={{ bold: true }}>AI Chat</span>
|
|
148
169
|
</text>
|
|
149
|
-
<text fg="#6a737c">{
|
|
170
|
+
<text fg="#6a737c">{modelName()}</text>
|
|
150
171
|
<box flexGrow={1} />
|
|
151
|
-
<text fg="#6a737c">esc:back
|
|
172
|
+
<text fg="#6a737c">esc:back · /help · /model · /clear</text>
|
|
152
173
|
</box>
|
|
153
174
|
|
|
154
175
|
{/* Messages */}
|
package/src/tui/routes/home.tsx
CHANGED
|
@@ -1,125 +1,302 @@
|
|
|
1
|
-
import { createSignal,
|
|
1
|
+
import { createSignal, Show } from "solid-js"
|
|
2
2
|
import { useKeyboard } from "@opentui/solid"
|
|
3
|
-
import { useRoute
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
import { useRoute } from "../context/route"
|
|
4
|
+
import { useExit } from "../context/exit"
|
|
5
|
+
|
|
6
|
+
const LOGO = [
|
|
7
|
+
" ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗ ██████╗ ██████╗ ",
|
|
8
|
+
" ██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║ ██╔═══██╗██╔════╝ ",
|
|
9
|
+
" ██║ ██║ ██║██║ ██║█████╗ ██████╔╝██║ ██║ ██║██║ ███╗",
|
|
10
|
+
" ██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗██║ ██║ ██║██║ ██║",
|
|
11
|
+
" ╚██████╗╚██████╔╝██████╔╝███████╗██████╔╝███████╗╚██████╔╝╚██████╔╝",
|
|
12
|
+
" ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const HELP_TEXT = [
|
|
16
|
+
"Commands:",
|
|
17
|
+
" /login Log in with GitHub or Google",
|
|
18
|
+
" /config Show current configuration",
|
|
19
|
+
" /config ai Configure AI provider (interactive)",
|
|
20
|
+
" /scan Scan IDE sessions",
|
|
21
|
+
" /publish Publish scanned sessions",
|
|
22
|
+
" /ai-publish AI writes a post from your session",
|
|
23
|
+
" /feed Browse recent posts",
|
|
24
|
+
" /search <query> Search posts",
|
|
25
|
+
" /trending View trending topics",
|
|
26
|
+
" /notifications View notifications",
|
|
27
|
+
" /dashboard Your stats",
|
|
28
|
+
" /models List available AI models",
|
|
29
|
+
" /help Show this help",
|
|
30
|
+
" /exit Exit",
|
|
31
|
+
"",
|
|
32
|
+
"Or just type anything to chat with AI.",
|
|
33
|
+
]
|
|
16
34
|
|
|
17
|
-
export function Home(
|
|
35
|
+
export function Home(props: {
|
|
36
|
+
loggedIn: boolean
|
|
37
|
+
username: string
|
|
38
|
+
hasAI: boolean
|
|
39
|
+
aiProvider: string
|
|
40
|
+
onLogin: () => Promise<void>
|
|
41
|
+
}) {
|
|
18
42
|
const route = useRoute()
|
|
19
|
-
const
|
|
20
|
-
const [
|
|
21
|
-
const [
|
|
22
|
-
|
|
23
|
-
const [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
const exit = useExit()
|
|
44
|
+
const [input, setInput] = createSignal("")
|
|
45
|
+
const [message, setMessage] = createSignal("")
|
|
46
|
+
const [messageColor, setMessageColor] = createSignal("#6a737c")
|
|
47
|
+
const [showHelp, setShowHelp] = createSignal(false)
|
|
48
|
+
|
|
49
|
+
function showMsg(text: string, color = "#6a737c") {
|
|
50
|
+
setMessage(text)
|
|
51
|
+
setMessageColor(color)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function handleSubmit() {
|
|
55
|
+
const text = input().trim()
|
|
56
|
+
setInput("")
|
|
57
|
+
if (!text) return
|
|
58
|
+
|
|
59
|
+
// Handle commands
|
|
60
|
+
if (text.startsWith("/")) {
|
|
61
|
+
const parts = text.split(/\s+/)
|
|
62
|
+
const cmd = parts[0]
|
|
63
|
+
|
|
64
|
+
if (cmd === "/help") {
|
|
65
|
+
setShowHelp(!showHelp())
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cmd === "/exit" || cmd === "/quit" || cmd === "/q") {
|
|
70
|
+
exit()
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (cmd === "/login") {
|
|
75
|
+
showMsg("Opening browser for login...", "#0074cc")
|
|
76
|
+
await props.onLogin()
|
|
77
|
+
showMsg("Logged in!", "#48a868")
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (cmd === "/config") {
|
|
82
|
+
if (parts[1] === "ai") {
|
|
83
|
+
showMsg("Use CLI: codeblog config --provider anthropic --api-key sk-...", "#f48225")
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const { Config } = await import("../../config")
|
|
88
|
+
const cfg = await Config.load()
|
|
89
|
+
const providers = cfg.providers || {}
|
|
90
|
+
const keys = Object.keys(providers)
|
|
91
|
+
const model = cfg.model || "claude-sonnet-4-20250514"
|
|
92
|
+
showMsg(`Model: ${model} | Providers: ${keys.length > 0 ? keys.join(", ") : "none"} | URL: ${cfg.api_url || "https://codeblog.ai"}`, "#e7e9eb")
|
|
93
|
+
} catch {
|
|
94
|
+
showMsg("Failed to load config", "#d73a49")
|
|
95
|
+
}
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (cmd === "/scan") {
|
|
100
|
+
showMsg("Scanning IDE sessions...", "#0074cc")
|
|
101
|
+
try {
|
|
102
|
+
const { registerAllScanners, scanAll } = await import("../../scanner")
|
|
103
|
+
registerAllScanners()
|
|
104
|
+
const sessions = scanAll(10)
|
|
105
|
+
if (sessions.length === 0) {
|
|
106
|
+
showMsg("No IDE sessions found.", "#f48225")
|
|
107
|
+
} else {
|
|
108
|
+
const summary = sessions.slice(0, 3).map((s) => `[${s.source}] ${s.project}`).join(" | ")
|
|
109
|
+
showMsg(`Found ${sessions.length} sessions: ${summary}`, "#48a868")
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
showMsg(`Scan failed: ${err instanceof Error ? err.message : String(err)}`, "#d73a49")
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (cmd === "/publish") {
|
|
118
|
+
showMsg("Publishing sessions...", "#0074cc")
|
|
119
|
+
try {
|
|
120
|
+
const { Publisher } = await import("../../publisher")
|
|
121
|
+
const results = await Publisher.scanAndPublish({ limit: 1 })
|
|
122
|
+
const ok = results.filter((r) => r.postId)
|
|
123
|
+
if (ok.length > 0) {
|
|
124
|
+
showMsg(`Published ${ok.length} post(s)!`, "#48a868")
|
|
125
|
+
} else {
|
|
126
|
+
showMsg("No sessions to publish.", "#f48225")
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
showMsg(`Publish failed: ${err instanceof Error ? err.message : String(err)}`, "#d73a49")
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (cmd === "/ai-publish") {
|
|
135
|
+
if (!props.hasAI) {
|
|
136
|
+
showMsg("No AI configured. Use: /config ai", "#d73a49")
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
showMsg("AI is writing a post from your session...", "#0074cc")
|
|
140
|
+
// Delegate to CLI command for now
|
|
141
|
+
showMsg("Use CLI: codeblog ai-publish", "#f48225")
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (cmd === "/feed") {
|
|
146
|
+
showMsg("Loading feed...", "#0074cc")
|
|
147
|
+
try {
|
|
148
|
+
const { Feed } = await import("../../api/feed")
|
|
149
|
+
const result = await Feed.list()
|
|
150
|
+
const posts = (result as any).posts || []
|
|
151
|
+
if (posts.length === 0) {
|
|
152
|
+
showMsg("No posts yet.", "#f48225")
|
|
153
|
+
} else {
|
|
154
|
+
const summary = posts.slice(0, 3).map((p: any) => p.title?.slice(0, 40)).join(" | ")
|
|
155
|
+
showMsg(`${posts.length} posts: ${summary}`, "#e7e9eb")
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
showMsg(`Feed failed: ${err instanceof Error ? err.message : String(err)}`, "#d73a49")
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (cmd === "/models") {
|
|
164
|
+
try {
|
|
165
|
+
const { AIProvider } = await import("../../ai/provider")
|
|
166
|
+
const models = await AIProvider.available()
|
|
167
|
+
const configured = models.filter((m) => m.hasKey)
|
|
168
|
+
const names = configured.map((m) => m.model.name).join(", ")
|
|
169
|
+
showMsg(configured.length > 0 ? `Available: ${names}` : "No models configured. Use: codeblog config --provider anthropic --api-key sk-...", configured.length > 0 ? "#48a868" : "#f48225")
|
|
170
|
+
} catch (err) {
|
|
171
|
+
showMsg(`Failed: ${err instanceof Error ? err.message : String(err)}`, "#d73a49")
|
|
172
|
+
}
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (cmd === "/search") {
|
|
177
|
+
const query = parts.slice(1).join(" ")
|
|
178
|
+
if (!query) {
|
|
179
|
+
showMsg("Usage: /search <query>", "#f48225")
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const { Posts } = await import("../../api/posts")
|
|
184
|
+
const result = await Posts.search(query)
|
|
185
|
+
const posts = (result as any).posts || []
|
|
186
|
+
showMsg(posts.length > 0 ? `${posts.length} results for "${query}"` : `No results for "${query}"`, posts.length > 0 ? "#48a868" : "#f48225")
|
|
187
|
+
} catch (err) {
|
|
188
|
+
showMsg(`Search failed: ${err instanceof Error ? err.message : String(err)}`, "#d73a49")
|
|
189
|
+
}
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (cmd === "/trending" || cmd === "/notifications" || cmd === "/dashboard") {
|
|
194
|
+
showMsg(`Use CLI: codeblog ${cmd.slice(1)}`, "#f48225")
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
showMsg(`Unknown command: ${cmd}. Type /help`, "#d73a49")
|
|
199
|
+
return
|
|
34
200
|
}
|
|
35
|
-
|
|
36
|
-
|
|
201
|
+
|
|
202
|
+
// Regular text → start AI chat
|
|
203
|
+
if (!props.hasAI) {
|
|
204
|
+
showMsg("No AI provider configured. Run: /config ai", "#d73a49")
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
route.navigate({ type: "chat", sessionMessages: [{ role: "user", content: text }] })
|
|
209
|
+
}
|
|
37
210
|
|
|
38
211
|
useKeyboard((evt) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
setSelected((s) => Math.max(0, s - 1))
|
|
212
|
+
if (evt.name === "return" && !evt.shift) {
|
|
213
|
+
handleSubmit()
|
|
42
214
|
evt.preventDefault()
|
|
215
|
+
return
|
|
43
216
|
}
|
|
44
|
-
|
|
45
|
-
|
|
217
|
+
|
|
218
|
+
if (evt.name === "backspace") {
|
|
219
|
+
setInput((s) => s.slice(0, -1))
|
|
46
220
|
evt.preventDefault()
|
|
221
|
+
return
|
|
47
222
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
223
|
+
|
|
224
|
+
if (evt.sequence && evt.sequence.length === 1 && !evt.ctrl && !evt.meta) {
|
|
225
|
+
setInput((s) => s + evt.sequence)
|
|
51
226
|
evt.preventDefault()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (evt.name === "space") {
|
|
231
|
+
setInput((s) => s + " ")
|
|
232
|
+
evt.preventDefault()
|
|
233
|
+
return
|
|
52
234
|
}
|
|
53
235
|
})
|
|
54
236
|
|
|
55
237
|
return (
|
|
56
|
-
<box flexDirection="column" flexGrow={1}>
|
|
57
|
-
{/*
|
|
58
|
-
<box
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
238
|
+
<box flexDirection="column" flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
|
239
|
+
{/* Top spacer */}
|
|
240
|
+
<box flexGrow={1} minHeight={0} />
|
|
241
|
+
|
|
242
|
+
{/* Logo */}
|
|
243
|
+
<box flexShrink={0} flexDirection="column">
|
|
244
|
+
{LOGO.map((line, i) => (
|
|
245
|
+
<text fg={i < 4 ? "#f48225" : "#0074cc"}>{line}</text>
|
|
246
|
+
))}
|
|
63
247
|
</box>
|
|
64
248
|
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
<text fg="#f48225">
|
|
68
|
-
<span style={{ bold: true }}>Recent Posts</span>
|
|
69
|
-
</text>
|
|
70
|
-
<text fg="#6a737c">{` (${posts().length})`}</text>
|
|
249
|
+
<box height={1} flexShrink={0}>
|
|
250
|
+
<text fg="#6a737c">The AI-powered coding forum</text>
|
|
71
251
|
</box>
|
|
72
252
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
253
|
+
{/* Status indicators */}
|
|
254
|
+
<box height={2} flexShrink={0} flexDirection="column" paddingTop={1}>
|
|
255
|
+
<box flexDirection="row" gap={2}>
|
|
256
|
+
<Show when={!props.loggedIn}>
|
|
257
|
+
<text fg="#d73a49">○ Not logged in — type /login</text>
|
|
258
|
+
</Show>
|
|
259
|
+
<Show when={props.loggedIn}>
|
|
260
|
+
<text fg="#48a868">● {props.username || "Logged in"}</text>
|
|
261
|
+
</Show>
|
|
262
|
+
<Show when={!props.hasAI}>
|
|
263
|
+
<text fg="#d73a49">○ No AI — type /config ai</text>
|
|
264
|
+
</Show>
|
|
265
|
+
<Show when={props.hasAI}>
|
|
266
|
+
<text fg="#48a868">● {props.aiProvider}</text>
|
|
267
|
+
</Show>
|
|
76
268
|
</box>
|
|
77
|
-
</
|
|
269
|
+
</box>
|
|
78
270
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
271
|
+
{/* Input prompt */}
|
|
272
|
+
<box width="100%" maxWidth={75} flexShrink={0} paddingTop={1}>
|
|
273
|
+
<box flexDirection="row" width="100%">
|
|
274
|
+
<text fg="#0074cc">
|
|
275
|
+
<span style={{ bold: true }}>{"❯ "}</span>
|
|
276
|
+
</text>
|
|
277
|
+
<text fg="#e7e9eb">{input()}</text>
|
|
278
|
+
<text fg="#6a737c">{"█"}</text>
|
|
279
|
+
</box>
|
|
280
|
+
</box>
|
|
281
|
+
|
|
282
|
+
{/* Message area */}
|
|
283
|
+
<Show when={message()}>
|
|
284
|
+
<box width="100%" maxWidth={75} paddingTop={1} flexShrink={0}>
|
|
285
|
+
<text fg={messageColor()}>{message()}</text>
|
|
82
286
|
</box>
|
|
83
287
|
</Show>
|
|
84
288
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
289
|
+
{/* Help text */}
|
|
290
|
+
<Show when={showHelp()}>
|
|
291
|
+
<box width="100%" maxWidth={75} paddingTop={1} flexShrink={0} flexDirection="column">
|
|
292
|
+
{HELP_TEXT.map((line) => (
|
|
293
|
+
<text fg={line.startsWith(" /") ? "#0074cc" : "#6a737c"}>{line}</text>
|
|
294
|
+
))}
|
|
88
295
|
</box>
|
|
89
296
|
</Show>
|
|
90
297
|
|
|
91
|
-
{/*
|
|
92
|
-
<box
|
|
93
|
-
<For each={posts()}>
|
|
94
|
-
{(post, i) => {
|
|
95
|
-
const score = post.upvotes - post.downvotes
|
|
96
|
-
const isSelected = () => i() === selected()
|
|
97
|
-
return (
|
|
98
|
-
<box flexDirection="row" paddingLeft={2} paddingRight={2}>
|
|
99
|
-
{/* Score */}
|
|
100
|
-
<box width={6} justifyContent="flex-end" marginRight={1}>
|
|
101
|
-
<text fg={score > 0 ? "#48a868" : score < 0 ? "#d73a49" : "#6a737c"}>
|
|
102
|
-
{score > 0 ? `+${score}` : `${score}`}
|
|
103
|
-
</text>
|
|
104
|
-
</box>
|
|
105
|
-
{/* Content */}
|
|
106
|
-
<box flexDirection="column" flexGrow={1}>
|
|
107
|
-
<text fg={isSelected() ? "#0074cc" : "#e7e9eb"}>
|
|
108
|
-
<span style={{ bold: isSelected() }}>{isSelected() ? "▸ " : " "}{post.title}</span>
|
|
109
|
-
</text>
|
|
110
|
-
<box flexDirection="row" gap={1}>
|
|
111
|
-
<text fg="#6a737c">{`💬${post.comment_count} 👁${post.views}`}</text>
|
|
112
|
-
<For each={(post.tags || []).slice(0, 3)}>
|
|
113
|
-
{(tag) => <text fg="#39739d">{`#${tag}`}</text>}
|
|
114
|
-
</For>
|
|
115
|
-
<text fg="#838c95">{`by ${post.agent || "anon"}`}</text>
|
|
116
|
-
</box>
|
|
117
|
-
</box>
|
|
118
|
-
</box>
|
|
119
|
-
)
|
|
120
|
-
}}
|
|
121
|
-
</For>
|
|
122
|
-
</box>
|
|
298
|
+
{/* Bottom spacer */}
|
|
299
|
+
<box flexGrow={1} minHeight={0} />
|
|
123
300
|
</box>
|
|
124
301
|
)
|
|
125
302
|
}
|