codeblog-app 1.5.2 → 1.6.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/chat.ts +54 -20
- package/src/ai/configure.ts +89 -0
- package/src/ai/provider.ts +117 -14
- package/src/ai/tools.ts +556 -0
- package/src/auth/oauth.ts +22 -6
- package/src/scanner/cursor.ts +8 -2
- package/src/tui/app.tsx +57 -50
- package/src/tui/commands.ts +126 -0
- package/src/tui/context/route.tsx +2 -2
- package/src/tui/context/theme.tsx +1 -1
- package/src/tui/routes/home.tsx +381 -240
- package/src/tui/routes/model.tsx +209 -0
- package/src/tui/routes/chat.tsx +0 -210
package/package.json
CHANGED
package/src/ai/chat.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import { streamText, type CoreMessage } from "ai"
|
|
2
2
|
import { AIProvider } from "./provider"
|
|
3
|
+
import { chatTools } from "./tools"
|
|
3
4
|
import { Log } from "../util/log"
|
|
4
5
|
|
|
5
6
|
const log = Log.create({ service: "ai-chat" })
|
|
6
7
|
|
|
8
|
+
const SYSTEM_PROMPT = `You are CodeBlog AI — an assistant for the CodeBlog developer forum (codeblog.ai).
|
|
9
|
+
|
|
10
|
+
You help developers with everything on the platform:
|
|
11
|
+
- Scan and analyze their local IDE coding sessions
|
|
12
|
+
- Write and publish blog posts from coding sessions
|
|
13
|
+
- Browse, search, read, comment, vote on forum posts
|
|
14
|
+
- Manage bookmarks, notifications, debates, tags, trending topics
|
|
15
|
+
- Manage agents, view dashboard, follow users
|
|
16
|
+
- Generate weekly digests
|
|
17
|
+
|
|
18
|
+
You have 20+ tools. Use them whenever the user's request matches. Chain multiple tools if needed.
|
|
19
|
+
After a tool returns results, summarize them naturally for the user.
|
|
20
|
+
|
|
21
|
+
Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
|
|
22
|
+
Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
|
|
23
|
+
|
|
7
24
|
export namespace AIChat {
|
|
8
25
|
export interface Message {
|
|
9
26
|
role: "user" | "assistant" | "system"
|
|
@@ -14,23 +31,12 @@ export namespace AIChat {
|
|
|
14
31
|
onToken?: (token: string) => void
|
|
15
32
|
onFinish?: (text: string) => void
|
|
16
33
|
onError?: (error: Error) => void
|
|
34
|
+
onToolCall?: (name: string, args: unknown) => void
|
|
35
|
+
onToolResult?: (name: string, result: unknown) => void
|
|
17
36
|
}
|
|
18
37
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
You help developers:
|
|
22
|
-
- Write engaging blog posts from their coding sessions
|
|
23
|
-
- Analyze code and explain technical concepts
|
|
24
|
-
- Draft comments and debate arguments
|
|
25
|
-
- Summarize posts and discussions
|
|
26
|
-
- Generate tags and titles for posts
|
|
27
|
-
|
|
28
|
-
Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
|
|
29
|
-
Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
|
|
30
|
-
|
|
31
|
-
export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string) {
|
|
38
|
+
export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
|
|
32
39
|
const model = await AIProvider.getModel(modelID)
|
|
33
|
-
|
|
34
40
|
log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
|
|
35
41
|
|
|
36
42
|
const coreMessages: CoreMessage[] = messages.map((m) => ({
|
|
@@ -42,15 +48,43 @@ Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a co
|
|
|
42
48
|
model,
|
|
43
49
|
system: SYSTEM_PROMPT,
|
|
44
50
|
messages: coreMessages,
|
|
51
|
+
tools: chatTools,
|
|
52
|
+
maxSteps: 5,
|
|
53
|
+
abortSignal: signal,
|
|
45
54
|
})
|
|
46
55
|
|
|
47
56
|
let full = ""
|
|
48
57
|
try {
|
|
49
|
-
for await (const
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
for await (const part of result.fullStream) {
|
|
59
|
+
switch (part.type) {
|
|
60
|
+
case "text-delta": {
|
|
61
|
+
// AI SDK v6 uses .text, older versions use .textDelta
|
|
62
|
+
const delta = (part as any).text ?? (part as any).textDelta ?? ""
|
|
63
|
+
if (delta) {
|
|
64
|
+
full += delta
|
|
65
|
+
callbacks.onToken?.(delta)
|
|
66
|
+
}
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
case "tool-call":
|
|
70
|
+
callbacks.onToolCall?.(part.toolName, part.args)
|
|
71
|
+
break
|
|
72
|
+
case "tool-result":
|
|
73
|
+
callbacks.onToolResult?.((part as any).toolName, (part as any).result ?? {})
|
|
74
|
+
break
|
|
75
|
+
case "error": {
|
|
76
|
+
const msg = part.error instanceof Error ? part.error.message : String(part.error)
|
|
77
|
+
log.error("stream part error", { error: msg })
|
|
78
|
+
callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// If fullStream text-delta didn't capture text, try result.text as fallback
|
|
84
|
+
if (!full.trim()) {
|
|
85
|
+
try { full = await result.text } catch {}
|
|
52
86
|
}
|
|
53
|
-
callbacks.onFinish?.(full)
|
|
87
|
+
callbacks.onFinish?.(full || "(No response)")
|
|
54
88
|
} catch (err) {
|
|
55
89
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
56
90
|
log.error("stream error", { error: error.message })
|
|
@@ -63,13 +97,13 @@ Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a co
|
|
|
63
97
|
return full
|
|
64
98
|
}
|
|
65
99
|
|
|
66
|
-
export async function generate(prompt: string, modelID?: string)
|
|
100
|
+
export async function generate(prompt: string, modelID?: string) {
|
|
67
101
|
let result = ""
|
|
68
102
|
await stream([{ role: "user", content: prompt }], { onFinish: (text) => (result = text) }, modelID)
|
|
69
103
|
return result
|
|
70
104
|
}
|
|
71
105
|
|
|
72
|
-
export async function analyzeAndPost(sessionContent: string, modelID?: string)
|
|
106
|
+
export async function analyzeAndPost(sessionContent: string, modelID?: string) {
|
|
73
107
|
const prompt = `Analyze this coding session and write a blog post about it.
|
|
74
108
|
|
|
75
109
|
The post should:
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// AI provider auto-detection and configuration
|
|
2
|
+
|
|
3
|
+
function looksLikeApi(r: Response) {
|
|
4
|
+
const ct = r.headers.get("content-type") || ""
|
|
5
|
+
return ct.includes("json") || ct.includes("text/plain")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function probe(base: string, key: string): Promise<"openai" | "anthropic" | null> {
|
|
9
|
+
const clean = base.replace(/\/+$/, "")
|
|
10
|
+
try {
|
|
11
|
+
const r = await fetch(`${clean}/v1/models`, {
|
|
12
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
13
|
+
signal: AbortSignal.timeout(8000),
|
|
14
|
+
})
|
|
15
|
+
if (r.ok || ((r.status === 401 || r.status === 403) && looksLikeApi(r))) return "openai"
|
|
16
|
+
} catch {}
|
|
17
|
+
try {
|
|
18
|
+
const r = await fetch(`${clean}/v1/messages`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
|
21
|
+
body: JSON.stringify({ model: "test", max_tokens: 1, messages: [] }),
|
|
22
|
+
signal: AbortSignal.timeout(8000),
|
|
23
|
+
})
|
|
24
|
+
if (r.status !== 404 && looksLikeApi(r)) return "anthropic"
|
|
25
|
+
} catch {}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const KEY_PREFIX_MAP: Record<string, string> = {
|
|
30
|
+
"sk-ant-": "anthropic",
|
|
31
|
+
"AIza": "google",
|
|
32
|
+
"xai-": "xai",
|
|
33
|
+
"gsk_": "groq",
|
|
34
|
+
"sk-or-": "openrouter",
|
|
35
|
+
"pplx-": "perplexity",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ENV_MAP: Record<string, string> = {
|
|
39
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
40
|
+
openai: "OPENAI_API_KEY",
|
|
41
|
+
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
42
|
+
xai: "XAI_API_KEY",
|
|
43
|
+
groq: "GROQ_API_KEY",
|
|
44
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
45
|
+
perplexity: "PERPLEXITY_API_KEY",
|
|
46
|
+
"openai-compatible": "OPENAI_COMPATIBLE_API_KEY",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function detectProvider(key: string) {
|
|
50
|
+
for (const [prefix, provider] of Object.entries(KEY_PREFIX_MAP)) {
|
|
51
|
+
if (key.startsWith(prefix)) return provider
|
|
52
|
+
}
|
|
53
|
+
return "openai"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function saveProvider(url: string, key: string): Promise<{ provider: string; error?: string }> {
|
|
57
|
+
const { Config } = await import("../config")
|
|
58
|
+
|
|
59
|
+
if (url) {
|
|
60
|
+
const detected = await probe(url, key)
|
|
61
|
+
if (!detected) return { provider: "", error: "Could not connect. Check URL and key." }
|
|
62
|
+
|
|
63
|
+
const provider = detected === "anthropic" ? "anthropic" : "openai-compatible"
|
|
64
|
+
const envKey = detected === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_COMPATIBLE_API_KEY"
|
|
65
|
+
const envBase = detected === "anthropic" ? "ANTHROPIC_BASE_URL" : "OPENAI_COMPATIBLE_BASE_URL"
|
|
66
|
+
process.env[envKey] = key
|
|
67
|
+
process.env[envBase] = url
|
|
68
|
+
|
|
69
|
+
const cfg = await Config.load()
|
|
70
|
+
const providers = cfg.providers || {}
|
|
71
|
+
providers[provider] = { api_key: key, base_url: url }
|
|
72
|
+
await Config.save({ providers })
|
|
73
|
+
return { provider: `${detected} format` }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const provider = detectProvider(key)
|
|
77
|
+
if (ENV_MAP[provider]) process.env[ENV_MAP[provider]] = key
|
|
78
|
+
|
|
79
|
+
const cfg = await Config.load()
|
|
80
|
+
const providers = cfg.providers || {}
|
|
81
|
+
providers[provider] = { api_key: key }
|
|
82
|
+
await Config.save({ providers })
|
|
83
|
+
return { provider }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function mask(s: string) {
|
|
87
|
+
if (s.length <= 8) return s
|
|
88
|
+
return s.slice(0, 4) + "\u2022".repeat(Math.min(s.length - 8, 20)) + s.slice(-4)
|
|
89
|
+
}
|
package/src/ai/provider.ts
CHANGED
|
@@ -173,14 +173,6 @@ export namespace AIProvider {
|
|
|
173
173
|
return {}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// Refresh models.dev in background (lazy, only on first use)
|
|
177
|
-
let modelsDevInitialized = false
|
|
178
|
-
function ensureModelsDev() {
|
|
179
|
-
if (modelsDevInitialized) return
|
|
180
|
-
modelsDevInitialized = true
|
|
181
|
-
fetchModelsDev().catch(() => {})
|
|
182
|
-
}
|
|
183
|
-
|
|
184
176
|
// ---------------------------------------------------------------------------
|
|
185
177
|
// Get API key for a provider
|
|
186
178
|
// ---------------------------------------------------------------------------
|
|
@@ -255,13 +247,13 @@ export namespace AIProvider {
|
|
|
255
247
|
return getLanguageModel(builtin.providerID, id, apiKey, undefined, base)
|
|
256
248
|
}
|
|
257
249
|
|
|
258
|
-
// Try models.dev
|
|
250
|
+
// Try models.dev (only if the user has a key for that provider)
|
|
259
251
|
const modelsDev = await fetchModelsDev()
|
|
260
252
|
for (const [providerID, provider] of Object.entries(modelsDev)) {
|
|
261
253
|
const p = provider as any
|
|
262
254
|
if (p.models?.[id]) {
|
|
263
255
|
const apiKey = await getApiKey(providerID)
|
|
264
|
-
if (!apiKey)
|
|
256
|
+
if (!apiKey) continue
|
|
265
257
|
const npm = p.models[id].provider?.npm || p.npm || "@ai-sdk/openai-compatible"
|
|
266
258
|
const base = await getBaseUrl(providerID)
|
|
267
259
|
return getLanguageModel(providerID, id, apiKey, npm, base || p.api)
|
|
@@ -278,6 +270,19 @@ export namespace AIProvider {
|
|
|
278
270
|
return getLanguageModel(providerID, mid, apiKey, undefined, base)
|
|
279
271
|
}
|
|
280
272
|
|
|
273
|
+
// Fallback: try any configured provider that has a base_url (custom/openai-compatible)
|
|
274
|
+
const cfg = await Config.load()
|
|
275
|
+
if (cfg.providers) {
|
|
276
|
+
for (const [providerID, p] of Object.entries(cfg.providers)) {
|
|
277
|
+
if (!p.api_key) continue
|
|
278
|
+
const base = p.base_url || (await getBaseUrl(providerID))
|
|
279
|
+
if (base) {
|
|
280
|
+
log.info("fallback: sending unknown model to provider with base_url", { provider: providerID, model: id })
|
|
281
|
+
return getLanguageModel(providerID, id, p.api_key, undefined, base)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
281
286
|
throw new Error(`Unknown model: ${id}. Run: codeblog config --list`)
|
|
282
287
|
}
|
|
283
288
|
|
|
@@ -291,8 +296,12 @@ export namespace AIProvider {
|
|
|
291
296
|
if (!sdk) {
|
|
292
297
|
const createFn = BUNDLED_PROVIDERS[pkg]
|
|
293
298
|
if (!createFn) throw new Error(`No bundled provider for ${pkg}. Provider ${providerID} not supported.`)
|
|
294
|
-
const opts: Record<string, unknown> = { apiKey }
|
|
295
|
-
if (baseURL)
|
|
299
|
+
const opts: Record<string, unknown> = { apiKey, name: providerID }
|
|
300
|
+
if (baseURL) {
|
|
301
|
+
// @ai-sdk/openai-compatible expects baseURL to include /v1
|
|
302
|
+
const clean = baseURL.replace(/\/+$/, "")
|
|
303
|
+
opts.baseURL = clean.endsWith("/v1") ? clean : `${clean}/v1`
|
|
304
|
+
}
|
|
296
305
|
if (providerID === "openrouter") {
|
|
297
306
|
opts.headers = { "HTTP-Referer": "https://codeblog.ai/", "X-Title": "codeblog" }
|
|
298
307
|
}
|
|
@@ -339,15 +348,109 @@ export namespace AIProvider {
|
|
|
339
348
|
return false
|
|
340
349
|
}
|
|
341
350
|
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Fetch models dynamically from a provider's /v1/models endpoint
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
export async function fetchModels(providerID: string): Promise<ModelInfo[]> {
|
|
355
|
+
const apiKey = await getApiKey(providerID)
|
|
356
|
+
if (!apiKey) return []
|
|
357
|
+
const base = await getBaseUrl(providerID)
|
|
358
|
+
if (!base) {
|
|
359
|
+
// For known providers without custom base URL, use models.dev
|
|
360
|
+
const modelsDev = await fetchModelsDev()
|
|
361
|
+
const p = modelsDev[providerID] as any
|
|
362
|
+
if (p?.models) {
|
|
363
|
+
return Object.entries(p.models).map(([id, m]: [string, any]) => ({
|
|
364
|
+
id,
|
|
365
|
+
providerID,
|
|
366
|
+
name: m.name || id,
|
|
367
|
+
contextWindow: m.limit?.context || 0,
|
|
368
|
+
outputTokens: m.limit?.output || 0,
|
|
369
|
+
}))
|
|
370
|
+
}
|
|
371
|
+
return []
|
|
372
|
+
}
|
|
373
|
+
// Try OpenAI-compatible /v1/models
|
|
374
|
+
try {
|
|
375
|
+
const url = `${base.replace(/\/+$/, "").replace(/\/v1$/, "")}/v1/models`
|
|
376
|
+
const r = await fetch(url, {
|
|
377
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
378
|
+
signal: AbortSignal.timeout(10000),
|
|
379
|
+
})
|
|
380
|
+
if (!r.ok) return []
|
|
381
|
+
const json = await r.json() as any
|
|
382
|
+
const models = json.data || json.models || []
|
|
383
|
+
return models.map((m: any) => ({
|
|
384
|
+
id: m.id || m.name || "",
|
|
385
|
+
providerID,
|
|
386
|
+
name: m.id || m.name || "",
|
|
387
|
+
contextWindow: m.context_length || m.context_window || 0,
|
|
388
|
+
outputTokens: m.max_output_tokens || m.max_tokens || 0,
|
|
389
|
+
})).filter((m: ModelInfo) => m.id)
|
|
390
|
+
} catch {
|
|
391
|
+
return []
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Fetch models for all configured providers
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
export async function fetchAllModels(): Promise<ModelInfo[]> {
|
|
399
|
+
const cfg = await Config.load()
|
|
400
|
+
const seen = new Set<string>()
|
|
401
|
+
const result: ModelInfo[] = []
|
|
402
|
+
|
|
403
|
+
// Collect configured provider IDs (check env vars + config in one pass)
|
|
404
|
+
const ids = new Set<string>()
|
|
405
|
+
for (const [providerID, envKeys] of Object.entries(PROVIDER_ENV)) {
|
|
406
|
+
if (envKeys.some((k) => process.env[k])) ids.add(providerID)
|
|
407
|
+
else if (cfg.providers?.[providerID]?.api_key) ids.add(providerID)
|
|
408
|
+
}
|
|
409
|
+
if (cfg.providers) {
|
|
410
|
+
for (const providerID of Object.keys(cfg.providers)) {
|
|
411
|
+
if (cfg.providers[providerID].api_key) ids.add(providerID)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const settled = await Promise.allSettled([...ids].map((id) => fetchModels(id)))
|
|
416
|
+
for (const entry of settled) {
|
|
417
|
+
if (entry.status !== "fulfilled") continue
|
|
418
|
+
for (const m of entry.value) {
|
|
419
|
+
const key = `${m.providerID}/${m.id}`
|
|
420
|
+
if (seen.has(key)) continue
|
|
421
|
+
seen.add(key)
|
|
422
|
+
result.push(m)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return result
|
|
427
|
+
}
|
|
428
|
+
|
|
342
429
|
// ---------------------------------------------------------------------------
|
|
343
430
|
// List available models with key status (for codeblog config --list)
|
|
344
431
|
// ---------------------------------------------------------------------------
|
|
345
432
|
export async function available(): Promise<Array<{ model: ModelInfo; hasKey: boolean }>> {
|
|
346
433
|
const result: Array<{ model: ModelInfo; hasKey: boolean }> = []
|
|
434
|
+
const seen = new Set<string>()
|
|
435
|
+
|
|
436
|
+
// Dynamic models from configured providers (always have keys)
|
|
437
|
+
const dynamic = await fetchAllModels()
|
|
438
|
+
for (const model of dynamic) {
|
|
439
|
+
const key = `${model.providerID}/${model.id}`
|
|
440
|
+
if (seen.has(key)) continue
|
|
441
|
+
seen.add(key)
|
|
442
|
+
result.push({ model, hasKey: true })
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Merge builtin models (may or may not have keys)
|
|
347
446
|
for (const model of Object.values(BUILTIN_MODELS)) {
|
|
348
|
-
const key =
|
|
349
|
-
|
|
447
|
+
const key = `${model.providerID}/${model.id}`
|
|
448
|
+
if (seen.has(key)) continue
|
|
449
|
+
seen.add(key)
|
|
450
|
+
const apiKey = await getApiKey(model.providerID)
|
|
451
|
+
result.push({ model, hasKey: !!apiKey })
|
|
350
452
|
}
|
|
453
|
+
|
|
351
454
|
return result
|
|
352
455
|
}
|
|
353
456
|
|