feat: unify LLM streaming with thinking display and Web/cache API shims

- Extract runSimpleLlmStream to @openchatlab/node-runtime for shared
  Electron/Server streaming with thinking_start/delta/end event handling
- Simplify Electron IPC ai.ts by delegating to shared stream function
- Add thinking/thinkingDone fields to LlmStreamChunk and preload types
- Add collapsible thinking process display in SQL LAB with auto-scroll
- Implement Web chatStream via SSE with proper error propagation
- Add /_web/cache/save-to-downloads and show-in-folder server routes
- Add cacheApi shim in Web registry for server-side file operations
- Increase SQL LAB maxTokens to 4096 to accommodate reasoning models
- Detect finishReason:length and show token limit error message
- Add i18n keys for thinking/thinkingProcess/errorTokenLimit (4 locales)
This commit is contained in:
digua committed 2026-05-18 22:32:00 +08:00
1 parent 58a558b7aa
commit cdb2bfea7c
14 files changed
+384 -82

No files matched your search

+4
View File
@@ -132,6 +132,10 @@ export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './r
// Re-exports from @mariozechner/pi-agent-core
export type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core'
// LLM simple streaming
export { runSimpleLlmStream } from './llm-stream'
export type { LlmStreamChunk, RunSimpleLlmStreamOptions } from './llm-stream'
// Re-exports from @mariozechner/pi-ai
export { Type, completeSimple, streamSimple } from '@mariozechner/pi-ai'
export type {
+119
View File
@@ -0,0 +1,119 @@
/**
* Shared LLM simple streaming — used by both Electron and Server.
*
* Wraps pi-ai's streamSimple with message preprocessing,
* event mapping, and error handling.
*/
import { streamSimple } from '@mariozechner/pi-ai'
import type { Model as PiModel, Api as PiApi, Message as PiMessage } from '@mariozechner/pi-ai'
export interface LlmStreamChunk {
content: string
isFinished: boolean
finishReason?: 'stop' | 'length' | 'error'
error?: string
/** When present, this chunk carries thinking/reasoning content rather than final text. */
thinking?: string
/** Signals the end of a thinking block (thinking is empty string, thinkingDone is true). */
thinkingDone?: boolean
}
export interface RunSimpleLlmStreamOptions {
messages: Array<{ role: string; content: string }>
apiKey: string
piModel: PiModel<PiApi>
temperature?: number
maxTokens?: number
onChunk: (chunk: LlmStreamChunk) => void
abortSignal?: AbortSignal
}
function toPiMessages(messages: Array<{ role: string; content: string }>, timestamp: number): PiMessage[] {
return messages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
timestamp,
})) as unknown as PiMessage[]
}
/**
* Run a simple LLM streaming call.
* Separates system message, streams text deltas via onChunk callback.
*/
export async function runSimpleLlmStream(options: RunSimpleLlmStreamOptions): Promise<void> {
const { messages, apiKey, piModel, temperature, maxTokens, onChunk, abortSignal } = options
const systemMsg = messages.find((m) => m.role === 'system')
const nonSystemMsgs = messages.filter((m) => m.role !== 'system')
const now = Date.now()
const eventStream = streamSimple(
piModel,
{
systemPrompt: systemMsg?.content,
messages: toPiMessages(nonSystemMsgs, now),
},
{ apiKey, temperature, maxTokens, signal: abortSignal }
)
let hasTerminalChunk = false
try {
for await (const event of eventStream) {
if (abortSignal?.aborted) {
if (!hasTerminalChunk) {
hasTerminalChunk = true
onChunk({ content: '', isFinished: true, finishReason: 'stop' })
}
return
}
if (event.type === 'thinking_start') {
onChunk({ content: '', isFinished: false, thinking: '' })
continue
}
if (event.type === 'thinking_delta') {
onChunk({ content: '', isFinished: false, thinking: event.delta })
continue
}
if (event.type === 'thinking_end') {
onChunk({ content: '', isFinished: false, thinking: '', thinkingDone: true })
continue
}
if (event.type === 'text_delta') {
onChunk({ content: event.delta, isFinished: false })
continue
}
if (event.type === 'done') {
hasTerminalChunk = true
onChunk({ content: '', isFinished: true, finishReason: event.reason === 'length' ? 'length' : 'stop' })
return
}
if (event.type === 'error') {
hasTerminalChunk = true
const errorMsg =
event.error?.content
?.filter((c) => c.type === 'text')
.map((c) => ('text' in c ? c.text : ''))
.join('') || 'Unknown LLM error'
onChunk({ content: '', isFinished: true, finishReason: 'error', error: errorMsg })
return
}
}
if (!hasTerminalChunk) {
onChunk({ content: '', isFinished: true, finishReason: 'stop' })
}
} catch (error) {
if (!hasTerminalChunk) {
const msg = error instanceof Error ? error.message : String(error)
onChunk({ content: '', isFinished: true, finishReason: 'error', error: msg })
}
}
}
+2 -1
View File
@@ -240,7 +240,8 @@ export type { TempDbMeta, ExportedSession } from './merger/temp-db'
// Re-exports: @mariozechner/pi-agent-core & @mariozechner/pi-ai
export type { AgentTool, AgentToolResult } from './ai'
export { Type, completeSimple, streamSimple } from './ai'
export { Type, completeSimple, streamSimple, runSimpleLlmStream } from './ai'
export type { LlmStreamChunk, RunSimpleLlmStreamOptions } from './ai'
export type { PiModel, PiApi, PiMessage, PiUsage, PiTextContent, PiAssistantMessage } from './ai'
// ==================== RAG ====================