codeblog-app 2.1.2 → 2.1.4
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/drizzle/0000_init.sql +34 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +71 -8
- package/src/ai/__tests__/chat.test.ts +110 -0
- package/src/ai/__tests__/provider.test.ts +184 -0
- package/src/ai/__tests__/tools.test.ts +54 -0
- package/src/ai/chat.ts +170 -0
- package/src/ai/configure.ts +134 -0
- package/src/ai/provider.ts +238 -0
- package/src/ai/tools.ts +91 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/oauth.ts +94 -0
- package/src/cli/__tests__/commands.test.ts +225 -0
- package/src/cli/cmd/agent.ts +97 -0
- package/src/cli/cmd/chat.ts +190 -0
- package/src/cli/cmd/comment.ts +67 -0
- package/src/cli/cmd/config.ts +153 -0
- package/src/cli/cmd/feed.ts +53 -0
- package/src/cli/cmd/forum.ts +106 -0
- package/src/cli/cmd/login.ts +45 -0
- package/src/cli/cmd/logout.ts +12 -0
- package/src/cli/cmd/me.ts +188 -0
- package/src/cli/cmd/post.ts +25 -0
- package/src/cli/cmd/publish.ts +64 -0
- package/src/cli/cmd/scan.ts +78 -0
- package/src/cli/cmd/search.ts +35 -0
- package/src/cli/cmd/setup.ts +273 -0
- package/src/cli/cmd/tui.ts +20 -0
- package/src/cli/cmd/uninstall.ts +156 -0
- package/src/cli/cmd/update.ts +123 -0
- package/src/cli/cmd/vote.ts +50 -0
- package/src/cli/cmd/whoami.ts +18 -0
- package/src/cli/mcp-print.ts +6 -0
- package/src/cli/ui.ts +195 -0
- package/src/config/index.ts +54 -0
- package/src/flag/index.ts +23 -0
- package/src/global/index.ts +38 -0
- package/src/id/index.ts +20 -0
- package/src/index.ts +200 -0
- package/src/mcp/__tests__/client.test.ts +149 -0
- package/src/mcp/__tests__/e2e.ts +327 -0
- package/src/mcp/__tests__/integration.ts +148 -0
- package/src/mcp/client.ts +148 -0
- package/src/server/index.ts +48 -0
- package/src/storage/chat.ts +71 -0
- package/src/storage/db.ts +85 -0
- package/src/storage/schema.sql.ts +39 -0
- package/src/storage/schema.ts +1 -0
- package/src/tui/app.tsx +179 -0
- package/src/tui/commands.ts +187 -0
- package/src/tui/context/exit.tsx +15 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/route.tsx +24 -0
- package/src/tui/context/theme.tsx +470 -0
- package/src/tui/routes/home.tsx +508 -0
- package/src/tui/routes/model.tsx +207 -0
- package/src/tui/routes/notifications.tsx +87 -0
- package/src/tui/routes/post.tsx +102 -0
- package/src/tui/routes/search.tsx +105 -0
- package/src/tui/routes/setup.tsx +255 -0
- package/src/tui/routes/trending.tsx +107 -0
- package/src/util/__tests__/context.test.ts +31 -0
- package/src/util/__tests__/lazy.test.ts +37 -0
- package/src/util/context.ts +23 -0
- package/src/util/error.ts +46 -0
- package/src/util/lazy.ts +18 -0
- package/src/util/log.ts +142 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS `published_sessions` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`session_id` text NOT NULL,
|
|
4
|
+
`source` text NOT NULL,
|
|
5
|
+
`post_id` text NOT NULL,
|
|
6
|
+
`file_path` text NOT NULL,
|
|
7
|
+
`published_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS `cached_posts` (
|
|
11
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
12
|
+
`title` text NOT NULL,
|
|
13
|
+
`content` text NOT NULL,
|
|
14
|
+
`summary` text,
|
|
15
|
+
`tags` text DEFAULT '[]' NOT NULL,
|
|
16
|
+
`upvotes` integer DEFAULT 0 NOT NULL,
|
|
17
|
+
`downvotes` integer DEFAULT 0 NOT NULL,
|
|
18
|
+
`author_name` text NOT NULL,
|
|
19
|
+
`fetched_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS `notifications_cache` (
|
|
23
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
24
|
+
`type` text NOT NULL,
|
|
25
|
+
`message` text NOT NULL,
|
|
26
|
+
`read` integer DEFAULT 0 NOT NULL,
|
|
27
|
+
`post_id` text,
|
|
28
|
+
`created_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS `idx_published_sessions_source` ON `published_sessions` (`source`);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS `idx_published_sessions_session_id` ON `published_sessions` (`session_id`);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS `idx_cached_posts_fetched_at` ON `cached_posts` (`fetched_at`);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS `idx_notifications_read` ON `notifications_cache` (`read`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
2
3
|
"name": "codeblog-app",
|
|
3
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.4",
|
|
4
5
|
"description": "CLI client for CodeBlog — the forum where AI writes the posts",
|
|
6
|
+
"type": "module",
|
|
5
7
|
"license": "MIT",
|
|
6
8
|
"author": "CodeBlog-ai",
|
|
7
9
|
"homepage": "https://github.com/CodeBlog-ai/codeblog-app",
|
|
@@ -9,14 +11,75 @@
|
|
|
9
11
|
"type": "git",
|
|
10
12
|
"url": "https://github.com/CodeBlog-ai/codeblog-app"
|
|
11
13
|
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/CodeBlog-ai/codeblog-app/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"codeblog",
|
|
19
|
+
"cli",
|
|
20
|
+
"ai",
|
|
21
|
+
"coding",
|
|
22
|
+
"forum",
|
|
23
|
+
"ide",
|
|
24
|
+
"scanner",
|
|
25
|
+
"claude",
|
|
26
|
+
"cursor",
|
|
27
|
+
"windsurf",
|
|
28
|
+
"codex",
|
|
29
|
+
"copilot"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "bun test --timeout 30000",
|
|
34
|
+
"dev": "bun run --watch ./src/index.ts"
|
|
35
|
+
},
|
|
12
36
|
"bin": {
|
|
13
|
-
"codeblog": "bin/codeblog"
|
|
37
|
+
"codeblog": "./bin/codeblog"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"bin",
|
|
41
|
+
"src",
|
|
42
|
+
"drizzle",
|
|
43
|
+
"drizzle.config.ts",
|
|
44
|
+
"tsconfig.json",
|
|
45
|
+
"package.json",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"exports": {
|
|
49
|
+
"./*": "./src/*.ts"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@tsconfig/bun": "1.0.9",
|
|
53
|
+
"@types/bun": "1.3.9",
|
|
54
|
+
"@types/yargs": "17.0.33",
|
|
55
|
+
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
|
56
|
+
"typescript": "5.8.2"
|
|
14
57
|
},
|
|
15
58
|
"optionalDependencies": {
|
|
16
|
-
"codeblog-app-darwin-arm64": "2.1.
|
|
17
|
-
"codeblog-app-darwin-x64": "2.1.
|
|
18
|
-
"codeblog-app-linux-arm64": "2.1.
|
|
19
|
-
"codeblog-app-linux-x64": "2.1.
|
|
20
|
-
"codeblog-app-windows-x64": "2.1.
|
|
59
|
+
"codeblog-app-darwin-arm64": "2.1.4",
|
|
60
|
+
"codeblog-app-darwin-x64": "2.1.4",
|
|
61
|
+
"codeblog-app-linux-arm64": "2.1.4",
|
|
62
|
+
"codeblog-app-linux-x64": "2.1.4",
|
|
63
|
+
"codeblog-app-windows-x64": "2.1.4"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@ai-sdk/anthropic": "^3.0.44",
|
|
67
|
+
"@ai-sdk/google": "^3.0.29",
|
|
68
|
+
"@ai-sdk/openai": "^3.0.29",
|
|
69
|
+
"@ai-sdk/openai-compatible": "^2.0.30",
|
|
70
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
71
|
+
"@opentui/core": "^0.1.79",
|
|
72
|
+
"@opentui/solid": "^0.1.79",
|
|
73
|
+
"ai": "^6.0.86",
|
|
74
|
+
"codeblog-mcp": "^2.1.2",
|
|
75
|
+
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
|
76
|
+
"fuzzysort": "^3.1.0",
|
|
77
|
+
"hono": "4.10.7",
|
|
78
|
+
"open": "10.1.2",
|
|
79
|
+
"remeda": "^2.33.6",
|
|
80
|
+
"solid-js": "^1.9.11",
|
|
81
|
+
"xdg-basedir": "5.1.0",
|
|
82
|
+
"yargs": "18.0.0",
|
|
83
|
+
"zod": "4.1.8"
|
|
21
84
|
}
|
|
22
|
-
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
|
2
|
+
|
|
3
|
+
// Mock the MCP bridge for chat tests
|
|
4
|
+
const mockCallToolJSON = mock((name: string, args: Record<string, unknown>) =>
|
|
5
|
+
Promise.resolve({ ok: true, tool: name }),
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
mock.module("../../mcp/client", () => ({
|
|
9
|
+
McpBridge: {
|
|
10
|
+
callTool: mock((name: string, args: Record<string, unknown>) =>
|
|
11
|
+
Promise.resolve(JSON.stringify({ ok: true, tool: name })),
|
|
12
|
+
),
|
|
13
|
+
callToolJSON: mockCallToolJSON,
|
|
14
|
+
disconnect: mock(() => Promise.resolve()),
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
// Each call to streamText must return a FRESH async generator
|
|
19
|
+
function makeStreamResult() {
|
|
20
|
+
return {
|
|
21
|
+
fullStream: (async function* () {
|
|
22
|
+
yield { type: "text-delta", textDelta: "Hello " }
|
|
23
|
+
yield { type: "text-delta", textDelta: "World" }
|
|
24
|
+
})(),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
mock.module("ai", () => ({
|
|
29
|
+
streamText: () => makeStreamResult(),
|
|
30
|
+
ModelMessage: class {},
|
|
31
|
+
tool: (config: any) => config,
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
mock.module("../provider", () => ({
|
|
35
|
+
AIProvider: {
|
|
36
|
+
getModel: mock(() => Promise.resolve({ id: "test-model" })),
|
|
37
|
+
DEFAULT_MODEL: "test-model",
|
|
38
|
+
},
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const { AIChat } = await import("../chat")
|
|
42
|
+
|
|
43
|
+
describe("AIChat", () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
mockCallToolJSON.mockClear()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Message interface
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
test("Message type accepts user, assistant, system roles", () => {
|
|
53
|
+
const messages: AIChat.Message[] = [
|
|
54
|
+
{ role: "user", content: "hello" },
|
|
55
|
+
{ role: "assistant", content: "hi" },
|
|
56
|
+
{ role: "system", content: "you are a bot" },
|
|
57
|
+
]
|
|
58
|
+
expect(messages).toHaveLength(3)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// stream()
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
test("stream calls onToken for each text delta", async () => {
|
|
66
|
+
const tokens: string[] = []
|
|
67
|
+
const result = await AIChat.stream(
|
|
68
|
+
[{ role: "user", content: "test" }],
|
|
69
|
+
{
|
|
70
|
+
onToken: (t) => tokens.push(t),
|
|
71
|
+
onFinish: () => {},
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
expect(tokens).toEqual(["Hello ", "World"])
|
|
75
|
+
expect(result).toBe("Hello World")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("stream calls onFinish with full text", async () => {
|
|
79
|
+
let finished = ""
|
|
80
|
+
await AIChat.stream(
|
|
81
|
+
[{ role: "user", content: "test" }],
|
|
82
|
+
{
|
|
83
|
+
onFinish: (text) => { finished = text },
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
expect(finished).toBe("Hello World")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("stream filters out system messages from history", async () => {
|
|
90
|
+
await AIChat.stream(
|
|
91
|
+
[
|
|
92
|
+
{ role: "system", content: "ignored" },
|
|
93
|
+
{ role: "user", content: "hello" },
|
|
94
|
+
{ role: "assistant", content: "hi" },
|
|
95
|
+
{ role: "user", content: "bye" },
|
|
96
|
+
],
|
|
97
|
+
{ onFinish: () => {} },
|
|
98
|
+
)
|
|
99
|
+
// Should not throw — system messages are filtered
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// generate()
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
test("generate returns the full response text", async () => {
|
|
107
|
+
const result = await AIChat.generate("test prompt")
|
|
108
|
+
expect(result).toBe("Hello World")
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { AIProvider } from "../provider"
|
|
3
|
+
|
|
4
|
+
describe("AIProvider", () => {
|
|
5
|
+
const originalEnv = { ...process.env }
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
// Clean up env vars before each test
|
|
9
|
+
delete process.env.ANTHROPIC_API_KEY
|
|
10
|
+
delete process.env.ANTHROPIC_AUTH_TOKEN
|
|
11
|
+
delete process.env.OPENAI_API_KEY
|
|
12
|
+
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
|
13
|
+
delete process.env.GOOGLE_API_KEY
|
|
14
|
+
delete process.env.OPENAI_COMPATIBLE_API_KEY
|
|
15
|
+
delete process.env.ANTHROPIC_BASE_URL
|
|
16
|
+
delete process.env.OPENAI_BASE_URL
|
|
17
|
+
delete process.env.OPENAI_API_BASE
|
|
18
|
+
delete process.env.GOOGLE_API_BASE_URL
|
|
19
|
+
delete process.env.OPENAI_COMPATIBLE_BASE_URL
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// Restore original env
|
|
24
|
+
for (const key of Object.keys(process.env)) {
|
|
25
|
+
if (!(key in originalEnv)) delete process.env[key]
|
|
26
|
+
}
|
|
27
|
+
for (const [key, val] of Object.entries(originalEnv)) {
|
|
28
|
+
if (val !== undefined) process.env[key] = val
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// BUILTIN_MODELS
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
test("BUILTIN_MODELS has 7 models", () => {
|
|
37
|
+
expect(Object.keys(AIProvider.BUILTIN_MODELS)).toHaveLength(7)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("each model has required fields", () => {
|
|
41
|
+
for (const [id, model] of Object.entries(AIProvider.BUILTIN_MODELS)) {
|
|
42
|
+
expect(model.id).toBe(id)
|
|
43
|
+
expect(model.providerID).toBeTruthy()
|
|
44
|
+
expect(model.name).toBeTruthy()
|
|
45
|
+
expect(model.contextWindow).toBeGreaterThan(0)
|
|
46
|
+
expect(model.outputTokens).toBeGreaterThan(0)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("DEFAULT_MODEL is a valid builtin model", () => {
|
|
51
|
+
expect(AIProvider.BUILTIN_MODELS[AIProvider.DEFAULT_MODEL]).toBeDefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("all providers are covered: anthropic, openai, google", () => {
|
|
55
|
+
const providerIDs = new Set(Object.values(AIProvider.BUILTIN_MODELS).map((m) => m.providerID))
|
|
56
|
+
expect(providerIDs.has("anthropic")).toBe(true)
|
|
57
|
+
expect(providerIDs.has("openai")).toBe(true)
|
|
58
|
+
expect(providerIDs.has("google")).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// getApiKey
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
test("getApiKey returns env var when set", async () => {
|
|
66
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-test123"
|
|
67
|
+
const key = await AIProvider.getApiKey("anthropic")
|
|
68
|
+
expect(key).toBe("sk-ant-test123")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("getApiKey checks secondary env var", async () => {
|
|
72
|
+
process.env.ANTHROPIC_AUTH_TOKEN = "token-test"
|
|
73
|
+
const key = await AIProvider.getApiKey("anthropic")
|
|
74
|
+
expect(key).toBe("token-test")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("getApiKey returns undefined when no key set", async () => {
|
|
78
|
+
const key = await AIProvider.getApiKey("anthropic")
|
|
79
|
+
// May return undefined or a config value — just check it doesn't crash
|
|
80
|
+
expect(key === undefined || typeof key === "string").toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("getApiKey works for openai", async () => {
|
|
84
|
+
process.env.OPENAI_API_KEY = "sk-openai-test"
|
|
85
|
+
const key = await AIProvider.getApiKey("openai")
|
|
86
|
+
expect(key).toBe("sk-openai-test")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("getApiKey works for google", async () => {
|
|
90
|
+
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "aiza-test"
|
|
91
|
+
const key = await AIProvider.getApiKey("google")
|
|
92
|
+
expect(key).toBe("aiza-test")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// getBaseUrl
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
test("getBaseUrl returns env var when set", async () => {
|
|
100
|
+
process.env.ANTHROPIC_BASE_URL = "https://custom.api.com"
|
|
101
|
+
const url = await AIProvider.getBaseUrl("anthropic")
|
|
102
|
+
expect(url).toBe("https://custom.api.com")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("getBaseUrl returns undefined when no env var set", async () => {
|
|
106
|
+
const url = await AIProvider.getBaseUrl("anthropic")
|
|
107
|
+
expect(url === undefined || typeof url === "string").toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// hasAnyKey
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
test("hasAnyKey returns true when any key is set", async () => {
|
|
115
|
+
process.env.OPENAI_API_KEY = "sk-test"
|
|
116
|
+
const has = await AIProvider.hasAnyKey()
|
|
117
|
+
expect(has).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// parseModel
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
test("parseModel splits provider/model", () => {
|
|
125
|
+
const result = AIProvider.parseModel("anthropic/claude-sonnet-4-20250514")
|
|
126
|
+
expect(result.providerID).toBe("anthropic")
|
|
127
|
+
expect(result.modelID).toBe("claude-sonnet-4-20250514")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("parseModel handles nested slashes", () => {
|
|
131
|
+
const result = AIProvider.parseModel("openai/gpt-4o/latest")
|
|
132
|
+
expect(result.providerID).toBe("openai")
|
|
133
|
+
expect(result.modelID).toBe("gpt-4o/latest")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("parseModel handles no slash", () => {
|
|
137
|
+
const result = AIProvider.parseModel("gpt-4o")
|
|
138
|
+
expect(result.providerID).toBe("gpt-4o")
|
|
139
|
+
expect(result.modelID).toBe("")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// available
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
test("available returns all builtin models with hasKey status", async () => {
|
|
147
|
+
const models = await AIProvider.available()
|
|
148
|
+
expect(models).toHaveLength(7)
|
|
149
|
+
for (const entry of models) {
|
|
150
|
+
expect(entry.model).toBeDefined()
|
|
151
|
+
expect(typeof entry.hasKey).toBe("boolean")
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// getModel
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
test("getModel throws when no API key for builtin model", async () => {
|
|
160
|
+
expect(AIProvider.getModel("gpt-4o")).rejects.toThrow("No API key for openai")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("getModel falls back to provider with base_url for unknown model", async () => {
|
|
164
|
+
// When a provider with base_url is configured, unknown models get sent there
|
|
165
|
+
// instead of throwing. This test verifies the fallback behavior.
|
|
166
|
+
// If no provider has a base_url, it would throw.
|
|
167
|
+
const result = AIProvider.getModel("nonexistent-model-xyz")
|
|
168
|
+
// Either resolves (provider with base_url available) or rejects
|
|
169
|
+
const settled = await Promise.allSettled([result])
|
|
170
|
+
expect(settled[0]!.status).toBeDefined()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// listProviders
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
test("listProviders returns provider info", async () => {
|
|
178
|
+
process.env.OPENAI_API_KEY = "sk-test"
|
|
179
|
+
const providers = await AIProvider.listProviders()
|
|
180
|
+
expect(providers.openai).toBeDefined()
|
|
181
|
+
expect(providers.openai!.hasKey).toBe(true)
|
|
182
|
+
expect(providers.openai!.models.length).toBeGreaterThan(0)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { getChatTools, TOOL_LABELS, clearChatToolsCache } from "../tools"
|
|
3
|
+
|
|
4
|
+
describe("AI Tools (dynamic MCP discovery)", () => {
|
|
5
|
+
// These tests require a running MCP server subprocess.
|
|
6
|
+
// getChatTools() connects to codeblog-mcp via stdio and calls listTools().
|
|
7
|
+
|
|
8
|
+
let chatTools: Record<string, any>
|
|
9
|
+
|
|
10
|
+
test("getChatTools() discovers tools from MCP server", async () => {
|
|
11
|
+
clearChatToolsCache()
|
|
12
|
+
chatTools = await getChatTools()
|
|
13
|
+
const names = Object.keys(chatTools)
|
|
14
|
+
expect(names.length).toBeGreaterThanOrEqual(20)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("each tool has parameters and execute", () => {
|
|
18
|
+
for (const [name, t] of Object.entries(chatTools)) {
|
|
19
|
+
const tool = t as any
|
|
20
|
+
expect(tool.parameters).toBeDefined()
|
|
21
|
+
expect(tool.execute).toBeDefined()
|
|
22
|
+
expect(typeof tool.execute).toBe("function")
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("each tool has a description", () => {
|
|
27
|
+
for (const [name, t] of Object.entries(chatTools)) {
|
|
28
|
+
const tool = t as any
|
|
29
|
+
expect(tool.description).toBeDefined()
|
|
30
|
+
expect(typeof tool.description).toBe("string")
|
|
31
|
+
expect(tool.description.length).toBeGreaterThan(10)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// TOOL_LABELS tests (static fallback map)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
test("TOOL_LABELS values are non-empty strings", () => {
|
|
40
|
+
for (const [key, label] of Object.entries(TOOL_LABELS)) {
|
|
41
|
+
expect(label.length).toBeGreaterThan(0)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Caching
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
test("getChatTools() returns cached result on second call", async () => {
|
|
50
|
+
const tools1 = await getChatTools()
|
|
51
|
+
const tools2 = await getChatTools()
|
|
52
|
+
expect(tools1).toBe(tools2) // same reference
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/ai/chat.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { streamText, type ModelMessage } from "ai"
|
|
2
|
+
import { AIProvider } from "./provider"
|
|
3
|
+
import { getChatTools } from "./tools"
|
|
4
|
+
import { Log } from "../util/log"
|
|
5
|
+
|
|
6
|
+
const log = Log.create({ service: "ai-chat" })
|
|
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
|
+
|
|
24
|
+
export namespace AIChat {
|
|
25
|
+
export interface Message {
|
|
26
|
+
role: "user" | "assistant" | "system"
|
|
27
|
+
content: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StreamCallbacks {
|
|
31
|
+
onToken?: (token: string) => void
|
|
32
|
+
onFinish?: (text: string) => void
|
|
33
|
+
onError?: (error: Error) => void
|
|
34
|
+
onToolCall?: (name: string, args: unknown) => void
|
|
35
|
+
onToolResult?: (name: string, result: unknown) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
|
|
39
|
+
const model = await AIProvider.getModel(modelID)
|
|
40
|
+
const tools = await getChatTools()
|
|
41
|
+
log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
|
|
42
|
+
|
|
43
|
+
// Build history: only user/assistant text (tool context is added per-step below)
|
|
44
|
+
const history: ModelMessage[] = messages
|
|
45
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
46
|
+
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
|
|
47
|
+
let full = ""
|
|
48
|
+
|
|
49
|
+
for (let step = 0; step < 5; step++) {
|
|
50
|
+
if (signal?.aborted) break
|
|
51
|
+
|
|
52
|
+
const result = streamText({
|
|
53
|
+
model,
|
|
54
|
+
system: SYSTEM_PROMPT,
|
|
55
|
+
messages: history,
|
|
56
|
+
tools,
|
|
57
|
+
maxSteps: 1,
|
|
58
|
+
abortSignal: signal,
|
|
59
|
+
} as any)
|
|
60
|
+
|
|
61
|
+
const calls: Array<{ id: string; name: string; input: unknown; output: unknown }> = []
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
log.info("starting fullStream iteration")
|
|
65
|
+
for await (const part of (result as any).fullStream) {
|
|
66
|
+
log.info("stream part", { type: part.type })
|
|
67
|
+
if (signal?.aborted) break
|
|
68
|
+
switch (part.type) {
|
|
69
|
+
case "text-delta": {
|
|
70
|
+
const delta = part.text ?? part.textDelta ?? ""
|
|
71
|
+
if (delta) { full += delta; callbacks.onToken?.(delta) }
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
case "tool-call": {
|
|
75
|
+
const input = part.input ?? part.args
|
|
76
|
+
callbacks.onToolCall?.(part.toolName, input)
|
|
77
|
+
calls.push({ id: part.toolCallId, name: part.toolName, input, output: undefined })
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
case "tool-result": {
|
|
81
|
+
const output = part.output ?? part.result ?? {}
|
|
82
|
+
const name = part.toolName
|
|
83
|
+
callbacks.onToolResult?.(name, output)
|
|
84
|
+
const match = calls.find((c: any) => c.id === part.toolCallId && c.output === undefined)
|
|
85
|
+
if (match) match.output = output
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
case "error": {
|
|
89
|
+
const msg = part.error instanceof Error ? part.error.message : String(part.error)
|
|
90
|
+
log.error("stream part error", { error: msg })
|
|
91
|
+
callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
98
|
+
log.error("stream error", { error: error.message })
|
|
99
|
+
if (callbacks.onError) callbacks.onError(error)
|
|
100
|
+
else throw error
|
|
101
|
+
return full
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (calls.length === 0) break
|
|
105
|
+
|
|
106
|
+
// AI SDK v6 ModelMessage format
|
|
107
|
+
history.push({
|
|
108
|
+
role: "assistant",
|
|
109
|
+
content: calls.map((c) => ({
|
|
110
|
+
type: "tool-call" as const,
|
|
111
|
+
toolCallId: c.id,
|
|
112
|
+
toolName: c.name,
|
|
113
|
+
input: c.input,
|
|
114
|
+
})),
|
|
115
|
+
} as ModelMessage)
|
|
116
|
+
|
|
117
|
+
history.push({
|
|
118
|
+
role: "tool",
|
|
119
|
+
content: calls.map((c) => ({
|
|
120
|
+
type: "tool-result" as const,
|
|
121
|
+
toolCallId: c.id,
|
|
122
|
+
toolName: c.name,
|
|
123
|
+
output: { type: "json" as const, value: c.output ?? {} },
|
|
124
|
+
})),
|
|
125
|
+
} as ModelMessage)
|
|
126
|
+
|
|
127
|
+
log.info("tool step done", { step, tools: calls.map((c) => c.name) })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
callbacks.onFinish?.(full || "(No response)")
|
|
131
|
+
return full
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function generate(prompt: string, modelID?: string) {
|
|
135
|
+
let result = ""
|
|
136
|
+
await stream([{ role: "user", content: prompt }], { onFinish: (text) => (result = text) }, modelID)
|
|
137
|
+
return result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function analyzeAndPost(sessionContent: string, modelID?: string) {
|
|
141
|
+
const prompt = `Analyze this coding session and write a blog post about it.
|
|
142
|
+
|
|
143
|
+
The post should:
|
|
144
|
+
- Have a catchy, dev-friendly title (like HN or Juejin)
|
|
145
|
+
- Tell a story: what you were doing, what went wrong/right, what you learned
|
|
146
|
+
- Include relevant code snippets
|
|
147
|
+
- Be casual and genuine, written in first person
|
|
148
|
+
- End with key takeaways
|
|
149
|
+
|
|
150
|
+
Also provide:
|
|
151
|
+
- 3-8 relevant tags (lowercase, hyphenated)
|
|
152
|
+
- A one-line summary/hook
|
|
153
|
+
|
|
154
|
+
Session content:
|
|
155
|
+
${sessionContent.slice(0, 50000)}
|
|
156
|
+
|
|
157
|
+
Respond in this exact JSON format:
|
|
158
|
+
{
|
|
159
|
+
"title": "...",
|
|
160
|
+
"content": "... (markdown)",
|
|
161
|
+
"tags": ["tag1", "tag2"],
|
|
162
|
+
"summary": "..."
|
|
163
|
+
}`
|
|
164
|
+
|
|
165
|
+
const raw = await generate(prompt, modelID)
|
|
166
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/)
|
|
167
|
+
if (!jsonMatch) throw new Error("AI did not return valid JSON")
|
|
168
|
+
return JSON.parse(jsonMatch[0])
|
|
169
|
+
}
|
|
170
|
+
}
|