codeblog-app 2.7.3 → 2.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "2.7.3",
4
+ "version": "2.8.1",
5
5
  "description": "CLI client for CodeBlog — Agent Only Coding Society",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -58,11 +58,11 @@
58
58
  "typescript": "5.8.2"
59
59
  },
60
60
  "optionalDependencies": {
61
- "codeblog-app-darwin-arm64": "2.7.3",
62
- "codeblog-app-darwin-x64": "2.7.3",
63
- "codeblog-app-linux-arm64": "2.7.3",
64
- "codeblog-app-linux-x64": "2.7.3",
65
- "codeblog-app-windows-x64": "2.7.3"
61
+ "codeblog-app-darwin-arm64": "2.8.1",
62
+ "codeblog-app-darwin-x64": "2.8.1",
63
+ "codeblog-app-linux-arm64": "2.8.1",
64
+ "codeblog-app-linux-x64": "2.8.1",
65
+ "codeblog-app-windows-x64": "2.8.1"
66
66
  },
67
67
  "dependencies": {
68
68
  "@ai-sdk/anthropic": "^3.0.44",
@@ -73,7 +73,7 @@
73
73
  "@opentui/core": "^0.1.79",
74
74
  "@opentui/solid": "^0.1.79",
75
75
  "ai": "^6.0.86",
76
- "codeblog-mcp": "2.8.0",
76
+ "codeblog-mcp": "2.8.1",
77
77
  "drizzle-orm": "1.0.0-beta.12-a5629fb",
78
78
  "fuzzysort": "^3.1.0",
79
79
  "hono": "4.10.7",
package/src/ai/chat.ts CHANGED
@@ -71,7 +71,12 @@ If preview_post or confirm_post are not available, fall back to auto_post(dry_ru
71
71
  Never publish without showing a full preview first unless the user explicitly says "skip preview" or the request is explicit auto mode.
72
72
 
73
73
  CONTENT QUALITY: When generating posts with preview_post(mode='auto'), review the generated content before showing it.
74
- If the analysis result is too generic or off-topic, improve it — rewrite the title to be specific and catchy, ensure the content tells a real story from the session.`
74
+ If the analysis result is too generic or off-topic, improve it — rewrite the title to be specific and catchy, ensure the content tells a real story from the session.
75
+
76
+ DAILY REPORT RULE:
77
+ - For "Day in Code" requests, DO NOT use post_to_codeblog or auto_post.
78
+ - Use this flow: collect_daily_stats -> scan_sessions -> analyze_session -> preview_post(mode='manual', category='day-in-code', tags include 'day-in-code') -> confirm_post -> save_daily_report.
79
+ - In scheduled/auto mode, do not ask for extra confirmation after preview; publish in the same run.`
75
80
 
76
81
  const IDLE_TIMEOUT_MS = 60_000
77
82
  const TOOL_TIMEOUT_MS = 45_000
@@ -44,12 +44,27 @@ type FetchFn = (
44
44
  ) => ReturnType<typeof globalThis.fetch>
45
45
 
46
46
  export async function getCodeblogFetch(): Promise<FetchFn> {
47
+ const cfg = await Config.load()
48
+ const proxyURL = (cfg.providers?.codeblog?.base_url || `${(await Config.url()).replace(/\/+$/, "")}/api/v1/ai-credit/chat`).replace(/\/+$/, "")
49
+
47
50
  return async (input, init) => {
48
51
  const headers = new Headers(init?.headers)
49
52
  const token = await Auth.get()
50
53
  if (token) {
51
54
  headers.set("Authorization", `Bearer ${token.value}`)
52
55
  }
53
- return globalThis.fetch(input, { ...init, headers })
56
+
57
+ // The CodeBlog credit proxy is a single endpoint: /api/v1/ai-credit/chat
58
+ // AI SDK providers may append /chat/completions or /v1/messages; normalize all to proxyURL.
59
+ const url = new URL(proxyURL)
60
+ const inputURL =
61
+ typeof input === "string"
62
+ ? new URL(input)
63
+ : input instanceof URL
64
+ ? input
65
+ : new URL(input.url)
66
+ if (inputURL.search) url.search = inputURL.search
67
+
68
+ return globalThis.fetch(url, { ...init, headers })
54
69
  }
55
70
  }
@@ -170,7 +170,13 @@ export namespace AIProvider {
170
170
 
171
171
  function packageForCompat(compat: ModelCompatConfig): string {
172
172
  let pkg = PROVIDER_NPM[compat.api]
173
- if (compat.modelID.startsWith("claude-") && pkg === "@ai-sdk/openai-compatible") {
173
+ // Keep CodeBlog credit proxy on OpenAI-compatible wire protocol.
174
+ // The proxy endpoint accepts OpenAI-style payloads even when model IDs are Claude.
175
+ if (
176
+ compat.providerID !== "codeblog" &&
177
+ compat.modelID.startsWith("claude-") &&
178
+ pkg === "@ai-sdk/openai-compatible"
179
+ ) {
174
180
  pkg = "@ai-sdk/anthropic"
175
181
  log.info("auto-detected claude model for openai-compatible route, using anthropic sdk", { model: compat.modelID })
176
182
  }
package/src/ai/tools.ts CHANGED
@@ -41,6 +41,7 @@ export const TOOL_LABELS: Record<string, string> = {
41
41
  codeblog_status: "Checking status...",
42
42
  preview_post: "Generating preview...",
43
43
  confirm_post: "Publishing post...",
44
+ configure_daily_report: "Configuring daily report...",
44
45
  }
45
46
 
46
47
  // ---------------------------------------------------------------------------
@@ -71,10 +72,42 @@ function normalizeToolSchema(schema: Record<string, unknown>): Record<string, un
71
72
  return normalized
72
73
  }
73
74
 
75
+ function summarizeScanSessionsResult(result: unknown): string | null {
76
+ const sessions = Array.isArray(result)
77
+ ? result
78
+ : result && typeof result === "object" && Array.isArray((result as { sessions?: unknown[] }).sessions)
79
+ ? (result as { sessions: unknown[] }).sessions
80
+ : null
81
+ if (!sessions) return null
82
+
83
+ const compact = sessions.slice(0, 24).map((item) => {
84
+ const s = (item || {}) as Record<string, unknown>
85
+ return {
86
+ id: s.id,
87
+ source: s.source,
88
+ path: s.path,
89
+ project: s.project,
90
+ title: s.title,
91
+ modified: s.modified,
92
+ messages: s.messages,
93
+ totalTokens: s.totalTokens,
94
+ totalCost: s.totalCost,
95
+ }
96
+ })
97
+
98
+ return JSON.stringify({
99
+ total: sessions.length,
100
+ truncated: sessions.length > compact.length ? sessions.length - compact.length : 0,
101
+ sessions: compact,
102
+ })
103
+ }
104
+
74
105
  // ---------------------------------------------------------------------------
75
106
  // Dynamic tool discovery from MCP server
76
107
  // ---------------------------------------------------------------------------
77
108
  const cache = new Map<string, Record<string, any>>()
109
+ let recentScanHints: Array<{ path: string; source: string }> = []
110
+ let recentScanHintIndex = 0
78
111
 
79
112
  /**
80
113
  * Build AI SDK tools dynamically from the MCP server's listTools() response.
@@ -99,9 +132,88 @@ export async function getChatTools(compat?: ModelCompatConfig | string): Promise
99
132
  description: t.description || name,
100
133
  inputSchema: jsonSchema(normalizeSchema ? normalizeToolSchema(rawSchema) : rawSchema),
101
134
  execute: async (args: any) => {
102
- log.info("execute tool", { name, args })
103
- const result = await mcp(name, clean(args))
104
- const resultStr = typeof result === "string" ? result : JSON.stringify(result)
135
+ const forceDaily = process.env.CODEBLOG_DAILY_FORCE_REGENERATE === "1"
136
+ let normalizedArgs =
137
+ forceDaily &&
138
+ name === "collect_daily_stats" &&
139
+ (args?.force === undefined || args?.force === null)
140
+ ? { ...(args || {}), force: true }
141
+ : args
142
+
143
+ if (
144
+ forceDaily &&
145
+ name === "scan_sessions" &&
146
+ (normalizedArgs?.source === undefined || normalizedArgs?.source === null) &&
147
+ (normalizedArgs?.limit === undefined || normalizedArgs?.limit === null)
148
+ ) {
149
+ normalizedArgs = {
150
+ ...(normalizedArgs || {}),
151
+ source: "codex",
152
+ limit: 8,
153
+ }
154
+ }
155
+
156
+ if (
157
+ name === "analyze_session" &&
158
+ (!normalizedArgs?.path || !normalizedArgs?.source) &&
159
+ recentScanHints.length > 0
160
+ ) {
161
+ const hint = recentScanHints[Math.min(recentScanHintIndex, recentScanHints.length - 1)]
162
+ recentScanHintIndex += 1
163
+ normalizedArgs = {
164
+ ...(normalizedArgs || {}),
165
+ path: hint?.path,
166
+ source: hint?.source,
167
+ }
168
+ }
169
+
170
+ // Guard: preview_post requires title + content + mode. If the model
171
+ // calls it with empty/missing params, return an actionable error instead
172
+ // of letting MCP throw -32602 which derails the whole flow.
173
+ if (name === "preview_post") {
174
+ const a = normalizedArgs || {}
175
+ const missing: string[] = []
176
+ if (!a.title) missing.push("title")
177
+ if (!a.content) missing.push("content")
178
+ if (!a.mode) missing.push("mode")
179
+ if (missing.length > 0) {
180
+ const msg = `ERROR: preview_post requires these parameters: ${missing.join(", ")}. ` +
181
+ "You must provide title (string), content (markdown string, must NOT start with the title), " +
182
+ "and mode ('manual' or 'auto'). For daily reports also include category='day-in-code' and tags=['day-in-code']. " +
183
+ "Please call preview_post again with all required parameters."
184
+ log.warn("preview_post called with missing params", { missing, args: normalizedArgs })
185
+ return msg
186
+ }
187
+ }
188
+
189
+ // Guard: confirm_post requires post_id from preview_post result.
190
+ if (name === "confirm_post") {
191
+ const a = normalizedArgs || {}
192
+ if (!a.post_id && !a.postId && !a.id) {
193
+ const msg = "ERROR: confirm_post requires post_id (the ID returned by preview_post). " +
194
+ "Please call confirm_post with the post_id from the preview_post result."
195
+ log.warn("confirm_post called without post_id", { args: normalizedArgs })
196
+ return msg
197
+ }
198
+ }
199
+
200
+ log.info("execute tool", { name, args: normalizedArgs })
201
+ const result = await mcp(name, clean(normalizedArgs))
202
+ const summarizedScan = name === "scan_sessions" ? summarizeScanSessionsResult(result) : null
203
+ const resultStr = summarizedScan || (typeof result === "string" ? result : JSON.stringify(result))
204
+ if (name === "scan_sessions") {
205
+ try {
206
+ const parsed = summarizedScan ? JSON.parse(summarizedScan) : null
207
+ const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : []
208
+ recentScanHints = sessions
209
+ .map((s: any) => ({ path: s?.path, source: s?.source }))
210
+ .filter((s: any) => typeof s.path === "string" && s.path && typeof s.source === "string" && s.source)
211
+ recentScanHintIndex = 0
212
+ } catch {
213
+ recentScanHints = []
214
+ recentScanHintIndex = 0
215
+ }
216
+ }
105
217
  log.info("execute tool result", { name, resultType: typeof result, resultLength: resultStr.length, resultPreview: resultStr.slice(0, 300) })
106
218
  // Truncate very large tool results to avoid overwhelming the LLM context
107
219
  if (resultStr.length > 8000) {
@@ -1,6 +1,7 @@
1
1
  import type { CommandModule } from "yargs"
2
2
  import { AIChat } from "../../ai/chat"
3
3
  import { AIProvider } from "../../ai/provider"
4
+ import { Config } from "../../config"
4
5
  import { UI } from "../ui"
5
6
 
6
7
  const DAILY_REPORT_PROMPT = `Generate a 'Day in Code' daily report. Follow these steps exactly:
@@ -65,9 +66,30 @@ export const DailyCommand: CommandModule = {
65
66
  .option("timezone", {
66
67
  describe: "IANA timezone (e.g. Asia/Shanghai)",
67
68
  type: "string",
69
+ })
70
+ .option("schedule-hour", {
71
+ describe: "Set auto-trigger hour (0-23, or -1 to disable). Saves to config without generating a report.",
72
+ type: "number",
68
73
  }),
69
74
  handler: async (args) => {
70
75
  try {
76
+ // Handle --schedule-hour: save config and exit
77
+ if (args.scheduleHour !== undefined) {
78
+ const hour = args.scheduleHour as number
79
+ if (hour < -1 || hour > 23 || !Number.isInteger(hour)) {
80
+ UI.error("--schedule-hour must be an integer from -1 to 23")
81
+ process.exitCode = 1
82
+ return
83
+ }
84
+ await Config.save({ dailyReportHour: hour })
85
+ if (hour < 0) {
86
+ UI.info("Daily report auto-trigger disabled.")
87
+ } else {
88
+ UI.info(`Daily report auto-trigger set to ${String(hour).padStart(2, "0")}:00 local time.`)
89
+ }
90
+ return
91
+ }
92
+
71
93
  const hasKey = await AIProvider.hasAnyKey()
72
94
  if (!hasKey) {
73
95
  UI.warn("No AI provider configured. Daily reports require AI.")
@@ -31,6 +31,7 @@ export namespace Config {
31
31
  active_agents?: Record<string, string>
32
32
  providers?: Record<string, ProviderConfig>
33
33
  feature_flags?: FeatureFlags
34
+ dailyReportHour?: number
34
35
  }
35
36
 
36
37
  const defaults: CodeblogConfig = {
@@ -105,6 +106,11 @@ export namespace Config {
105
106
  return process.env.CODEBLOG_LANGUAGE || (await load()).default_language
106
107
  }
107
108
 
109
+ export async function dailyReportHour(): Promise<number> {
110
+ const val = (await load()).dailyReportHour
111
+ return val !== undefined ? val : 22
112
+ }
113
+
108
114
  function parseBool(raw: string | undefined): boolean | undefined {
109
115
  if (!raw) return undefined
110
116
  const v = raw.trim().toLowerCase()
@@ -437,10 +437,10 @@ export function Home(props: {
437
437
  // Every 30 minutes, check if it's past the configured hour (default 22:00)
438
438
  // and no daily report has been generated today. If so, auto-trigger.
439
439
  {
440
- const DAILY_REPORT_HOUR = 22 // 10 PM local time
441
440
  const CHECK_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
442
441
  const DAILY_REPORT_MAX_ATTEMPTS = 3
443
442
  const DAILY_REPORT_RETRY_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
443
+ const FORCE_DAILY_REGENERATE = process.env.CODEBLOG_DAILY_FORCE_REGENERATE === "1"
444
444
  let dailyReportCompletedDate: string | null = null
445
445
  const dailyReportAttempts = new Map<string, number>()
446
446
  const dailyReportLastAttemptAt = new Map<string, number>()
@@ -480,6 +480,23 @@ export function Home(props: {
480
480
  }
481
481
  }
482
482
 
483
+ const parseCollectStatsResult = (
484
+ raw: string | undefined,
485
+ ): { already_exists?: boolean; no_activity?: boolean } | null => {
486
+ if (!raw) return null
487
+ try {
488
+ const parsed = JSON.parse(raw)
489
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
490
+ const data = parsed as { already_exists?: boolean; no_activity?: boolean }
491
+ return {
492
+ already_exists: !!data.already_exists,
493
+ no_activity: !!data.no_activity,
494
+ }
495
+ } catch {
496
+ return null
497
+ }
498
+ }
499
+
483
500
  const checkDailyReport = async () => {
484
501
  if (dailyReportCheckRunning) return
485
502
  if (!props.hasAI || !props.loggedIn) return
@@ -489,14 +506,20 @@ export function Home(props: {
489
506
 
490
507
  const now = new Date()
491
508
  try {
509
+ const { Config } = await import("../../config")
510
+ const reportHour = await Config.dailyReportHour()
511
+ if (reportHour < 0) return // auto-trigger disabled
512
+
492
513
  const today = localDateKey(now)
493
514
  if (dailyReportCompletedDate === today) return
494
- if (now.getHours() < DAILY_REPORT_HOUR) return
515
+ if (now.getHours() < reportHour) return
495
516
 
496
- const currentStatus = await fetchDailyReportStatus(today)
497
- if (currentStatus === "exists") {
498
- dailyReportCompletedDate = today
499
- return
517
+ if (!FORCE_DAILY_REGENERATE) {
518
+ const currentStatus = await fetchDailyReportStatus(today)
519
+ if (currentStatus === "exists") {
520
+ dailyReportCompletedDate = today
521
+ return
522
+ }
500
523
  }
501
524
 
502
525
  const attempts = dailyReportAttempts.get(today) || 0
@@ -519,21 +542,61 @@ export function Home(props: {
519
542
  )
520
543
 
521
544
  // Use the same prompt as the /daily command
522
- await send(
523
- "Generate my 'Day in Code' daily report. " +
524
- "Start by calling collect_daily_stats, then scan_sessions to find today's sessions, " +
525
- "then analyze_session on the top 2-3 sessions. " +
526
- "Write the post as the AI agent in first person tell the story of your day collaborating with the user. " +
527
- "What did you work on together? What challenges came up? What decisions were made? " +
528
- "The narrative is the main content. Stats are supporting context woven into the story. " +
529
- "Use concise markdown tables in a data-summary section, but do not make the post only tables. " +
530
- "Do NOT include any source code or file paths. " +
531
- "Use category='day-in-code' and tags=['day-in-code']. " +
532
- "This is auto modeproceed directly to confirm_post without waiting for approval. " +
533
- "After publishing, call save_daily_report to persist the stats.",
534
- { display: "Auto-generating daily report (Day in Code)" },
545
+ const runResult = await send(
546
+ "Generate my 'Day in Code' daily report. Follow these steps EXACTLY in order:\n\n" +
547
+ "STEP 1: Call collect_daily_stats to get today's coding activity data.\n" +
548
+ "- If it returns already_exists=true" + (FORCE_DAILY_REGENERATE ? " (force regenerate is enabled, so IGNORE already_exists and continue)" : ", STOP here — report already done") + ".\n" +
549
+ "- If it returns no_activity=true, STOP herenothing to report.\n" +
550
+ "- Save the date, timezone, and _rawStats from the result you need them for save_daily_report later.\n\n" +
551
+ "STEP 2: Call scan_sessions with source='codex' and limit=8.\n" +
552
+ "- From the results, pick the top 2-3 sessions by activity.\n\n" +
553
+ "STEP 3: Call analyze_session on each picked session. Pass BOTH path and source EXACTLY from scan_sessions results.\n\n" +
554
+ "STEP 4: Write the blog post content (DO NOT call any tool yet).\n" +
555
+ "- Write as the AI agent in first person tell the story of your day collaborating with the user.\n" +
556
+ "- The narrative is the main content. Stats are supporting context woven into the story.\n" +
557
+ "- Use concise markdown tables in a data-summary section, but do not make the post only tables.\n" +
558
+ "- Do NOT include any source code or file paths.\n" +
559
+ "- Prepare a catchy title (string), the full content (markdown, must NOT start with the title), and a summary.\n\n" +
560
+ "STEP 5: Call preview_post with ALL of these parameters:\n" +
561
+ " - mode: 'manual'\n" +
562
+ " - title: your catchy title (string, REQUIRED)\n" +
563
+ " - content: your full markdown content (string, REQUIRED, must NOT start with the title)\n" +
564
+ " - category: 'day-in-code'\n" +
565
+ " - tags: ['day-in-code']\n" +
566
+ " CRITICAL: Do NOT call preview_post with empty or missing title/content — it will fail.\n\n" +
567
+ "STEP 6: Call confirm_post with the post_id returned by preview_post. This is scheduled auto mode — do NOT wait for user confirmation.\n\n" +
568
+ "STEP 7: Call save_daily_report with:\n" +
569
+ " - date and timezone from collect_daily_stats result\n" +
570
+ " - post_id from confirm_post result\n" +
571
+ " - _rawStats from collect_daily_stats result\n\n" +
572
+ "FORBIDDEN: Do NOT call post_to_codeblog or auto_post. Only use preview_post + confirm_post.\n" +
573
+ (FORCE_DAILY_REGENERATE
574
+ ? "FORCE MODE: When calling collect_daily_stats, set force=true.\n"
575
+ : "") +
576
+ "This scheduled task MUST complete all 7 steps in this single run.",
577
+ {
578
+ display: "Auto-generating daily report (Day in Code)",
579
+ maxSteps: 16,
580
+ idleTimeoutMs: 240_000,
581
+ toolTimeoutMs: 60_000,
582
+ freshContext: true,
583
+ },
535
584
  )
536
585
 
586
+ const collectStats = parseCollectStatsResult(
587
+ runResult?.toolResults.find((t) => t.name === "collect_daily_stats")?.result,
588
+ )
589
+ if (collectStats?.already_exists && !FORCE_DAILY_REGENERATE) {
590
+ dailyReportCompletedDate = today
591
+ showMsg("Today's Day in Code report already exists.", theme.colors.success)
592
+ return
593
+ }
594
+ if (collectStats?.no_activity) {
595
+ dailyReportCompletedDate = today
596
+ showMsg("No coding activity detected today. Skipping daily report auto-run.", theme.colors.warning)
597
+ return
598
+ }
599
+
537
600
  const afterStatus = await fetchDailyReportStatus(today)
538
601
  if (afterStatus === "exists") {
539
602
  dailyReportCompletedDate = today
@@ -674,7 +737,10 @@ export function Home(props: {
674
737
  setInput((s) => s + text)
675
738
  })
676
739
 
677
- async function send(text: string, options?: { display?: string }) {
740
+ async function send(
741
+ text: string,
742
+ options?: { display?: string; maxSteps?: number; idleTimeoutMs?: number; toolTimeoutMs?: number; freshContext?: boolean },
743
+ ): Promise<{ toolResults: Array<{ name: string; result: string }>; assistantContent?: string } | undefined> {
678
744
  if (!text.trim() || streaming()) return
679
745
  ensureSession()
680
746
  const prompt = text.trim()
@@ -686,6 +752,8 @@ export function Home(props: {
686
752
  abortByUser = false
687
753
  const assembler = new TuiStreamAssembler()
688
754
  const toolResults: Array<{ name: string; result: string }> = []
755
+ let assistantContent: string | undefined
756
+ let streamErrorHandled = false
689
757
  const flushMs = 60
690
758
  let flushTimer: ReturnType<typeof setTimeout> | undefined
691
759
 
@@ -693,7 +761,8 @@ export function Home(props: {
693
761
  if (force) {
694
762
  if (flushTimer) clearTimeout(flushTimer)
695
763
  flushTimer = undefined
696
- setStreamText(assembler.getText())
764
+ const nextText = assembler.getText()
765
+ queueMicrotask(() => setStreamText(nextText))
697
766
  return
698
767
  }
699
768
  if (flushTimer) return
@@ -710,14 +779,19 @@ export function Home(props: {
710
779
  const { resolveModelFromConfig } = await import("../../ai/models")
711
780
  const cfg = await Config.load()
712
781
  const mid = resolveModelFromConfig(cfg) || AIProvider.DEFAULT_MODEL
713
- const allMsgs = [...prev, userMsg]
782
+ const modelInput = options?.freshContext ? [userMsg] : [...prev, userMsg]
783
+ const allMsgs = modelInput
714
784
  .filter((m): m is ChatMsg & { role: "user" | "assistant" } => m.role === "user" || m.role === "assistant")
715
785
  .map((m) => ({ role: m.role, content: m.modelContent || m.content }))
716
786
  abortCtrl = new AbortController()
717
787
 
718
788
  let hasToolCalls = false
719
789
  let finalizedText = ""
720
- for await (const event of AIChat.streamEvents(allMsgs, mid, abortCtrl.signal, { maxSteps: 10 })) {
790
+ for await (const event of AIChat.streamEvents(allMsgs, mid, abortCtrl.signal, {
791
+ maxSteps: options?.maxSteps ?? 12,
792
+ idleTimeoutMs: options?.idleTimeoutMs,
793
+ toolTimeoutMs: options?.toolTimeoutMs,
794
+ })) {
721
795
  if (event.type === "text-delta") {
722
796
  assembler.pushDelta(event.text, event.seq)
723
797
  flushStream()
@@ -728,11 +802,14 @@ export function Home(props: {
728
802
  hasToolCalls = true
729
803
  const partial = assembler.getText().trim()
730
804
  if (partial) {
731
- setMessages((p) => [...p, { role: "assistant", content: partial }])
805
+ queueMicrotask(() => {
806
+ setMessages((p) => [...p, { role: "assistant", content: partial }])
807
+ })
732
808
  assembler.reset()
733
809
  setStreamText("")
734
810
  }
735
- setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[event.name] || event.name, toolName: event.name, toolCallID: event.callID, toolStatus: "running" }])
811
+ const toolMsg: ChatMsg = { role: "tool", content: TOOL_LABELS[event.name] || event.name, toolName: event.name, toolCallID: event.callID, toolStatus: "running" }
812
+ queueMicrotask(() => setMessages((p) => [...p, toolMsg]))
736
813
  continue
737
814
  }
738
815
 
@@ -741,32 +818,40 @@ export function Home(props: {
741
818
  const str = typeof event.result === "string" ? event.result : JSON.stringify(event.result)
742
819
  toolResults.push({ name: event.name, result: str.slice(0, 1200) })
743
820
  } catch {}
744
- setMessages((p) => {
745
- let matched = false
746
- const next = p.map((m) => {
747
- if (m.role !== "tool" || m.toolStatus !== "running") return m
748
- if (m.toolCallID !== event.callID) return m
749
- matched = true
750
- return { ...m, toolStatus: "done" as const }
821
+ const evtCallID = event.callID
822
+ const evtName = event.name
823
+ queueMicrotask(() => {
824
+ setMessages((p) => {
825
+ let matched = false
826
+ const next = p.map((m) => {
827
+ if (m.role !== "tool" || m.toolStatus !== "running") return m
828
+ if (m.toolCallID !== evtCallID) return m
829
+ matched = true
830
+ return { ...m, toolStatus: "done" as const }
831
+ })
832
+ if (matched) return next
833
+ return p.map((m) =>
834
+ m.role === "tool" && m.toolName === evtName && m.toolStatus === "running"
835
+ ? { ...m, toolStatus: "done" as const }
836
+ : m
837
+ )
751
838
  })
752
- if (matched) return next
753
- return p.map((m) =>
754
- m.role === "tool" && m.toolName === event.name && m.toolStatus === "running"
755
- ? { ...m, toolStatus: "done" as const }
756
- : m
757
- )
758
839
  })
759
840
  continue
760
841
  }
761
842
 
762
843
  if (event.type === "error") {
763
- setMessages((p) => {
764
- const updated = p.map((m) =>
765
- m.role === "tool" && m.toolStatus === "running"
766
- ? { ...m, toolStatus: "error" as const }
767
- : m
768
- )
769
- return [...updated, { role: "assistant" as const, content: `Error: ${event.error.message}` }]
844
+ if (streamErrorHandled) continue
845
+ streamErrorHandled = true
846
+ queueMicrotask(() => {
847
+ setMessages((p) => {
848
+ const updated = p.map((m) =>
849
+ m.role === "tool" && m.toolStatus === "running"
850
+ ? { ...m, toolStatus: "error" as const }
851
+ : m
852
+ )
853
+ return [...updated, { role: "assistant" as const, content: `Error: ${event.error.message}` }]
854
+ })
770
855
  })
771
856
  continue
772
857
  }
@@ -781,7 +866,10 @@ export function Home(props: {
781
866
  hasToolCalls,
782
867
  toolResults,
783
868
  })
784
- if (content) setMessages((p) => [...p, { role: "assistant", content }])
869
+ if (content) {
870
+ assistantContent = content
871
+ queueMicrotask(() => setMessages((p) => [...p, { role: "assistant", content }]))
872
+ }
785
873
  }
786
874
  }
787
875
  } catch (err) {
@@ -794,6 +882,7 @@ export function Home(props: {
794
882
  setStreaming(false)
795
883
  saveChat()
796
884
  }
885
+ return { toolResults, assistantContent }
797
886
  }
798
887
 
799
888
  const aiProviderFiltered = createMemo(() => {