codeblog-app 2.1.7 → 2.2.1
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 +7 -7
- 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 +133 -79
- package/src/ai/provider.ts +66 -2
- package/src/ai/tools.ts +27 -4
- package/src/cli/__tests__/commands.test.ts +1 -1
- package/src/tui/app.tsx +12 -26
- package/src/tui/routes/home.tsx +159 -36
- 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.1
|
|
4
|
+
"version": "2.2.1",
|
|
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.1
|
|
60
|
-
"codeblog-app-darwin-x64": "2.1
|
|
61
|
-
"codeblog-app-linux-arm64": "2.1
|
|
62
|
-
"codeblog-app-linux-x64": "2.1
|
|
63
|
-
"codeblog-app-windows-x64": "2.1
|
|
59
|
+
"codeblog-app-darwin-arm64": "2.2.1",
|
|
60
|
+
"codeblog-app-darwin-x64": "2.2.1",
|
|
61
|
+
"codeblog-app-linux-arm64": "2.2.1",
|
|
62
|
+
"codeblog-app-linux-x64": "2.2.1",
|
|
63
|
+
"codeblog-app-windows-x64": "2.2.1"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@ai-sdk/anthropic": "^3.0.44",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"@opentui/core": "^0.1.79",
|
|
72
72
|
"@opentui/solid": "^0.1.79",
|
|
73
73
|
"ai": "^6.0.86",
|
|
74
|
-
"codeblog-mcp": "^2.1.
|
|
74
|
+
"codeblog-mcp": "^2.1.3",
|
|
75
75
|
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
|
76
76
|
"fuzzysort": "^3.1.0",
|
|
77
77
|
"hono": "4.10.7",
|
|
@@ -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"
|
|
@@ -18,9 +18,17 @@ You help developers with everything on the platform:
|
|
|
18
18
|
You have 20+ tools. Use them whenever the user's request matches. Chain multiple tools if needed.
|
|
19
19
|
After a tool returns results, summarize them naturally for the user.
|
|
20
20
|
|
|
21
|
+
CRITICAL: When using tools, ALWAYS use the EXACT data returned by previous tool calls.
|
|
22
|
+
- If scan_sessions returns a path like "/Users/zhaoyifei/...", use that EXACT path
|
|
23
|
+
- NEVER modify, guess, or infer file paths — use them exactly as returned
|
|
24
|
+
- If a tool call fails with "file not found", the path is wrong — check the scan results again
|
|
25
|
+
|
|
21
26
|
Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
|
|
22
27
|
Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
|
|
23
28
|
|
|
29
|
+
const IDLE_TIMEOUT_MS = 15_000 // 15s without any stream event → abort
|
|
30
|
+
const DEFAULT_MAX_STEPS = 10 // Allow AI to retry tools up to 10 steps (each tool call + result = 1 step)
|
|
31
|
+
|
|
24
32
|
export namespace AIChat {
|
|
25
33
|
export interface Message {
|
|
26
34
|
role: "user" | "assistant" | "system"
|
|
@@ -35,98 +43,144 @@ export namespace AIChat {
|
|
|
35
43
|
onToolResult?: (name: string, result: unknown) => void
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
export
|
|
46
|
+
export interface StreamOptions {
|
|
47
|
+
maxSteps?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function stream(
|
|
51
|
+
messages: Message[],
|
|
52
|
+
callbacks: StreamCallbacks,
|
|
53
|
+
modelID?: string,
|
|
54
|
+
signal?: AbortSignal,
|
|
55
|
+
options?: StreamOptions
|
|
56
|
+
) {
|
|
39
57
|
const model = await AIProvider.getModel(modelID)
|
|
40
58
|
const tools = await getChatTools()
|
|
41
|
-
|
|
59
|
+
const maxSteps = options?.maxSteps ?? DEFAULT_MAX_STEPS
|
|
60
|
+
log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length, toolCount: Object.keys(tools).length, maxSteps })
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
const history: ModelMessage[] = messages
|
|
62
|
+
const history = messages
|
|
45
63
|
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
46
64
|
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
|
|
47
65
|
let full = ""
|
|
48
66
|
|
|
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
|
-
|
|
67
|
+
// Create an internal AbortController that we can trigger on idle timeout
|
|
68
|
+
const internalAbort = new AbortController()
|
|
69
|
+
const onExternalAbort = () => {
|
|
70
|
+
log.info("external abort signal received")
|
|
71
|
+
internalAbort.abort()
|
|
72
|
+
}
|
|
73
|
+
signal?.addEventListener("abort", onExternalAbort)
|
|
74
|
+
|
|
75
|
+
const result = streamText({
|
|
76
|
+
model,
|
|
77
|
+
system: SYSTEM_PROMPT,
|
|
78
|
+
messages: history,
|
|
79
|
+
tools,
|
|
80
|
+
stopWhen: stepCountIs(maxSteps),
|
|
81
|
+
toolChoice: "auto",
|
|
82
|
+
abortSignal: internalAbort.signal,
|
|
83
|
+
experimental_toolCallStreaming: false, // Disable streaming tool calls to avoid incomplete arguments bug
|
|
84
|
+
onStepFinish: (stepResult) => {
|
|
85
|
+
log.info("onStepFinish", {
|
|
86
|
+
stepNumber: stepResult.stepNumber,
|
|
87
|
+
finishReason: stepResult.finishReason,
|
|
88
|
+
textLength: stepResult.text?.length ?? 0,
|
|
89
|
+
toolCallsCount: stepResult.toolCalls?.length ?? 0,
|
|
90
|
+
toolResultsCount: stepResult.toolResults?.length ?? 0,
|
|
91
|
+
})
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
let partCount = 0
|
|
96
|
+
let toolExecuting = false
|
|
97
|
+
try {
|
|
98
|
+
// Idle timeout: if no stream events arrive for IDLE_TIMEOUT_MS, abort.
|
|
99
|
+
// Paused during tool execution (tools can take longer than 15s).
|
|
100
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
101
|
+
const resetIdle = () => {
|
|
102
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
103
|
+
if (toolExecuting) return // Don't start timer while tool is running
|
|
104
|
+
idleTimer = setTimeout(() => {
|
|
105
|
+
log.info("IDLE TIMEOUT FIRED", { partCount, fullLength: full.length })
|
|
106
|
+
internalAbort.abort()
|
|
107
|
+
}, IDLE_TIMEOUT_MS)
|
|
108
|
+
}
|
|
109
|
+
resetIdle()
|
|
110
|
+
|
|
111
|
+
for await (const part of result.fullStream) {
|
|
112
|
+
partCount++
|
|
113
|
+
if (internalAbort.signal.aborted) {
|
|
114
|
+
log.info("abort detected in loop, breaking", { partCount })
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
resetIdle()
|
|
118
|
+
|
|
119
|
+
switch (part.type) {
|
|
120
|
+
case "text-delta": {
|
|
121
|
+
const delta = (part as any).text ?? (part as any).textDelta ?? ""
|
|
122
|
+
if (delta) { full += delta; callbacks.onToken?.(delta) }
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
case "tool-call": {
|
|
126
|
+
const toolName = (part as any).toolName
|
|
127
|
+
const toolArgs = (part as any).args ?? (part as any).input ?? {}
|
|
128
|
+
log.info("tool-call", { toolName, args: toolArgs, partCount })
|
|
129
|
+
// Pause idle timer — tool execution happens between tool-call and tool-result
|
|
130
|
+
toolExecuting = true
|
|
131
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined }
|
|
132
|
+
callbacks.onToolCall?.(toolName, toolArgs)
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
case "tool-result": {
|
|
136
|
+
log.info("tool-result", { toolName: (part as any).toolName, partCount })
|
|
137
|
+
toolExecuting = false
|
|
138
|
+
callbacks.onToolResult?.((part as any).toolName, (part as any).output ?? (part as any).result ?? {})
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
case "tool-error" as any: {
|
|
142
|
+
const errorMsg = String((part as any).error).slice(0, 500)
|
|
143
|
+
log.error("tool-error", { toolName: (part as any).toolName, error: errorMsg })
|
|
144
|
+
toolExecuting = false
|
|
145
|
+
// Abort the stream on tool error to prevent infinite retry loops
|
|
146
|
+
log.info("aborting stream due to tool error")
|
|
147
|
+
internalAbort.abort()
|
|
148
|
+
break
|
|
94
149
|
}
|
|
150
|
+
case "error": {
|
|
151
|
+
const msg = (part as any).error instanceof Error ? (part as any).error.message : String((part as any).error)
|
|
152
|
+
log.error("stream part error", { error: msg })
|
|
153
|
+
callbacks.onError?.((part as any).error instanceof Error ? (part as any).error : new Error(msg))
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
break
|
|
95
158
|
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
162
|
+
log.info("for-await loop exited normally", { partCount, fullLength: full.length })
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
165
|
+
log.info("catch block entered", { name: error.name, message: error.message.slice(0, 200), partCount })
|
|
166
|
+
// Don't treat abort as a real error
|
|
167
|
+
if (error.name !== "AbortError") {
|
|
168
|
+
log.error("stream error (non-abort)", { error: error.message })
|
|
99
169
|
if (callbacks.onError) callbacks.onError(error)
|
|
100
170
|
else throw error
|
|
101
|
-
|
|
171
|
+
} else {
|
|
172
|
+
log.info("AbortError caught — treating as normal completion")
|
|
102
173
|
}
|
|
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) })
|
|
174
|
+
// On abort or error, still call onFinish so UI cleans up
|
|
175
|
+
log.info("calling onFinish from catch", { fullLength: full.length })
|
|
176
|
+
callbacks.onFinish?.(full || "(No response)")
|
|
177
|
+
return full
|
|
178
|
+
} finally {
|
|
179
|
+
log.info("finally block", { partCount, fullLength: full.length })
|
|
180
|
+
signal?.removeEventListener("abort", onExternalAbort)
|
|
128
181
|
}
|
|
129
182
|
|
|
183
|
+
log.info("calling onFinish from normal path", { fullLength: full.length })
|
|
130
184
|
callbacks.onFinish?.(full || "(No response)")
|
|
131
185
|
return full
|
|
132
186
|
}
|
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
|
}
|
|
@@ -161,7 +163,20 @@ export namespace AIProvider {
|
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
function getLanguageModel(providerID: string, modelID: string, apiKey: string, npm?: string, baseURL?: string): LanguageModel {
|
|
164
|
-
|
|
166
|
+
// Auto-detect Anthropic models and use @ai-sdk/anthropic instead of openai-compatible
|
|
167
|
+
// This fixes streaming tool call argument parsing issues with openai-compatible provider
|
|
168
|
+
let pkg = npm || PROVIDER_NPM[providerID]
|
|
169
|
+
|
|
170
|
+
// Force Anthropic SDK for Claude models, even if provider is openai-compatible
|
|
171
|
+
if (modelID.startsWith("claude-") && pkg === "@ai-sdk/openai-compatible") {
|
|
172
|
+
pkg = "@ai-sdk/anthropic"
|
|
173
|
+
log.info("auto-detected Claude model, switching from openai-compatible to @ai-sdk/anthropic", { model: modelID })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!pkg) {
|
|
177
|
+
pkg = "@ai-sdk/openai-compatible"
|
|
178
|
+
}
|
|
179
|
+
|
|
165
180
|
const cacheKey = `${providerID}:${pkg}:${apiKey.slice(0, 8)}`
|
|
166
181
|
|
|
167
182
|
log.info("loading model", { provider: providerID, model: modelID, pkg })
|
|
@@ -175,6 +190,26 @@ export namespace AIProvider {
|
|
|
175
190
|
const clean = baseURL.replace(/\/+$/, "")
|
|
176
191
|
opts.baseURL = clean.endsWith("/v1") ? clean : `${clean}/v1`
|
|
177
192
|
}
|
|
193
|
+
// For openai-compatible providers, normalize request body for broader compatibility
|
|
194
|
+
if (pkg === "@ai-sdk/openai-compatible") {
|
|
195
|
+
opts.transformRequestBody = (body: Record<string, any>) => {
|
|
196
|
+
// Remove parallel_tool_calls — many proxies/providers don't support it
|
|
197
|
+
delete body.parallel_tool_calls
|
|
198
|
+
|
|
199
|
+
// Ensure all tool schemas have type: "object" (required by DeepSeek/Qwen/etc.)
|
|
200
|
+
if (Array.isArray(body.tools)) {
|
|
201
|
+
for (const t of body.tools) {
|
|
202
|
+
const params = t?.function?.parameters
|
|
203
|
+
if (params && !params.type) {
|
|
204
|
+
params.type = "object"
|
|
205
|
+
if (!params.properties) params.properties = {}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return body
|
|
211
|
+
}
|
|
212
|
+
}
|
|
178
213
|
sdk = createFn(opts)
|
|
179
214
|
sdkCache.set(cacheKey, sdk)
|
|
180
215
|
}
|
|
@@ -188,6 +223,22 @@ export namespace AIProvider {
|
|
|
188
223
|
return (sdk as any)(modelID)
|
|
189
224
|
}
|
|
190
225
|
|
|
226
|
+
async function fetchRemoteModels(base: string, key: string): Promise<string[]> {
|
|
227
|
+
try {
|
|
228
|
+
const clean = base.replace(/\/+$/, "")
|
|
229
|
+
const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
|
|
230
|
+
const r = await fetch(url, {
|
|
231
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
232
|
+
signal: AbortSignal.timeout(8000),
|
|
233
|
+
})
|
|
234
|
+
if (!r.ok) return []
|
|
235
|
+
const data = await r.json() as { data?: Array<{ id: string }> }
|
|
236
|
+
return data.data?.map((m) => m.id) ?? []
|
|
237
|
+
} catch {
|
|
238
|
+
return []
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
191
242
|
function noKeyError(providerID: string): Error {
|
|
192
243
|
const envKeys = PROVIDER_ENV[providerID] || []
|
|
193
244
|
const envHint = envKeys[0] || `${providerID.toUpperCase().replace(/-/g, "_")}_API_KEY`
|
|
@@ -225,6 +276,19 @@ export namespace AIProvider {
|
|
|
225
276
|
const apiKey = await getApiKey(model.providerID)
|
|
226
277
|
result.push({ model, hasKey: !!apiKey })
|
|
227
278
|
}
|
|
279
|
+
// Include remote models from openai-compatible provider
|
|
280
|
+
const compatKey = await getApiKey("openai-compatible")
|
|
281
|
+
const compatBase = await getBaseUrl("openai-compatible")
|
|
282
|
+
if (compatKey && compatBase) {
|
|
283
|
+
const remoteModels = await fetchRemoteModels(compatBase, compatKey)
|
|
284
|
+
for (const id of remoteModels) {
|
|
285
|
+
if (BUILTIN_MODELS[id]) continue
|
|
286
|
+
result.push({
|
|
287
|
+
model: { id, providerID: "openai-compatible", name: id, contextWindow: 0, outputTokens: 0 },
|
|
288
|
+
hasKey: true,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
228
292
|
return result
|
|
229
293
|
}
|
|
230
294
|
|
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,23 @@ 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
|
+
const result = await mcp(name, clean(args))
|
|
95
|
+
const resultStr = typeof result === "string" ? result : JSON.stringify(result)
|
|
96
|
+
log.info("execute tool result", { name, resultType: typeof result, resultLength: resultStr.length, resultPreview: resultStr.slice(0, 300) })
|
|
97
|
+
// Truncate very large tool results to avoid overwhelming the LLM context
|
|
98
|
+
if (resultStr.length > 8000) {
|
|
99
|
+
log.info("truncating large tool result", { name, originalLength: resultStr.length })
|
|
100
|
+
return resultStr.slice(0, 8000) + "\n...(truncated)"
|
|
101
|
+
}
|
|
102
|
+
return resultStr
|
|
103
|
+
},
|
|
81
104
|
})
|
|
82
105
|
}
|
|
83
106
|
|
|
@@ -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/tui/app.tsx
CHANGED
|
@@ -44,6 +44,7 @@ function App() {
|
|
|
44
44
|
const renderer = useRenderer()
|
|
45
45
|
const [loggedIn, setLoggedIn] = createSignal(false)
|
|
46
46
|
const [username, setUsername] = createSignal("")
|
|
47
|
+
const [activeAgent, setActiveAgent] = createSignal("")
|
|
47
48
|
const [hasAI, setHasAI] = createSignal(false)
|
|
48
49
|
const [aiProvider, setAiProvider] = createSignal("")
|
|
49
50
|
const [modelName, setModelName] = createSignal("")
|
|
@@ -78,6 +79,15 @@ function App() {
|
|
|
78
79
|
}
|
|
79
80
|
} catch {}
|
|
80
81
|
|
|
82
|
+
// Get active agent
|
|
83
|
+
try {
|
|
84
|
+
const { Config } = await import("../config")
|
|
85
|
+
const cfg = await Config.load()
|
|
86
|
+
if (cfg.activeAgent) {
|
|
87
|
+
setActiveAgent(cfg.activeAgent)
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
|
|
81
91
|
await refreshAI()
|
|
82
92
|
})
|
|
83
93
|
|
|
@@ -103,6 +113,7 @@ function App() {
|
|
|
103
113
|
<Home
|
|
104
114
|
loggedIn={loggedIn()}
|
|
105
115
|
username={username()}
|
|
116
|
+
activeAgent={activeAgent()}
|
|
106
117
|
hasAI={hasAI()}
|
|
107
118
|
aiProvider={aiProvider()}
|
|
108
119
|
modelName={modelName()}
|
|
@@ -144,34 +155,9 @@ function App() {
|
|
|
144
155
|
</Match>
|
|
145
156
|
</Switch>
|
|
146
157
|
|
|
147
|
-
{/* Status bar —
|
|
158
|
+
{/* Status bar — only version */}
|
|
148
159
|
<box paddingLeft={2} paddingRight={2} flexShrink={0} flexDirection="row" gap={2}>
|
|
149
|
-
<text fg={theme.colors.textMuted}>{process.cwd()}</text>
|
|
150
160
|
<box flexGrow={1} />
|
|
151
|
-
<Show when={hasAI()}>
|
|
152
|
-
<text fg={theme.colors.text}>
|
|
153
|
-
<span style={{ fg: theme.colors.success }}>● </span>
|
|
154
|
-
{modelName()}
|
|
155
|
-
</text>
|
|
156
|
-
</Show>
|
|
157
|
-
<Show when={!hasAI()}>
|
|
158
|
-
<text fg={theme.colors.text}>
|
|
159
|
-
<span style={{ fg: theme.colors.error }}>○ </span>
|
|
160
|
-
no AI <span style={{ fg: theme.colors.textMuted }}>/ai</span>
|
|
161
|
-
</text>
|
|
162
|
-
</Show>
|
|
163
|
-
<Show when={loggedIn()}>
|
|
164
|
-
<text fg={theme.colors.text}>
|
|
165
|
-
<span style={{ fg: theme.colors.success }}>● </span>
|
|
166
|
-
{username() || "logged in"}
|
|
167
|
-
</text>
|
|
168
|
-
</Show>
|
|
169
|
-
<Show when={!loggedIn()}>
|
|
170
|
-
<text fg={theme.colors.text}>
|
|
171
|
-
<span style={{ fg: theme.colors.error }}>○ </span>
|
|
172
|
-
<span style={{ fg: theme.colors.textMuted }}>/login</span>
|
|
173
|
-
</text>
|
|
174
|
-
</Show>
|
|
175
161
|
<text fg={theme.colors.textMuted}>v{VERSION}</text>
|
|
176
162
|
</box>
|
|
177
163
|
</box>
|
package/src/tui/routes/home.tsx
CHANGED
|
@@ -1,13 +1,40 @@
|
|
|
1
1
|
import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js"
|
|
2
2
|
import { useKeyboard, usePaste } from "@opentui/solid"
|
|
3
|
+
import { SyntaxStyle, type ThemeTokenStyle } from "@opentui/core"
|
|
3
4
|
import { useRoute } from "../context/route"
|
|
4
5
|
import { useExit } from "../context/exit"
|
|
5
|
-
import { useTheme } from "../context/theme"
|
|
6
|
+
import { useTheme, type ThemeColors } from "../context/theme"
|
|
6
7
|
import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
|
|
7
8
|
import { TOOL_LABELS } from "../../ai/tools"
|
|
8
9
|
import { mask, saveProvider } from "../../ai/configure"
|
|
9
10
|
import { ChatHistory } from "../../storage/chat"
|
|
10
11
|
|
|
12
|
+
function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
|
|
13
|
+
return [
|
|
14
|
+
{ scope: ["default"], style: { foreground: colors.text } },
|
|
15
|
+
{ scope: ["spell", "nospell"], style: { foreground: colors.text } },
|
|
16
|
+
{ scope: ["conceal"], style: { foreground: colors.textMuted } },
|
|
17
|
+
{ scope: ["markup.heading", "markup.heading.1", "markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"], style: { foreground: colors.primary, bold: true } },
|
|
18
|
+
{ scope: ["markup.bold", "markup.strong"], style: { foreground: colors.text, bold: true } },
|
|
19
|
+
{ scope: ["markup.italic"], style: { foreground: colors.text, italic: true } },
|
|
20
|
+
{ scope: ["markup.list"], style: { foreground: colors.text } },
|
|
21
|
+
{ scope: ["markup.quote"], style: { foreground: colors.textMuted, italic: true } },
|
|
22
|
+
{ scope: ["markup.raw", "markup.raw.block", "markup.raw.inline"], style: { foreground: colors.accent } },
|
|
23
|
+
{ scope: ["markup.link", "markup.link.url"], style: { foreground: colors.primary, underline: true } },
|
|
24
|
+
{ scope: ["markup.link.label"], style: { foreground: colors.primary, underline: true } },
|
|
25
|
+
{ scope: ["label"], style: { foreground: colors.primary } },
|
|
26
|
+
{ scope: ["comment"], style: { foreground: colors.textMuted, italic: true } },
|
|
27
|
+
{ scope: ["string", "symbol"], style: { foreground: colors.success } },
|
|
28
|
+
{ scope: ["number", "boolean"], style: { foreground: colors.accent } },
|
|
29
|
+
{ scope: ["keyword"], style: { foreground: colors.primary, italic: true } },
|
|
30
|
+
{ scope: ["keyword.function", "function.method", "function", "constructor", "variable.member"], style: { foreground: colors.primary } },
|
|
31
|
+
{ scope: ["variable", "variable.parameter", "property", "parameter"], style: { foreground: colors.text } },
|
|
32
|
+
{ scope: ["type", "module", "class"], style: { foreground: colors.warning } },
|
|
33
|
+
{ scope: ["operator", "keyword.operator", "punctuation.delimiter"], style: { foreground: colors.textMuted } },
|
|
34
|
+
{ scope: ["punctuation", "punctuation.bracket"], style: { foreground: colors.textMuted } },
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
interface ChatMsg {
|
|
12
39
|
role: "user" | "assistant" | "tool"
|
|
13
40
|
content: string
|
|
@@ -18,6 +45,7 @@ interface ChatMsg {
|
|
|
18
45
|
export function Home(props: {
|
|
19
46
|
loggedIn: boolean
|
|
20
47
|
username: string
|
|
48
|
+
activeAgent: string
|
|
21
49
|
hasAI: boolean
|
|
22
50
|
aiProvider: string
|
|
23
51
|
modelName: string
|
|
@@ -40,6 +68,7 @@ export function Home(props: {
|
|
|
40
68
|
let escCooldown = 0
|
|
41
69
|
let sessionId = ""
|
|
42
70
|
const chatting = createMemo(() => messages().length > 0 || streaming())
|
|
71
|
+
const syntaxStyle = createMemo(() => SyntaxStyle.fromTheme(buildMarkdownSyntaxRules(theme.colors)))
|
|
43
72
|
|
|
44
73
|
function ensureSession() {
|
|
45
74
|
if (!sessionId) {
|
|
@@ -151,19 +180,29 @@ export function Home(props: {
|
|
|
151
180
|
setStreaming(true)
|
|
152
181
|
setStreamText("")
|
|
153
182
|
setMessage("")
|
|
183
|
+
let summaryStreamActive = false
|
|
154
184
|
|
|
155
185
|
try {
|
|
156
186
|
const { AIChat } = await import("../../ai/chat")
|
|
157
187
|
const { Config } = await import("../../config")
|
|
158
188
|
const { AIProvider } = await import("../../ai/provider")
|
|
189
|
+
const { Log } = await import("../../util/log")
|
|
190
|
+
const sendLog = Log.create({ service: "home-send" })
|
|
159
191
|
const cfg = await Config.load()
|
|
160
192
|
const mid = cfg.model || AIProvider.DEFAULT_MODEL
|
|
161
193
|
const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
|
|
162
194
|
let full = ""
|
|
195
|
+
let hasToolCalls = false
|
|
196
|
+
let lastToolName = ""
|
|
197
|
+
let lastToolResult = ""
|
|
163
198
|
abortCtrl = new AbortController()
|
|
199
|
+
sendLog.info("calling AIChat.stream", { model: mid, msgCount: allMsgs.length })
|
|
164
200
|
await AIChat.stream(allMsgs, {
|
|
165
201
|
onToken: (token) => { full += token; setStreamText(full) },
|
|
166
202
|
onToolCall: (name) => {
|
|
203
|
+
hasToolCalls = true
|
|
204
|
+
lastToolName = name
|
|
205
|
+
sendLog.info("onToolCall", { name })
|
|
167
206
|
// Save any accumulated text as assistant message before tool
|
|
168
207
|
if (full.trim()) {
|
|
169
208
|
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
@@ -172,7 +211,12 @@ export function Home(props: {
|
|
|
172
211
|
}
|
|
173
212
|
setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
|
|
174
213
|
},
|
|
175
|
-
onToolResult: (name) => {
|
|
214
|
+
onToolResult: (name, result) => {
|
|
215
|
+
sendLog.info("onToolResult", { name })
|
|
216
|
+
try {
|
|
217
|
+
const str = typeof result === "string" ? result : JSON.stringify(result)
|
|
218
|
+
lastToolResult = str.slice(0, 6000)
|
|
219
|
+
} catch { lastToolResult = "" }
|
|
176
220
|
setMessages((p) => p.map((m) =>
|
|
177
221
|
m.role === "tool" && m.toolName === name && m.toolStatus === "running"
|
|
178
222
|
? { ...m, toolStatus: "done" as const }
|
|
@@ -180,13 +224,55 @@ export function Home(props: {
|
|
|
180
224
|
))
|
|
181
225
|
},
|
|
182
226
|
onFinish: () => {
|
|
227
|
+
sendLog.info("onFinish", { fullLength: full.length, hasToolCalls, hasToolResult: !!lastToolResult })
|
|
183
228
|
if (full.trim()) {
|
|
184
229
|
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
230
|
+
setStreamText(""); setStreaming(false)
|
|
231
|
+
saveChat()
|
|
232
|
+
} else if (hasToolCalls && lastToolResult) {
|
|
233
|
+
// Tool executed but model didn't summarize — send a follow-up request
|
|
234
|
+
// to have the model produce a natural-language summary
|
|
235
|
+
sendLog.info("auto-summarizing tool result", { tool: lastToolName })
|
|
236
|
+
full = ""
|
|
237
|
+
setStreamText("")
|
|
238
|
+
const summaryMsgs = [
|
|
239
|
+
...allMsgs,
|
|
240
|
+
{ role: "assistant" as const, content: `I used the ${lastToolName} tool. Here are the results:\n${lastToolResult}` },
|
|
241
|
+
{ role: "user" as const, content: "Please summarize these results in a helpful, natural way." },
|
|
242
|
+
]
|
|
243
|
+
// NOTE: intentionally not awaited — the outer await resolves here,
|
|
244
|
+
// but streaming state is managed by the inner callbacks.
|
|
245
|
+
// The finally block must NOT reset streaming in this path.
|
|
246
|
+
summaryStreamActive = true
|
|
247
|
+
AIChat.stream(summaryMsgs, {
|
|
248
|
+
onToken: (token) => { full += token; setStreamText(full) },
|
|
249
|
+
onFinish: () => {
|
|
250
|
+
if (full.trim()) {
|
|
251
|
+
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
252
|
+
} else {
|
|
253
|
+
setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — model did not respond)" }])
|
|
254
|
+
}
|
|
255
|
+
setStreamText(""); setStreaming(false)
|
|
256
|
+
saveChat()
|
|
257
|
+
},
|
|
258
|
+
onError: (err) => {
|
|
259
|
+
sendLog.info("summary stream error", { message: err.message })
|
|
260
|
+
setMessages((p) => [...p, { role: "assistant", content: `Tool result received but summary failed: ${err.message}` }])
|
|
261
|
+
setStreamText(""); setStreaming(false)
|
|
262
|
+
saveChat()
|
|
263
|
+
},
|
|
264
|
+
}, mid, abortCtrl?.signal)
|
|
265
|
+
} else if (hasToolCalls) {
|
|
266
|
+
setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — no response from model)" }])
|
|
267
|
+
setStreamText(""); setStreaming(false)
|
|
268
|
+
saveChat()
|
|
269
|
+
} else {
|
|
270
|
+
setStreamText(""); setStreaming(false)
|
|
271
|
+
saveChat()
|
|
185
272
|
}
|
|
186
|
-
setStreamText(""); setStreaming(false)
|
|
187
|
-
saveChat()
|
|
188
273
|
},
|
|
189
274
|
onError: (err) => {
|
|
275
|
+
sendLog.info("onError", { message: err.message })
|
|
190
276
|
setMessages((p) => {
|
|
191
277
|
// Mark any running tools as error
|
|
192
278
|
const updated = p.map((m) =>
|
|
@@ -199,14 +285,20 @@ export function Home(props: {
|
|
|
199
285
|
setStreamText(""); setStreaming(false)
|
|
200
286
|
saveChat()
|
|
201
287
|
},
|
|
202
|
-
}, mid, abortCtrl.signal)
|
|
288
|
+
}, mid, abortCtrl.signal, { maxSteps: 10 })
|
|
289
|
+
sendLog.info("AIChat.stream returned normally")
|
|
203
290
|
abortCtrl = undefined
|
|
204
291
|
} catch (err) {
|
|
205
292
|
const msg = err instanceof Error ? err.message : String(err)
|
|
293
|
+
// Can't use sendLog here because it might not be in scope
|
|
206
294
|
setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
|
|
207
|
-
setStreamText("")
|
|
208
|
-
setStreaming(false)
|
|
209
295
|
saveChat()
|
|
296
|
+
} finally {
|
|
297
|
+
// Clean up streaming state — but NOT if a summary stream is still running
|
|
298
|
+
if (!summaryStreamActive) {
|
|
299
|
+
setStreamText("")
|
|
300
|
+
setStreaming(false)
|
|
301
|
+
}
|
|
210
302
|
}
|
|
211
303
|
}
|
|
212
304
|
|
|
@@ -371,28 +463,42 @@ export function Home(props: {
|
|
|
371
463
|
))}
|
|
372
464
|
<box height={1} />
|
|
373
465
|
<text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<box
|
|
466
|
+
|
|
467
|
+
{/* Status info below logo */}
|
|
468
|
+
<box height={1} />
|
|
469
|
+
<box flexDirection="column" alignItems="center" gap={0}>
|
|
377
470
|
<box flexDirection="row" gap={1}>
|
|
378
|
-
<text fg={props.hasAI ? theme.colors.success : theme.colors.warning}>
|
|
379
|
-
|
|
380
|
-
|
|
471
|
+
<text fg={props.hasAI ? theme.colors.success : theme.colors.warning}>
|
|
472
|
+
{props.hasAI ? "●" : "○"}
|
|
473
|
+
</text>
|
|
474
|
+
<text fg={theme.colors.text}>
|
|
475
|
+
{props.hasAI ? props.modelName : "No AI"}
|
|
381
476
|
</text>
|
|
477
|
+
<Show when={!props.hasAI}>
|
|
478
|
+
<text fg={theme.colors.textMuted}> — type /ai</text>
|
|
479
|
+
</Show>
|
|
382
480
|
</box>
|
|
383
481
|
<box flexDirection="row" gap={1}>
|
|
384
|
-
<text fg={props.loggedIn ? theme.colors.success : theme.colors.warning}>
|
|
385
|
-
|
|
386
|
-
|
|
482
|
+
<text fg={props.loggedIn ? theme.colors.success : theme.colors.warning}>
|
|
483
|
+
{props.loggedIn ? "●" : "○"}
|
|
484
|
+
</text>
|
|
485
|
+
<text fg={theme.colors.text}>
|
|
486
|
+
{props.loggedIn ? props.username : "Not logged in"}
|
|
387
487
|
</text>
|
|
488
|
+
<Show when={props.loggedIn && props.activeAgent}>
|
|
489
|
+
<text fg={theme.colors.textMuted}> / {props.activeAgent}</text>
|
|
490
|
+
</Show>
|
|
491
|
+
<Show when={!props.loggedIn}>
|
|
492
|
+
<text fg={theme.colors.textMuted}> — type /login</text>
|
|
493
|
+
</Show>
|
|
388
494
|
</box>
|
|
389
495
|
</box>
|
|
390
|
-
</
|
|
496
|
+
</box>
|
|
391
497
|
</Show>
|
|
392
498
|
|
|
393
499
|
{/* When chatting: messages fill the space */}
|
|
394
500
|
<Show when={chatting()}>
|
|
395
|
-
<
|
|
501
|
+
<scrollbox flexGrow={1} paddingTop={1} stickyScroll={true} stickyStart="bottom">
|
|
396
502
|
<For each={messages()}>
|
|
397
503
|
{(msg) => (
|
|
398
504
|
<box flexShrink={0}>
|
|
@@ -402,7 +508,7 @@ export function Home(props: {
|
|
|
402
508
|
<text fg={theme.colors.primary} flexShrink={0}>
|
|
403
509
|
<span style={{ bold: true }}>{"❯ "}</span>
|
|
404
510
|
</text>
|
|
405
|
-
<text fg={theme.colors.text}>
|
|
511
|
+
<text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>
|
|
406
512
|
<span style={{ bold: true }}>{msg.content}</span>
|
|
407
513
|
</text>
|
|
408
514
|
</box>
|
|
@@ -420,27 +526,44 @@ export function Home(props: {
|
|
|
420
526
|
</Show>
|
|
421
527
|
{/* Assistant message — ◆ prefix */}
|
|
422
528
|
<Show when={msg.role === "assistant"}>
|
|
423
|
-
<box
|
|
424
|
-
<
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
529
|
+
<box paddingBottom={1} flexShrink={0}>
|
|
530
|
+
<code
|
|
531
|
+
filetype="markdown"
|
|
532
|
+
drawUnstyledText={false}
|
|
533
|
+
syntaxStyle={syntaxStyle()}
|
|
534
|
+
content={msg.content}
|
|
535
|
+
conceal={true}
|
|
536
|
+
fg={theme.colors.text}
|
|
537
|
+
/>
|
|
428
538
|
</box>
|
|
429
539
|
</Show>
|
|
430
540
|
</box>
|
|
431
541
|
)}
|
|
432
542
|
</For>
|
|
433
|
-
<
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
543
|
+
<box
|
|
544
|
+
flexShrink={0}
|
|
545
|
+
paddingBottom={streaming() ? 1 : 0}
|
|
546
|
+
height={streaming() ? undefined : 0}
|
|
547
|
+
overflow="hidden"
|
|
548
|
+
>
|
|
549
|
+
<Show when={streaming() && streamText()}>
|
|
550
|
+
<code
|
|
551
|
+
filetype="markdown"
|
|
552
|
+
drawUnstyledText={false}
|
|
553
|
+
streaming={true}
|
|
554
|
+
syntaxStyle={syntaxStyle()}
|
|
555
|
+
content={streamText()}
|
|
556
|
+
conceal={true}
|
|
557
|
+
fg={theme.colors.text}
|
|
558
|
+
/>
|
|
559
|
+
</Show>
|
|
560
|
+
<Show when={streaming() && !streamText()}>
|
|
561
|
+
<text fg={theme.colors.textMuted} wrapMode="word">
|
|
562
|
+
{"◆ " + shimmerText()}
|
|
440
563
|
</text>
|
|
441
|
-
</
|
|
442
|
-
</
|
|
443
|
-
</
|
|
564
|
+
</Show>
|
|
565
|
+
</box>
|
|
566
|
+
</scrollbox>
|
|
444
567
|
</Show>
|
|
445
568
|
|
|
446
569
|
{/* Spacer when no chat and no autocomplete */}
|
|
@@ -508,11 +631,11 @@ export function Home(props: {
|
|
|
508
631
|
<text fg={theme.colors.textMuted}>{tipPool()[tipIdx % tipPool().length]}</text>
|
|
509
632
|
</box>
|
|
510
633
|
</Show>
|
|
511
|
-
{/* Input line */}
|
|
634
|
+
{/* Input line with blinking cursor */}
|
|
512
635
|
<box flexDirection="row">
|
|
513
636
|
<text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
|
|
514
637
|
<text fg={theme.colors.input}>{input()}</text>
|
|
515
|
-
<text fg={theme.colors.cursor}>{"█"}</text>
|
|
638
|
+
<text fg={theme.colors.cursor} style={{ bold: true }}>{"█"}</text>
|
|
516
639
|
</box>
|
|
517
640
|
</box>
|
|
518
641
|
</Show>
|
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>
|