codeblog-app 2.1.6 → 2.2.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 +6 -6
- package/src/ai/__tests__/chat.test.ts +71 -2
- package/src/ai/__tests__/provider.test.ts +16 -2
- package/src/ai/__tests__/tools.test.ts +47 -8
- package/src/ai/chat.ts +109 -78
- package/src/ai/provider.ts +52 -1
- package/src/ai/tools.ts +33 -4
- package/src/cli/__tests__/commands.test.ts +1 -1
- package/src/cli/cmd/uninstall.ts +168 -43
- package/src/tui/routes/home.tsx +86 -19
- package/src/tui/routes/model.tsx +7 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "codeblog-app",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "CLI client for CodeBlog — the forum where AI writes the posts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
"typescript": "5.8.2"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
|
-
"codeblog-app-darwin-arm64": "2.
|
|
60
|
-
"codeblog-app-darwin-x64": "2.
|
|
61
|
-
"codeblog-app-linux-arm64": "2.
|
|
62
|
-
"codeblog-app-linux-x64": "2.
|
|
63
|
-
"codeblog-app-windows-x64": "2.
|
|
59
|
+
"codeblog-app-darwin-arm64": "2.2.0",
|
|
60
|
+
"codeblog-app-darwin-x64": "2.2.0",
|
|
61
|
+
"codeblog-app-linux-arm64": "2.2.0",
|
|
62
|
+
"codeblog-app-linux-x64": "2.2.0",
|
|
63
|
+
"codeblog-app-windows-x64": "2.2.0"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@ai-sdk/anthropic": "^3.0.44",
|
|
@@ -5,12 +5,17 @@ const mockCallToolJSON = mock((name: string, args: Record<string, unknown>) =>
|
|
|
5
5
|
Promise.resolve({ ok: true, tool: name }),
|
|
6
6
|
)
|
|
7
7
|
|
|
8
|
+
const mockListTools = mock(() =>
|
|
9
|
+
Promise.resolve({ tools: [] }),
|
|
10
|
+
)
|
|
11
|
+
|
|
8
12
|
mock.module("../../mcp/client", () => ({
|
|
9
13
|
McpBridge: {
|
|
10
14
|
callTool: mock((name: string, args: Record<string, unknown>) =>
|
|
11
15
|
Promise.resolve(JSON.stringify({ ok: true, tool: name })),
|
|
12
16
|
),
|
|
13
17
|
callToolJSON: mockCallToolJSON,
|
|
18
|
+
listTools: mockListTools,
|
|
14
19
|
disconnect: mock(() => Promise.resolve()),
|
|
15
20
|
},
|
|
16
21
|
}))
|
|
@@ -25,10 +30,23 @@ function makeStreamResult() {
|
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
function makeToolCallStreamResult() {
|
|
34
|
+
return {
|
|
35
|
+
fullStream: (async function* () {
|
|
36
|
+
yield { type: "tool-call", toolName: "scan_sessions", args: { limit: 5 } }
|
|
37
|
+
yield { type: "tool-result", toolName: "scan_sessions", result: { sessions: [] } }
|
|
38
|
+
yield { type: "text-delta", textDelta: "Done scanning." }
|
|
39
|
+
})(),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let streamFactory = () => makeStreamResult()
|
|
44
|
+
|
|
28
45
|
mock.module("ai", () => ({
|
|
29
|
-
streamText: () =>
|
|
30
|
-
|
|
46
|
+
streamText: () => streamFactory(),
|
|
47
|
+
stepCountIs: (n: number) => ({ type: "step-count", count: n }),
|
|
31
48
|
tool: (config: any) => config,
|
|
49
|
+
jsonSchema: (schema: any) => schema,
|
|
32
50
|
}))
|
|
33
51
|
|
|
34
52
|
mock.module("../provider", () => ({
|
|
@@ -43,6 +61,7 @@ const { AIChat } = await import("../chat")
|
|
|
43
61
|
describe("AIChat", () => {
|
|
44
62
|
beforeEach(() => {
|
|
45
63
|
mockCallToolJSON.mockClear()
|
|
64
|
+
streamFactory = () => makeStreamResult()
|
|
46
65
|
})
|
|
47
66
|
|
|
48
67
|
// ---------------------------------------------------------------------------
|
|
@@ -99,6 +118,56 @@ describe("AIChat", () => {
|
|
|
99
118
|
// Should not throw — system messages are filtered
|
|
100
119
|
})
|
|
101
120
|
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// stream() with tool calls
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
test("stream dispatches onToolCall and onToolResult callbacks", async () => {
|
|
126
|
+
streamFactory = () => makeToolCallStreamResult()
|
|
127
|
+
|
|
128
|
+
const toolCalls: Array<{ name: string; args: unknown }> = []
|
|
129
|
+
const toolResults: Array<{ name: string; result: unknown }> = []
|
|
130
|
+
const tokens: string[] = []
|
|
131
|
+
|
|
132
|
+
await AIChat.stream(
|
|
133
|
+
[{ role: "user", content: "scan my sessions" }],
|
|
134
|
+
{
|
|
135
|
+
onToken: (t) => tokens.push(t),
|
|
136
|
+
onToolCall: (name, args) => toolCalls.push({ name, args }),
|
|
137
|
+
onToolResult: (name, result) => toolResults.push({ name, result }),
|
|
138
|
+
onFinish: () => {},
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(toolCalls).toEqual([{ name: "scan_sessions", args: { limit: 5 } }])
|
|
143
|
+
expect(toolResults).toEqual([{ name: "scan_sessions", result: { sessions: [] } }])
|
|
144
|
+
expect(tokens).toEqual(["Done scanning."])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// stream() error handling
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
test("stream calls onError when error event is received", async () => {
|
|
152
|
+
streamFactory = () => ({
|
|
153
|
+
fullStream: (async function* () {
|
|
154
|
+
yield { type: "error", error: new Error("test error") }
|
|
155
|
+
})(),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const errors: Error[] = []
|
|
159
|
+
await AIChat.stream(
|
|
160
|
+
[{ role: "user", content: "test" }],
|
|
161
|
+
{
|
|
162
|
+
onError: (err) => errors.push(err),
|
|
163
|
+
onFinish: () => {},
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
expect(errors).toHaveLength(1)
|
|
168
|
+
expect(errors[0]!.message).toBe("test error")
|
|
169
|
+
})
|
|
170
|
+
|
|
102
171
|
// ---------------------------------------------------------------------------
|
|
103
172
|
// generate()
|
|
104
173
|
// ---------------------------------------------------------------------------
|
|
@@ -143,13 +143,27 @@ describe("AIProvider", () => {
|
|
|
143
143
|
// available
|
|
144
144
|
// ---------------------------------------------------------------------------
|
|
145
145
|
|
|
146
|
-
test("available returns
|
|
146
|
+
test("available returns at least builtin models with hasKey status", async () => {
|
|
147
147
|
const models = await AIProvider.available()
|
|
148
|
-
expect(models).
|
|
148
|
+
expect(models.length).toBeGreaterThanOrEqual(7)
|
|
149
149
|
for (const entry of models) {
|
|
150
150
|
expect(entry.model).toBeDefined()
|
|
151
151
|
expect(typeof entry.hasKey).toBe("boolean")
|
|
152
152
|
}
|
|
153
|
+
// The first 7 should always be builtins
|
|
154
|
+
const builtinCount = models.filter((m) => AIProvider.BUILTIN_MODELS[m.model.id]).length
|
|
155
|
+
expect(builtinCount).toBe(7)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("available includes openai-compatible remote models when configured", async () => {
|
|
159
|
+
// With a valid key and base URL from config, remote models should be fetched.
|
|
160
|
+
// This test verifies that available() attempts to include openai-compatible models.
|
|
161
|
+
// Use localhost to ensure fast failure (connection refused) instead of DNS timeout.
|
|
162
|
+
process.env.OPENAI_COMPATIBLE_API_KEY = "sk-test"
|
|
163
|
+
process.env.OPENAI_COMPATIBLE_BASE_URL = "http://127.0.0.1:1"
|
|
164
|
+
const models = await AIProvider.available()
|
|
165
|
+
// Should still return at least builtins even if remote fetch fails
|
|
166
|
+
expect(models.length).toBeGreaterThanOrEqual(7)
|
|
153
167
|
})
|
|
154
168
|
|
|
155
169
|
// ---------------------------------------------------------------------------
|
|
@@ -1,23 +1,55 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test"
|
|
2
|
-
import { getChatTools, TOOL_LABELS, clearChatToolsCache } from "../tools"
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test"
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// Mock MCP bridge so tests don't need a running MCP server
|
|
4
|
+
const MOCK_MCP_TOOLS = [
|
|
5
|
+
{
|
|
6
|
+
name: "scan_sessions",
|
|
7
|
+
description: "Scan IDE sessions for coding activity",
|
|
8
|
+
inputSchema: { type: "object", properties: { limit: { type: "number" } } },
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: "my_dashboard",
|
|
12
|
+
description: "Show the user dashboard with stats",
|
|
13
|
+
inputSchema: {},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "browse_posts",
|
|
17
|
+
description: "Browse posts on the forum with filters",
|
|
18
|
+
inputSchema: { type: "object", properties: { page: { type: "number" }, tag: { type: "string" } } },
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
mock.module("../../mcp/client", () => ({
|
|
23
|
+
McpBridge: {
|
|
24
|
+
listTools: mock(() => Promise.resolve({ tools: MOCK_MCP_TOOLS })),
|
|
25
|
+
callToolJSON: mock((name: string) => Promise.resolve({ ok: true, tool: name })),
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
mock.module("ai", () => ({
|
|
30
|
+
tool: (config: any) => config,
|
|
31
|
+
jsonSchema: (schema: any) => schema,
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
const { getChatTools, TOOL_LABELS, clearChatToolsCache } = await import("../tools")
|
|
7
35
|
|
|
36
|
+
describe("AI Tools (dynamic MCP discovery)", () => {
|
|
8
37
|
let chatTools: Record<string, any>
|
|
9
38
|
|
|
10
39
|
test("getChatTools() discovers tools from MCP server", async () => {
|
|
11
40
|
clearChatToolsCache()
|
|
12
41
|
chatTools = await getChatTools()
|
|
13
42
|
const names = Object.keys(chatTools)
|
|
14
|
-
expect(names.length).
|
|
43
|
+
expect(names.length).toBe(MOCK_MCP_TOOLS.length)
|
|
44
|
+
expect(names).toContain("scan_sessions")
|
|
45
|
+
expect(names).toContain("my_dashboard")
|
|
46
|
+
expect(names).toContain("browse_posts")
|
|
15
47
|
})
|
|
16
48
|
|
|
17
|
-
test("each tool has
|
|
49
|
+
test("each tool has inputSchema and execute", () => {
|
|
18
50
|
for (const [name, t] of Object.entries(chatTools)) {
|
|
19
51
|
const tool = t as any
|
|
20
|
-
expect(tool.
|
|
52
|
+
expect(tool.inputSchema).toBeDefined()
|
|
21
53
|
expect(tool.execute).toBeDefined()
|
|
22
54
|
expect(typeof tool.execute).toBe("function")
|
|
23
55
|
}
|
|
@@ -32,6 +64,13 @@ describe("AI Tools (dynamic MCP discovery)", () => {
|
|
|
32
64
|
}
|
|
33
65
|
})
|
|
34
66
|
|
|
67
|
+
test("normalizeToolSchema adds type:object to empty schemas", async () => {
|
|
68
|
+
// my_dashboard has empty inputSchema {} — should be normalized
|
|
69
|
+
const dashboard = chatTools["my_dashboard"] as any
|
|
70
|
+
expect(dashboard.inputSchema.type).toBe("object")
|
|
71
|
+
expect(dashboard.inputSchema.properties).toEqual({})
|
|
72
|
+
})
|
|
73
|
+
|
|
35
74
|
// ---------------------------------------------------------------------------
|
|
36
75
|
// TOOL_LABELS tests (static fallback map)
|
|
37
76
|
// ---------------------------------------------------------------------------
|
package/src/ai/chat.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { streamText,
|
|
1
|
+
import { streamText, stepCountIs } from "ai"
|
|
2
2
|
import { AIProvider } from "./provider"
|
|
3
3
|
import { getChatTools } from "./tools"
|
|
4
4
|
import { Log } from "../util/log"
|
|
@@ -21,6 +21,9 @@ After a tool returns results, summarize them naturally for the user.
|
|
|
21
21
|
Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
|
|
22
22
|
Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
|
|
23
23
|
|
|
24
|
+
const MAX_TOOL_STEPS = 1
|
|
25
|
+
const IDLE_TIMEOUT_MS = 15_000 // 15s without any stream event → abort
|
|
26
|
+
|
|
24
27
|
export namespace AIChat {
|
|
25
28
|
export interface Message {
|
|
26
29
|
role: "user" | "assistant" | "system"
|
|
@@ -38,95 +41,123 @@ export namespace AIChat {
|
|
|
38
41
|
export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
|
|
39
42
|
const model = await AIProvider.getModel(modelID)
|
|
40
43
|
const tools = await getChatTools()
|
|
41
|
-
log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
|
|
44
|
+
log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length, toolCount: Object.keys(tools).length })
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
const history: ModelMessage[] = messages
|
|
46
|
+
const history = messages
|
|
45
47
|
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
46
48
|
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
|
|
47
49
|
let full = ""
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
51
|
+
// Create an internal AbortController that we can trigger on idle timeout
|
|
52
|
+
const internalAbort = new AbortController()
|
|
53
|
+
const onExternalAbort = () => {
|
|
54
|
+
log.info("external abort signal received")
|
|
55
|
+
internalAbort.abort()
|
|
56
|
+
}
|
|
57
|
+
signal?.addEventListener("abort", onExternalAbort)
|
|
58
|
+
|
|
59
|
+
const result = streamText({
|
|
60
|
+
model,
|
|
61
|
+
system: SYSTEM_PROMPT,
|
|
62
|
+
messages: history,
|
|
63
|
+
tools,
|
|
64
|
+
stopWhen: stepCountIs(MAX_TOOL_STEPS),
|
|
65
|
+
toolChoice: "auto",
|
|
66
|
+
abortSignal: internalAbort.signal,
|
|
67
|
+
onStepFinish: (stepResult) => {
|
|
68
|
+
log.info("onStepFinish", {
|
|
69
|
+
stepNumber: stepResult.stepNumber,
|
|
70
|
+
finishReason: stepResult.finishReason,
|
|
71
|
+
textLength: stepResult.text?.length ?? 0,
|
|
72
|
+
toolCallsCount: stepResult.toolCalls?.length ?? 0,
|
|
73
|
+
toolResultsCount: stepResult.toolResults?.length ?? 0,
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
let partCount = 0
|
|
79
|
+
let toolExecuting = false
|
|
80
|
+
try {
|
|
81
|
+
// Idle timeout: if no stream events arrive for IDLE_TIMEOUT_MS, abort.
|
|
82
|
+
// Paused during tool execution (tools can take longer than 15s).
|
|
83
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
84
|
+
const resetIdle = () => {
|
|
85
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
86
|
+
if (toolExecuting) return // Don't start timer while tool is running
|
|
87
|
+
idleTimer = setTimeout(() => {
|
|
88
|
+
log.info("IDLE TIMEOUT FIRED", { partCount, fullLength: full.length })
|
|
89
|
+
internalAbort.abort()
|
|
90
|
+
}, IDLE_TIMEOUT_MS)
|
|
91
|
+
}
|
|
92
|
+
resetIdle()
|
|
93
|
+
|
|
94
|
+
for await (const part of result.fullStream) {
|
|
95
|
+
partCount++
|
|
96
|
+
if (internalAbort.signal.aborted) {
|
|
97
|
+
log.info("abort detected in loop, breaking", { partCount })
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
resetIdle()
|
|
101
|
+
|
|
102
|
+
switch (part.type) {
|
|
103
|
+
case "text-delta": {
|
|
104
|
+
const delta = (part as any).text ?? (part as any).textDelta ?? ""
|
|
105
|
+
if (delta) { full += delta; callbacks.onToken?.(delta) }
|
|
106
|
+
break
|
|
107
|
+
}
|
|
108
|
+
case "tool-call": {
|
|
109
|
+
log.info("tool-call", { toolName: (part as any).toolName, partCount })
|
|
110
|
+
// Pause idle timer — tool execution happens between tool-call and tool-result
|
|
111
|
+
toolExecuting = true
|
|
112
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined }
|
|
113
|
+
callbacks.onToolCall?.((part as any).toolName, (part as any).input ?? (part as any).args)
|
|
114
|
+
break
|
|
94
115
|
}
|
|
116
|
+
case "tool-result": {
|
|
117
|
+
log.info("tool-result", { toolName: (part as any).toolName, partCount })
|
|
118
|
+
toolExecuting = false
|
|
119
|
+
callbacks.onToolResult?.((part as any).toolName, (part as any).output ?? (part as any).result ?? {})
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
case "tool-error" as any: {
|
|
123
|
+
log.error("tool-error", { toolName: (part as any).toolName, error: String((part as any).error).slice(0, 500) })
|
|
124
|
+
toolExecuting = false
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
case "error": {
|
|
128
|
+
const msg = (part as any).error instanceof Error ? (part as any).error.message : String((part as any).error)
|
|
129
|
+
log.error("stream part error", { error: msg })
|
|
130
|
+
callbacks.onError?.((part as any).error instanceof Error ? (part as any).error : new Error(msg))
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
break
|
|
95
135
|
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
139
|
+
log.info("for-await loop exited normally", { partCount, fullLength: full.length })
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
142
|
+
log.info("catch block entered", { name: error.name, message: error.message.slice(0, 200), partCount })
|
|
143
|
+
// Don't treat abort as a real error
|
|
144
|
+
if (error.name !== "AbortError") {
|
|
145
|
+
log.error("stream error (non-abort)", { error: error.message })
|
|
99
146
|
if (callbacks.onError) callbacks.onError(error)
|
|
100
147
|
else throw error
|
|
101
|
-
|
|
148
|
+
} else {
|
|
149
|
+
log.info("AbortError caught — treating as normal completion")
|
|
102
150
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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) })
|
|
151
|
+
// On abort or error, still call onFinish so UI cleans up
|
|
152
|
+
log.info("calling onFinish from catch", { fullLength: full.length })
|
|
153
|
+
callbacks.onFinish?.(full || "(No response)")
|
|
154
|
+
return full
|
|
155
|
+
} finally {
|
|
156
|
+
log.info("finally block", { partCount, fullLength: full.length })
|
|
157
|
+
signal?.removeEventListener("abort", onExternalAbort)
|
|
128
158
|
}
|
|
129
159
|
|
|
160
|
+
log.info("calling onFinish from normal path", { fullLength: full.length })
|
|
130
161
|
callbacks.onFinish?.(full || "(No response)")
|
|
131
162
|
return full
|
|
132
163
|
}
|
package/src/ai/provider.ts
CHANGED
|
@@ -115,7 +115,9 @@ export namespace AIProvider {
|
|
|
115
115
|
}
|
|
116
116
|
const compatKey = await getApiKey("openai-compatible")
|
|
117
117
|
if (compatKey) {
|
|
118
|
-
|
|
118
|
+
const compatBase = await getBaseUrl("openai-compatible")
|
|
119
|
+
const remoteModels = compatBase ? await fetchRemoteModels(compatBase, compatKey) : []
|
|
120
|
+
result["openai-compatible"] = { name: "OpenAI Compatible", models: remoteModels, hasKey: true }
|
|
119
121
|
}
|
|
120
122
|
return result
|
|
121
123
|
}
|
|
@@ -175,6 +177,26 @@ export namespace AIProvider {
|
|
|
175
177
|
const clean = baseURL.replace(/\/+$/, "")
|
|
176
178
|
opts.baseURL = clean.endsWith("/v1") ? clean : `${clean}/v1`
|
|
177
179
|
}
|
|
180
|
+
// For openai-compatible providers, normalize request body for broader compatibility
|
|
181
|
+
if (pkg === "@ai-sdk/openai-compatible") {
|
|
182
|
+
opts.transformRequestBody = (body: Record<string, any>) => {
|
|
183
|
+
// Remove parallel_tool_calls — many proxies/providers don't support it
|
|
184
|
+
delete body.parallel_tool_calls
|
|
185
|
+
|
|
186
|
+
// Ensure all tool schemas have type: "object" (required by DeepSeek/Qwen/etc.)
|
|
187
|
+
if (Array.isArray(body.tools)) {
|
|
188
|
+
for (const t of body.tools) {
|
|
189
|
+
const params = t?.function?.parameters
|
|
190
|
+
if (params && !params.type) {
|
|
191
|
+
params.type = "object"
|
|
192
|
+
if (!params.properties) params.properties = {}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return body
|
|
198
|
+
}
|
|
199
|
+
}
|
|
178
200
|
sdk = createFn(opts)
|
|
179
201
|
sdkCache.set(cacheKey, sdk)
|
|
180
202
|
}
|
|
@@ -188,6 +210,22 @@ export namespace AIProvider {
|
|
|
188
210
|
return (sdk as any)(modelID)
|
|
189
211
|
}
|
|
190
212
|
|
|
213
|
+
async function fetchRemoteModels(base: string, key: string): Promise<string[]> {
|
|
214
|
+
try {
|
|
215
|
+
const clean = base.replace(/\/+$/, "")
|
|
216
|
+
const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
|
|
217
|
+
const r = await fetch(url, {
|
|
218
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
219
|
+
signal: AbortSignal.timeout(8000),
|
|
220
|
+
})
|
|
221
|
+
if (!r.ok) return []
|
|
222
|
+
const data = await r.json() as { data?: Array<{ id: string }> }
|
|
223
|
+
return data.data?.map((m) => m.id) ?? []
|
|
224
|
+
} catch {
|
|
225
|
+
return []
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
191
229
|
function noKeyError(providerID: string): Error {
|
|
192
230
|
const envKeys = PROVIDER_ENV[providerID] || []
|
|
193
231
|
const envHint = envKeys[0] || `${providerID.toUpperCase().replace(/-/g, "_")}_API_KEY`
|
|
@@ -225,6 +263,19 @@ export namespace AIProvider {
|
|
|
225
263
|
const apiKey = await getApiKey(model.providerID)
|
|
226
264
|
result.push({ model, hasKey: !!apiKey })
|
|
227
265
|
}
|
|
266
|
+
// Include remote models from openai-compatible provider
|
|
267
|
+
const compatKey = await getApiKey("openai-compatible")
|
|
268
|
+
const compatBase = await getBaseUrl("openai-compatible")
|
|
269
|
+
if (compatKey && compatBase) {
|
|
270
|
+
const remoteModels = await fetchRemoteModels(compatBase, compatKey)
|
|
271
|
+
for (const id of remoteModels) {
|
|
272
|
+
if (BUILTIN_MODELS[id]) continue
|
|
273
|
+
result.push({
|
|
274
|
+
model: { id, providerID: "openai-compatible", name: id, contextWindow: 0, outputTokens: 0 },
|
|
275
|
+
hasKey: true,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
228
279
|
return result
|
|
229
280
|
}
|
|
230
281
|
|
package/src/ai/tools.ts
CHANGED
|
@@ -53,6 +53,18 @@ function clean(obj: Record<string, unknown>): Record<string, unknown> {
|
|
|
53
53
|
return result
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Schema normalization: ensure all JSON schemas are valid tool input schemas.
|
|
58
|
+
// Some MCP tools have empty inputSchema ({}) which produces schemas without
|
|
59
|
+
// "type": "object", causing providers like DeepSeek/Qwen to reject them.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
function normalizeToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
|
62
|
+
const normalized = { ...schema }
|
|
63
|
+
if (!normalized.type) normalized.type = "object"
|
|
64
|
+
if (normalized.type === "object" && !normalized.properties) normalized.properties = {}
|
|
65
|
+
return normalized
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
// ---------------------------------------------------------------------------
|
|
57
69
|
// Dynamic tool discovery from MCP server
|
|
58
70
|
// ---------------------------------------------------------------------------
|
|
@@ -72,12 +84,29 @@ export async function getChatTools(): Promise<Record<string, any>> {
|
|
|
72
84
|
|
|
73
85
|
for (const t of mcpTools) {
|
|
74
86
|
const name = t.name
|
|
75
|
-
const
|
|
87
|
+
const rawSchema = (t.inputSchema ?? {}) as Record<string, unknown>
|
|
76
88
|
|
|
77
|
-
tools[name] =
|
|
89
|
+
tools[name] = tool({
|
|
78
90
|
description: t.description || name,
|
|
79
|
-
|
|
80
|
-
execute: async (args: any) =>
|
|
91
|
+
inputSchema: jsonSchema(normalizeToolSchema(rawSchema)),
|
|
92
|
+
execute: async (args: any) => {
|
|
93
|
+
log.info("execute tool", { name, args })
|
|
94
|
+
try {
|
|
95
|
+
const result = await mcp(name, clean(args))
|
|
96
|
+
const resultStr = typeof result === "string" ? result : JSON.stringify(result)
|
|
97
|
+
log.info("execute tool result", { name, resultType: typeof result, resultLength: resultStr.length, resultPreview: resultStr.slice(0, 300) })
|
|
98
|
+
// Truncate very large tool results to avoid overwhelming the LLM context
|
|
99
|
+
if (resultStr.length > 8000) {
|
|
100
|
+
log.info("truncating large tool result", { name, originalLength: resultStr.length })
|
|
101
|
+
return resultStr.slice(0, 8000) + "\n...(truncated)"
|
|
102
|
+
}
|
|
103
|
+
return resultStr
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
106
|
+
log.error("execute tool error", { name, error: msg })
|
|
107
|
+
return JSON.stringify({ error: msg })
|
|
108
|
+
}
|
|
109
|
+
},
|
|
81
110
|
})
|
|
82
111
|
}
|
|
83
112
|
|
|
@@ -74,7 +74,7 @@ describe("CLI Commands", () => {
|
|
|
74
74
|
test("handler calls codeblog_status when --status flag", async () => {
|
|
75
75
|
mockCallTool.mockImplementationOnce(() => Promise.resolve("Status: OK"))
|
|
76
76
|
await (ScanCommand.handler as any)({ status: true, limit: 20 })
|
|
77
|
-
expect(mockCallTool).toHaveBeenCalledWith("codeblog_status")
|
|
77
|
+
expect(mockCallTool).toHaveBeenCalledWith("codeblog_status", {})
|
|
78
78
|
})
|
|
79
79
|
|
|
80
80
|
test("handler passes source when provided", async () => {
|
package/src/cli/cmd/uninstall.ts
CHANGED
|
@@ -5,6 +5,39 @@ import fs from "fs/promises"
|
|
|
5
5
|
import path from "path"
|
|
6
6
|
import os from "os"
|
|
7
7
|
|
|
8
|
+
const DIM = "\x1b[90m"
|
|
9
|
+
const RESET = "\x1b[0m"
|
|
10
|
+
const BOLD = "\x1b[1m"
|
|
11
|
+
const RED = "\x1b[91m"
|
|
12
|
+
const GREEN = "\x1b[92m"
|
|
13
|
+
const YELLOW = "\x1b[93m"
|
|
14
|
+
const CYAN = "\x1b[36m"
|
|
15
|
+
|
|
16
|
+
const W = 60 // inner width of the box
|
|
17
|
+
const BAR = `${DIM}│${RESET}`
|
|
18
|
+
|
|
19
|
+
/** Strip ANSI escape sequences to get visible character length */
|
|
20
|
+
function visLen(s: string): number {
|
|
21
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function line(text = "") {
|
|
25
|
+
const pad = Math.max(0, W - visLen(text) - 1)
|
|
26
|
+
console.log(` ${BAR} ${text}${" ".repeat(pad)}${BAR}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function lineSuccess(text: string) {
|
|
30
|
+
line(`${GREEN}✓${RESET} ${text}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lineWarn(text: string) {
|
|
34
|
+
line(`${YELLOW}⚠${RESET} ${text}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lineInfo(text: string) {
|
|
38
|
+
line(`${DIM}${text}${RESET}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
export const UninstallCommand: CommandModule = {
|
|
9
42
|
command: "uninstall",
|
|
10
43
|
describe: "Uninstall codeblog CLI and remove all local data",
|
|
@@ -15,35 +48,86 @@ export const UninstallCommand: CommandModule = {
|
|
|
15
48
|
default: false,
|
|
16
49
|
}),
|
|
17
50
|
handler: async (args) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
51
|
+
const keepData = args["keep-data"] as boolean
|
|
52
|
+
const binPath = process.execPath
|
|
53
|
+
const pkg = await import("../../../package.json")
|
|
54
|
+
|
|
55
|
+
console.log(UI.logo())
|
|
56
|
+
|
|
57
|
+
// Top border
|
|
58
|
+
console.log(` ${DIM}┌${"─".repeat(W)}┐${RESET}`)
|
|
59
|
+
line()
|
|
60
|
+
line(`${RED}${BOLD}Uninstall CodeBlog${RESET} ${DIM}v${pkg.version}${RESET}`)
|
|
61
|
+
line()
|
|
62
|
+
|
|
63
|
+
// Show what will be removed
|
|
64
|
+
line(`${BOLD}The following will be removed:${RESET}`)
|
|
65
|
+
line()
|
|
66
|
+
line(` ${DIM}Binary${RESET} ${binPath}`)
|
|
67
|
+
|
|
68
|
+
if (!keepData) {
|
|
69
|
+
const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
|
|
70
|
+
for (const dir of dirs) {
|
|
71
|
+
const label = dir.includes("config") ? "Config" : dir.includes("data") || dir.includes("share") ? "Data" : dir.includes("cache") ? "Cache" : "State"
|
|
72
|
+
try {
|
|
73
|
+
await fs.access(dir)
|
|
74
|
+
line(` ${DIM}${label.padEnd(10)}${RESET}${dir}`)
|
|
75
|
+
} catch {
|
|
76
|
+
// dir doesn't exist, skip
|
|
77
|
+
}
|
|
78
|
+
}
|
|
27
79
|
}
|
|
28
|
-
UI.println("")
|
|
29
80
|
|
|
30
|
-
|
|
81
|
+
if (os.platform() !== "win32") {
|
|
82
|
+
const rcFiles = getShellRcFiles()
|
|
83
|
+
for (const rc of rcFiles) {
|
|
84
|
+
try {
|
|
85
|
+
const content = await fs.readFile(rc, "utf-8")
|
|
86
|
+
if (content.includes("# codeblog")) {
|
|
87
|
+
line(` ${DIM}Shell RC${RESET} ${rc} ${DIM}(PATH entry)${RESET}`)
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
line()
|
|
94
|
+
|
|
95
|
+
// Separator
|
|
96
|
+
console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
|
|
97
|
+
line()
|
|
98
|
+
|
|
99
|
+
// Confirm
|
|
100
|
+
line(`${BOLD}Type "yes" to confirm uninstall:${RESET}`)
|
|
101
|
+
process.stderr.write(` ${BAR} ${DIM}> ${RESET}`)
|
|
102
|
+
const answer = await readLine()
|
|
103
|
+
// Print the line with right border after input
|
|
104
|
+
const inputDisplay = answer || ""
|
|
105
|
+
const inputLine = `${DIM}> ${RESET}${inputDisplay}`
|
|
106
|
+
const inputPad = Math.max(0, W - visLen(inputLine) - 1)
|
|
107
|
+
process.stderr.write(`\x1b[A\r ${BAR} ${inputLine}${" ".repeat(inputPad)}${BAR}\n`)
|
|
108
|
+
|
|
31
109
|
if (answer.toLowerCase() !== "yes") {
|
|
32
|
-
|
|
110
|
+
line()
|
|
111
|
+
line(`Uninstall cancelled.`)
|
|
112
|
+
line()
|
|
113
|
+
console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
|
|
114
|
+
console.log("")
|
|
33
115
|
return
|
|
34
116
|
}
|
|
35
117
|
|
|
36
|
-
|
|
118
|
+
line()
|
|
37
119
|
|
|
120
|
+
// Execute uninstall steps
|
|
38
121
|
// 1. Remove data directories
|
|
39
|
-
if (!
|
|
122
|
+
if (!keepData) {
|
|
40
123
|
const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
|
|
41
124
|
for (const dir of dirs) {
|
|
42
125
|
try {
|
|
126
|
+
await fs.access(dir)
|
|
43
127
|
await fs.rm(dir, { recursive: true, force: true })
|
|
44
|
-
|
|
128
|
+
lineSuccess(`Removed ${dir}`)
|
|
45
129
|
} catch {
|
|
46
|
-
//
|
|
130
|
+
// dir doesn't exist
|
|
47
131
|
}
|
|
48
132
|
}
|
|
49
133
|
}
|
|
@@ -53,67 +137,109 @@ export const UninstallCommand: CommandModule = {
|
|
|
53
137
|
await cleanShellRc()
|
|
54
138
|
}
|
|
55
139
|
|
|
56
|
-
// 3. Remove the binary
|
|
57
|
-
const binPath = process.execPath
|
|
140
|
+
// 3. Remove the binary
|
|
58
141
|
const binDir = path.dirname(binPath)
|
|
59
142
|
|
|
60
143
|
if (os.platform() === "win32") {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
UI.println(` ${UI.Style.TEXT_HIGHLIGHT}del "${binPath}"${UI.Style.TEXT_NORMAL}`)
|
|
65
|
-
|
|
66
|
-
// Try to remove from PATH
|
|
144
|
+
lineInfo(`Binary at ${binPath}`)
|
|
145
|
+
lineWarn(`On Windows, delete manually after exit:`)
|
|
146
|
+
line(` ${CYAN}del "${binPath}"${RESET}`)
|
|
67
147
|
await cleanWindowsPath(binDir)
|
|
68
148
|
} else {
|
|
69
149
|
try {
|
|
70
150
|
await fs.unlink(binPath)
|
|
71
|
-
|
|
151
|
+
lineSuccess(`Removed ${binPath}`)
|
|
72
152
|
} catch (e: any) {
|
|
73
153
|
if (e.code === "EBUSY" || e.code === "ETXTBSY") {
|
|
74
|
-
// Binary is running, schedule delete via shell
|
|
75
154
|
const { spawn } = await import("child_process")
|
|
76
155
|
spawn("sh", ["-c", `sleep 1 && rm -f "${binPath}"`], {
|
|
77
156
|
detached: true,
|
|
78
157
|
stdio: "ignore",
|
|
79
158
|
}).unref()
|
|
80
|
-
|
|
159
|
+
lineSuccess(`Binary will be removed: ${binPath}`)
|
|
81
160
|
} else {
|
|
82
|
-
|
|
83
|
-
|
|
161
|
+
lineWarn(`Could not remove binary: ${e.message}`)
|
|
162
|
+
line(` Run manually: ${CYAN}rm "${binPath}"${RESET}`)
|
|
84
163
|
}
|
|
85
164
|
}
|
|
86
165
|
}
|
|
87
166
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
167
|
+
line()
|
|
168
|
+
|
|
169
|
+
// Separator
|
|
170
|
+
console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
|
|
171
|
+
line()
|
|
172
|
+
line(`${GREEN}${BOLD}CodeBlog has been uninstalled.${RESET} Goodbye!`)
|
|
173
|
+
line()
|
|
174
|
+
|
|
175
|
+
// Bottom border
|
|
176
|
+
console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
|
|
177
|
+
console.log("")
|
|
91
178
|
},
|
|
92
179
|
}
|
|
93
180
|
|
|
94
|
-
|
|
181
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
function readLine(): Promise<string> {
|
|
184
|
+
const stdin = process.stdin
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
const wasRaw = stdin.isRaw
|
|
187
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
|
|
188
|
+
|
|
189
|
+
let buf = ""
|
|
190
|
+
const onData = (ch: Buffer) => {
|
|
191
|
+
const c = ch.toString("utf8")
|
|
192
|
+
if (c === "\u0003") {
|
|
193
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
194
|
+
stdin.removeListener("data", onData)
|
|
195
|
+
process.exit(130)
|
|
196
|
+
}
|
|
197
|
+
if (c === "\r" || c === "\n") {
|
|
198
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
|
|
199
|
+
stdin.removeListener("data", onData)
|
|
200
|
+
process.stderr.write("\n")
|
|
201
|
+
resolve(buf)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (c === "\u007f" || c === "\b") {
|
|
205
|
+
if (buf.length > 0) {
|
|
206
|
+
buf = buf.slice(0, -1)
|
|
207
|
+
process.stderr.write("\b \b")
|
|
208
|
+
}
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
const clean = c.replace(/[\x00-\x1f\x7f]/g, "")
|
|
212
|
+
if (clean) {
|
|
213
|
+
buf += clean
|
|
214
|
+
process.stderr.write(clean)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
stdin.on("data", onData)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getShellRcFiles(): string[] {
|
|
95
222
|
const home = os.homedir()
|
|
96
|
-
|
|
223
|
+
return [
|
|
97
224
|
path.join(home, ".zshrc"),
|
|
98
225
|
path.join(home, ".bashrc"),
|
|
99
226
|
path.join(home, ".profile"),
|
|
100
227
|
]
|
|
228
|
+
}
|
|
101
229
|
|
|
102
|
-
|
|
230
|
+
async function cleanShellRc() {
|
|
231
|
+
for (const rc of getShellRcFiles()) {
|
|
103
232
|
try {
|
|
104
233
|
const content = await fs.readFile(rc, "utf-8")
|
|
105
234
|
if (!content.includes("# codeblog")) continue
|
|
106
235
|
|
|
107
|
-
// Remove the "# codeblog" line and the export PATH line that follows
|
|
108
236
|
const lines = content.split("\n")
|
|
109
237
|
const filtered: string[] = []
|
|
110
238
|
for (let i = 0; i < lines.length; i++) {
|
|
111
239
|
if (lines[i]!.trim() === "# codeblog") {
|
|
112
|
-
// Skip this line and the next export PATH line
|
|
113
240
|
if (i + 1 < lines.length && lines[i + 1]!.includes("export PATH=")) {
|
|
114
|
-
i++
|
|
241
|
+
i++
|
|
115
242
|
}
|
|
116
|
-
// Also skip a preceding blank line if present
|
|
117
243
|
if (filtered.length > 0 && filtered[filtered.length - 1]!.trim() === "") {
|
|
118
244
|
filtered.pop()
|
|
119
245
|
}
|
|
@@ -123,7 +249,7 @@ async function cleanShellRc() {
|
|
|
123
249
|
}
|
|
124
250
|
|
|
125
251
|
await fs.writeFile(rc, filtered.join("\n"), "utf-8")
|
|
126
|
-
|
|
252
|
+
lineSuccess(`Cleaned PATH from ${rc}`)
|
|
127
253
|
} catch {
|
|
128
254
|
// file doesn't exist or not readable
|
|
129
255
|
}
|
|
@@ -136,7 +262,6 @@ async function cleanWindowsPath(binDir: string) {
|
|
|
136
262
|
const { promisify } = await import("util")
|
|
137
263
|
const execAsync = promisify(exec)
|
|
138
264
|
|
|
139
|
-
// Read current user PATH
|
|
140
265
|
const { stdout } = await execAsync(
|
|
141
266
|
`powershell -Command "[Environment]::GetEnvironmentVariable('Path','User')"`,
|
|
142
267
|
)
|
|
@@ -148,9 +273,9 @@ async function cleanWindowsPath(binDir: string) {
|
|
|
148
273
|
await execAsync(
|
|
149
274
|
`powershell -Command "[Environment]::SetEnvironmentVariable('Path','${newPath}','User')"`,
|
|
150
275
|
)
|
|
151
|
-
|
|
276
|
+
lineSuccess(`Removed ${binDir} from user PATH`)
|
|
152
277
|
}
|
|
153
278
|
} catch {
|
|
154
|
-
|
|
279
|
+
lineWarn("Could not clean PATH. Remove manually from System Settings.")
|
|
155
280
|
}
|
|
156
281
|
}
|
package/src/tui/routes/home.tsx
CHANGED
|
@@ -156,14 +156,24 @@ export function Home(props: {
|
|
|
156
156
|
const { AIChat } = await import("../../ai/chat")
|
|
157
157
|
const { Config } = await import("../../config")
|
|
158
158
|
const { AIProvider } = await import("../../ai/provider")
|
|
159
|
+
const { Log } = await import("../../util/log")
|
|
160
|
+
const sendLog = Log.create({ service: "home-send" })
|
|
159
161
|
const cfg = await Config.load()
|
|
160
162
|
const mid = cfg.model || AIProvider.DEFAULT_MODEL
|
|
161
163
|
const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
|
|
162
164
|
let full = ""
|
|
165
|
+
let hasToolCalls = false
|
|
166
|
+
let lastToolName = ""
|
|
167
|
+
let lastToolResult = ""
|
|
168
|
+
let summaryStreamActive = false
|
|
163
169
|
abortCtrl = new AbortController()
|
|
170
|
+
sendLog.info("calling AIChat.stream", { model: mid, msgCount: allMsgs.length })
|
|
164
171
|
await AIChat.stream(allMsgs, {
|
|
165
172
|
onToken: (token) => { full += token; setStreamText(full) },
|
|
166
173
|
onToolCall: (name) => {
|
|
174
|
+
hasToolCalls = true
|
|
175
|
+
lastToolName = name
|
|
176
|
+
sendLog.info("onToolCall", { name })
|
|
167
177
|
// Save any accumulated text as assistant message before tool
|
|
168
178
|
if (full.trim()) {
|
|
169
179
|
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
@@ -172,7 +182,12 @@ export function Home(props: {
|
|
|
172
182
|
}
|
|
173
183
|
setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
|
|
174
184
|
},
|
|
175
|
-
onToolResult: (name) => {
|
|
185
|
+
onToolResult: (name, result) => {
|
|
186
|
+
sendLog.info("onToolResult", { name })
|
|
187
|
+
try {
|
|
188
|
+
const str = typeof result === "string" ? result : JSON.stringify(result)
|
|
189
|
+
lastToolResult = str.slice(0, 6000)
|
|
190
|
+
} catch { lastToolResult = "" }
|
|
176
191
|
setMessages((p) => p.map((m) =>
|
|
177
192
|
m.role === "tool" && m.toolName === name && m.toolStatus === "running"
|
|
178
193
|
? { ...m, toolStatus: "done" as const }
|
|
@@ -180,13 +195,55 @@ export function Home(props: {
|
|
|
180
195
|
))
|
|
181
196
|
},
|
|
182
197
|
onFinish: () => {
|
|
198
|
+
sendLog.info("onFinish", { fullLength: full.length, hasToolCalls, hasToolResult: !!lastToolResult })
|
|
183
199
|
if (full.trim()) {
|
|
184
200
|
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
201
|
+
setStreamText(""); setStreaming(false)
|
|
202
|
+
saveChat()
|
|
203
|
+
} else if (hasToolCalls && lastToolResult) {
|
|
204
|
+
// Tool executed but model didn't summarize — send a follow-up request
|
|
205
|
+
// to have the model produce a natural-language summary
|
|
206
|
+
sendLog.info("auto-summarizing tool result", { tool: lastToolName })
|
|
207
|
+
full = ""
|
|
208
|
+
setStreamText("")
|
|
209
|
+
const summaryMsgs = [
|
|
210
|
+
...allMsgs,
|
|
211
|
+
{ role: "assistant" as const, content: `I used the ${lastToolName} tool. Here are the results:\n${lastToolResult}` },
|
|
212
|
+
{ role: "user" as const, content: "Please summarize these results in a helpful, natural way." },
|
|
213
|
+
]
|
|
214
|
+
// NOTE: intentionally not awaited — the outer await resolves here,
|
|
215
|
+
// but streaming state is managed by the inner callbacks.
|
|
216
|
+
// The finally block must NOT reset streaming in this path.
|
|
217
|
+
summaryStreamActive = true
|
|
218
|
+
AIChat.stream(summaryMsgs, {
|
|
219
|
+
onToken: (token) => { full += token; setStreamText(full) },
|
|
220
|
+
onFinish: () => {
|
|
221
|
+
if (full.trim()) {
|
|
222
|
+
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
223
|
+
} else {
|
|
224
|
+
setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — model did not respond)" }])
|
|
225
|
+
}
|
|
226
|
+
setStreamText(""); setStreaming(false)
|
|
227
|
+
saveChat()
|
|
228
|
+
},
|
|
229
|
+
onError: (err) => {
|
|
230
|
+
sendLog.info("summary stream error", { message: err.message })
|
|
231
|
+
setMessages((p) => [...p, { role: "assistant", content: `Tool result received but summary failed: ${err.message}` }])
|
|
232
|
+
setStreamText(""); setStreaming(false)
|
|
233
|
+
saveChat()
|
|
234
|
+
},
|
|
235
|
+
}, mid, abortCtrl?.signal)
|
|
236
|
+
} else if (hasToolCalls) {
|
|
237
|
+
setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — no response from model)" }])
|
|
238
|
+
setStreamText(""); setStreaming(false)
|
|
239
|
+
saveChat()
|
|
240
|
+
} else {
|
|
241
|
+
setStreamText(""); setStreaming(false)
|
|
242
|
+
saveChat()
|
|
185
243
|
}
|
|
186
|
-
setStreamText(""); setStreaming(false)
|
|
187
|
-
saveChat()
|
|
188
244
|
},
|
|
189
245
|
onError: (err) => {
|
|
246
|
+
sendLog.info("onError", { message: err.message })
|
|
190
247
|
setMessages((p) => {
|
|
191
248
|
// Mark any running tools as error
|
|
192
249
|
const updated = p.map((m) =>
|
|
@@ -200,13 +257,19 @@ export function Home(props: {
|
|
|
200
257
|
saveChat()
|
|
201
258
|
},
|
|
202
259
|
}, mid, abortCtrl.signal)
|
|
260
|
+
sendLog.info("AIChat.stream returned normally")
|
|
203
261
|
abortCtrl = undefined
|
|
204
262
|
} catch (err) {
|
|
205
263
|
const msg = err instanceof Error ? err.message : String(err)
|
|
264
|
+
// Can't use sendLog here because it might not be in scope
|
|
206
265
|
setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
|
|
207
|
-
setStreamText("")
|
|
208
|
-
setStreaming(false)
|
|
209
266
|
saveChat()
|
|
267
|
+
} finally {
|
|
268
|
+
// Clean up streaming state — but NOT if a summary stream is still running
|
|
269
|
+
if (!summaryStreamActive) {
|
|
270
|
+
setStreamText("")
|
|
271
|
+
setStreaming(false)
|
|
272
|
+
}
|
|
210
273
|
}
|
|
211
274
|
}
|
|
212
275
|
|
|
@@ -392,7 +455,7 @@ export function Home(props: {
|
|
|
392
455
|
|
|
393
456
|
{/* When chatting: messages fill the space */}
|
|
394
457
|
<Show when={chatting()}>
|
|
395
|
-
<
|
|
458
|
+
<scrollbox flexGrow={1} paddingTop={1} stickyScroll={true} stickyStart="bottom">
|
|
396
459
|
<For each={messages()}>
|
|
397
460
|
{(msg) => (
|
|
398
461
|
<box flexShrink={0}>
|
|
@@ -402,7 +465,7 @@ export function Home(props: {
|
|
|
402
465
|
<text fg={theme.colors.primary} flexShrink={0}>
|
|
403
466
|
<span style={{ bold: true }}>{"❯ "}</span>
|
|
404
467
|
</text>
|
|
405
|
-
<text fg={theme.colors.text}>
|
|
468
|
+
<text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>
|
|
406
469
|
<span style={{ bold: true }}>{msg.content}</span>
|
|
407
470
|
</text>
|
|
408
471
|
</box>
|
|
@@ -424,23 +487,27 @@ export function Home(props: {
|
|
|
424
487
|
<text fg={theme.colors.success} flexShrink={0}>
|
|
425
488
|
<span style={{ bold: true }}>{"◆ "}</span>
|
|
426
489
|
</text>
|
|
427
|
-
<text fg={theme.colors.text}>{msg.content}</text>
|
|
490
|
+
<text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>{msg.content}</text>
|
|
428
491
|
</box>
|
|
429
492
|
</Show>
|
|
430
493
|
</box>
|
|
431
494
|
)}
|
|
432
495
|
</For>
|
|
433
|
-
<
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
496
|
+
<box
|
|
497
|
+
flexDirection="row"
|
|
498
|
+
paddingBottom={streaming() ? 1 : 0}
|
|
499
|
+
flexShrink={0}
|
|
500
|
+
height={streaming() ? undefined : 0}
|
|
501
|
+
overflow="hidden"
|
|
502
|
+
>
|
|
503
|
+
<text fg={theme.colors.success} flexShrink={0}>
|
|
504
|
+
<span style={{ bold: true }}>{streaming() ? "◆ " : ""}</span>
|
|
505
|
+
</text>
|
|
506
|
+
<text fg={streamText() ? theme.colors.text : theme.colors.textMuted} wrapMode="word" flexGrow={1} flexShrink={1}>
|
|
507
|
+
{streaming() ? (streamText() || shimmerText()) : ""}
|
|
508
|
+
</text>
|
|
509
|
+
</box>
|
|
510
|
+
</scrollbox>
|
|
444
511
|
</Show>
|
|
445
512
|
|
|
446
513
|
{/* Spacer when no chat and no autocomplete */}
|
package/src/tui/routes/model.tsx
CHANGED
|
@@ -54,7 +54,8 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
|
|
|
54
54
|
}))
|
|
55
55
|
if (items.length > 0) {
|
|
56
56
|
setModels(items)
|
|
57
|
-
const
|
|
57
|
+
const modelId = cfg.model || AIProvider.DEFAULT_MODEL
|
|
58
|
+
const curIdx = items.findIndex((m) => m.id === modelId || `${m.provider}/${m.id}` === modelId)
|
|
58
59
|
if (curIdx >= 0) setIdx(curIdx)
|
|
59
60
|
setStatus(`${items.length} models loaded`)
|
|
60
61
|
} else {
|
|
@@ -123,9 +124,11 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
|
|
|
123
124
|
|
|
124
125
|
async function save(id: string) {
|
|
125
126
|
try {
|
|
127
|
+
const item = filtered().find((m) => m.id === id)
|
|
128
|
+
const saveId = item && item.provider === "openai-compatible" ? `openai-compatible/${id}` : id
|
|
126
129
|
const { Config } = await import("../../config")
|
|
127
|
-
await Config.save({ model:
|
|
128
|
-
props.onDone(
|
|
130
|
+
await Config.save({ model: saveId })
|
|
131
|
+
props.onDone(saveId)
|
|
129
132
|
} catch {
|
|
130
133
|
props.onDone()
|
|
131
134
|
}
|
|
@@ -187,7 +190,7 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
|
|
|
187
190
|
<text fg={selected() ? "#ffffff" : theme.colors.textMuted}>
|
|
188
191
|
{" " + m.provider}
|
|
189
192
|
</text>
|
|
190
|
-
{m.id === current() ? (
|
|
193
|
+
{(m.id === current() || `${m.provider}/${m.id}` === current()) ? (
|
|
191
194
|
<text fg={selected() ? "#ffffff" : theme.colors.success}>{" ← current"}</text>
|
|
192
195
|
) : null}
|
|
193
196
|
</box>
|