v0.5.0: Binary-Free Mode - No OpenCode binary required

 Major Features:
- Native session management without OpenCode binary
- Provider routing: OpenCode Zen (free), Qwen OAuth, Z.AI
- Streaming chat with tool execution loop
- Mode detection API (/api/meta/mode)
- MCP integration fix (resolved infinite loading)
- NomadArch Native option in UI with comparison info

🆓 Free Models (No API Key):
- GPT-5 Nano (400K context)
- Grok Code Fast 1 (256K context)
- GLM-4.7 (205K context)
- Doubao Seed Code (256K context)
- Big Pickle (200K context)

📦 New Files:
- session-store.ts: Native session persistence
- native-sessions.ts: REST API for sessions
- lite-mode.ts: UI mode detection client
- native-sessions.ts (UI): SolidJS store

🔧 Updated:
- All installers: Optional binary download
- All launchers: Mode detection display
- Binary selector: Added NomadArch Native option
- README: Binary-Free Mode documentation
This commit is contained in:
Gemini AI
2025-12-26 02:08:13 +04:00
Unverified
parent 8dddf4d0cf
commit 4bd2893864
83 changed files with 10678 additions and 1290 deletions

View File

@@ -1,5 +1,7 @@
import { untrack, batch } from "solid-js"
import { addDebugLog } from "../components/debug-overlay"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances"
import { instances, activeInstanceId } from "./instances"
import { addTaskMessage } from "./task-actions"
import { addRecentModelPreference, setAgentModelPreference, getAgentModelPreference } from "./preferences"
@@ -36,7 +38,8 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
const COMPACTION_SUMMARY_MAX_CHARS = 4000
const STREAM_TIMEOUT_MS = 120_000
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
export const BUILD_PREVIEW_EVENT = "opencode:build-preview"
export const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
function markOpencodeZenModelOffline(modelId: string): void {
if (typeof window === "undefined" || !modelId) return
@@ -234,6 +237,8 @@ async function checkTokenBudgetBeforeSend(
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
const MAX_ATTACHMENT_CHARS = 8000
const MAX_CONTEXT_MESSAGES = 100
const MAX_MESSAGES_FOR_YIELD = 50
function shouldForceEnglish(prompt: string): boolean {
const text = prompt.trim()
@@ -270,6 +275,12 @@ function clampText(value: string, maxChars: number): string {
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
}
async function yieldIfNeeded(index: number): Promise<void> {
if (index > 0 && index % MAX_MESSAGES_FOR_YIELD === 0) {
await new Promise(resolve => setTimeout(resolve, 0))
}
}
async function buildSkillsSystemInstruction(instanceId: string, sessionId: string): Promise<string | undefined> {
const session = sessions().get(instanceId)?.get(sessionId)
const selected = session?.skills ?? []
@@ -290,17 +301,42 @@ async function buildSkillsSystemInstruction(instanceId: string, sessionId: strin
return `You have access to the following skills. Follow their instructions when relevant.\n\n${payload}`
}
async function buildFileSystemContext(instanceId: string): Promise<string | undefined> {
try {
const files = await serverApi.listWorkspaceFiles(instanceId)
if (!files || files.length === 0) return undefined
// Sort directories first
const sorted = files.sort((a: any, b: any) => {
const aDir = a.isDirectory || a.type === "directory"
const bDir = b.isDirectory || b.type === "directory"
if (aDir === bDir) return (a.name || "").localeCompare(b.name || "")
return aDir ? -1 : 1
})
const list = sorted.map((f: any) => {
const isDir = f.isDirectory || f.type === "directory"
return isDir ? `${f.name}/` : f.name
}).join("\n")
return `## Project Context\nCurrent Workspace Directory:\n\`\`\`\n${list}\n\`\`\`\nYou are an expert software architect working in this project. Use standard tools to explore further.`
} catch (error) {
return undefined
}
}
async function mergeSystemInstructions(
instanceId: string,
sessionId: string,
prompt: string,
): Promise<string | undefined> {
const [languageSystem, skillsSystem] = await Promise.all([
const [languageSystem, skillsSystem, projectContext] = await Promise.all([
Promise.resolve(buildLanguageSystemInstruction(prompt)),
buildSkillsSystemInstruction(instanceId, sessionId),
buildFileSystemContext(instanceId),
])
const sshInstruction = buildSshPasswordInstruction(prompt)
const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
const sections = [projectContext, languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
if (sections.length === 0) return undefined
return sections.join("\n\n")
}
@@ -346,32 +382,40 @@ function extractPlainTextFromParts(
return segments.join("\n").trim()
}
function buildExternalChatMessages(
async function buildExternalChatMessages(
instanceId: string,
sessionId: string,
systemMessage?: string,
): ExternalChatMessage[] {
const store = messageStoreBus.getOrCreate(instanceId)
const messageIds = store.getSessionMessageIds(sessionId)
const messages: ExternalChatMessage[] = []
): Promise<ExternalChatMessage[]> {
return untrack(async () => {
const store = messageStoreBus.getOrCreate(instanceId)
const messageIds = store.getSessionMessageIds(sessionId)
const messages: ExternalChatMessage[] = []
if (systemMessage) {
messages.push({ role: "system", content: systemMessage })
}
if (systemMessage) {
messages.push({ role: "system", content: systemMessage })
}
for (const messageId of messageIds) {
const record = store.getMessage(messageId)
if (!record) continue
const { orderedParts } = buildRecordDisplayData(instanceId, record)
const content = extractPlainTextFromParts(orderedParts as Array<{ type?: string; text?: unknown; filename?: string }>)
if (!content) continue
messages.push({
role: record.role === "assistant" ? "assistant" : "user",
content,
})
}
const limitedMessageIds = messageIds.length > MAX_CONTEXT_MESSAGES
? messageIds.slice(-MAX_CONTEXT_MESSAGES)
: messageIds
return messages
for (let i = 0; i < limitedMessageIds.length; i++) {
const messageId = limitedMessageIds[i]
await yieldIfNeeded(i)
const record = store.getMessage(messageId)
if (!record) continue
const { orderedParts } = buildRecordDisplayData(instanceId, record)
const content = extractPlainTextFromParts(orderedParts as Array<{ type?: string; text?: unknown; filename?: string }>)
if (!content) continue
messages.push({
role: record.role === "assistant" ? "assistant" : "user",
content,
})
}
return messages
})
}
function decodeAttachmentData(data: Uint8Array): string {
@@ -391,7 +435,7 @@ async function buildExternalChatMessagesWithAttachments(
systemMessage: string | undefined,
attachments: Array<{ filename?: string; source?: any; mediaType?: string }>,
): Promise<ExternalChatMessage[]> {
const baseMessages = buildExternalChatMessages(instanceId, sessionId, systemMessage)
const baseMessages = await buildExternalChatMessages(instanceId, sessionId, systemMessage)
if (!attachments || attachments.length === 0) {
return baseMessages
}
@@ -455,6 +499,8 @@ async function readSseStream(
resetIdleTimer()
try {
let chunkCount = 0
let lastYieldTime = performance.now()
while (!shouldStop) {
const { done, value } = await reader.read()
if (done) break
@@ -473,9 +519,21 @@ async function readSseStream(
break
}
onData(data)
chunkCount++
}
// Throttle UI updates: yield control if time elapsed > 16ms to prevent frame drops
const now = performance.now()
if (now - lastYieldTime > 16) {
addDebugLog(`Yielding after ${Math.round(now - lastYieldTime)}ms (chunks: ${chunkCount})`, "info")
lastYieldTime = now
if ('requestIdleCallback' in window) {
await new Promise<void>(resolve => {
requestIdleCallback(() => resolve(), { timeout: 16 })
})
} else {
await new Promise<void>(resolve => setTimeout(resolve, 0))
}
}
// Yield to main thread periodically to prevent UI freeze during rapid streaming
await new Promise<void>(resolve => setTimeout(resolve, 0))
}
if (timedOut) {
throw new Error("Stream timed out")
@@ -499,6 +557,10 @@ async function streamOllamaChat(
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
// Get workspace path for tool execution
const instance = instances().get(instanceId)
const workspacePath = instance?.folder || ""
const response = await fetch("/api/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -507,6 +569,8 @@ async function streamOllamaChat(
model: modelId,
messages,
stream: true,
workspacePath,
enableTools: true,
}),
})
@@ -516,54 +580,105 @@ async function streamOllamaChat(
}
const store = messageStoreBus.getOrCreate(instanceId)
store.beginStreamingUpdate()
let fullText = ""
let lastUpdateAt = 0
try {
await readSseStream(response, (data) => {
try {
const chunk = JSON.parse(data)
// Check for error response from server
if (chunk?.error) {
throw new Error(chunk.error)
if (chunk?.error) throw new Error(chunk.error)
// Handle tool execution results (special events from backend)
if (chunk?.type === "tool_result") {
const toolResult = `\n\n✅ **Tool Executed:** ${chunk.content}\n\n`
fullText += toolResult
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
// Dispatch file change event to refresh sidebar
if (typeof window !== "undefined") {
console.log(`[EVENT] Dispatching FILE_CHANGE_EVENT for ${instanceId}`);
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId } }))
}
// Auto-trigger preview for HTML file writes
const content = chunk.content || ""
if (content.includes("Successfully wrote") &&
(content.includes(".html") || content.includes("index.") || content.includes(".htm"))) {
if (typeof window !== "undefined") {
const htmlMatch = content.match(/to\s+([^\s]+\.html?)/)
if (htmlMatch) {
const relativePath = htmlMatch[1]
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"
const apiOrigin = origin.replace(":3000", ":9898")
const previewUrl = `${apiOrigin}/api/workspaces/${instanceId}/serve/${relativePath}`
console.log(`[EVENT] Auto-preview triggered for ${previewUrl}`);
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, {
detail: { url: previewUrl, instanceId }
}))
}
}
}
return
}
const delta = chunk?.message?.content
if (typeof delta !== "string" || delta.length === 0) return
fullText += delta
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
const now = Date.now()
if (now - lastUpdateAt > 150) { // Limit to ~7 updates per second
lastUpdateAt = now
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
}
} catch (e) {
if (e instanceof Error) throw e
// Ignore malformed chunks
}
})
// Always apply final text update
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
} finally {
clearTimeout(timeoutId)
store.endStreamingUpdate()
}
store.upsertMessage({
id: assistantMessageId,
sessionId,
role: "assistant",
status: "complete",
updatedAt: Date.now(),
isEphemeral: false,
})
store.setMessageInfo(assistantMessageId, {
id: assistantMessageId,
role: "assistant",
providerID: providerId,
modelID: modelId,
time: { created: store.getMessageInfo(assistantMessageId)?.time?.created ?? Date.now(), completed: Date.now() },
} as any)
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "sent",
updatedAt: Date.now(),
isEphemeral: false,
batch(() => {
store.upsertMessage({
id: assistantMessageId,
sessionId,
role: "assistant",
status: "complete",
updatedAt: Date.now(),
isEphemeral: false,
})
store.setMessageInfo(assistantMessageId, {
id: assistantMessageId,
role: "assistant",
providerID: providerId,
modelID: modelId,
time: { created: store.getMessageInfo(assistantMessageId)?.time?.created ?? Date.now(), completed: Date.now() },
} as any)
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "sent",
updatedAt: Date.now(),
isEphemeral: false,
})
})
}
@@ -582,6 +697,10 @@ async function streamQwenChat(
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
// Get workspace path for tool execution
const instance = instances().get(instanceId)
const workspacePath = instance?.folder || ""
const response = await fetch("/api/qwen/chat", {
method: "POST",
headers: {
@@ -594,6 +713,8 @@ async function streamQwenChat(
messages,
stream: true,
resource_url: resourceUrl,
workspacePath,
enableTools: true,
}),
})
@@ -603,27 +724,86 @@ async function streamQwenChat(
}
const store = messageStoreBus.getOrCreate(instanceId)
store.beginStreamingUpdate()
let fullText = ""
let lastUpdateAt = 0
try {
await readSseStream(response, (data) => {
try {
const chunk = JSON.parse(data)
// Handle tool execution results
if (chunk?.type === "tool_result") {
const toolResult = `\n\n✅ **Tool Executed:** ${chunk.content}\n\n`
fullText += toolResult
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
// Dispatch file change event to refresh sidebar
if (typeof window !== "undefined") {
console.log(`[Qwen] Dispatching FILE_CHANGE_EVENT for ${instanceId}`);
console.log(`[EVENT] Dispatching FILE_CHANGE_EVENT for ${instanceId}`);
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId } }));
// Double-tap refresh after 1s to catch FS latency
setTimeout(() => {
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId } }));
}, 1000);
}
// Auto-trigger preview for HTML file writes
const content = chunk.content || ""
if (content.includes("Successfully wrote") &&
(content.includes(".html") || content.includes("index.") || content.includes(".htm"))) {
if (typeof window !== "undefined") {
const htmlMatch = content.match(/to\s+([^\s]+\.html?)/)
if (htmlMatch) {
const relativePath = htmlMatch[1]
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"
const apiOrigin = origin.replace(":3000", ":9898")
const previewUrl = `${apiOrigin}/api/workspaces/${instanceId}/serve/${relativePath}`
console.log(`[Qwen] Auto-preview triggered for ${relativePath}`);
console.log(`[EVENT] Auto-preview triggered for ${previewUrl}`);
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, {
detail: { url: previewUrl, instanceId }
}))
}
}
}
return
}
const delta =
chunk?.choices?.[0]?.delta?.content ??
chunk?.choices?.[0]?.message?.content
if (typeof delta !== "string" || delta.length === 0) return
fullText += delta
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
const now = Date.now()
if (now - lastUpdateAt > 40) { // Limit to ~25 updates per second
lastUpdateAt = now
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
}
} catch {
// Ignore malformed chunks
}
})
// Always apply final text update
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
} finally {
clearTimeout(timeoutId)
store.endStreamingUpdate()
}
store.upsertMessage({
@@ -664,6 +844,10 @@ async function streamOpenCodeZenChat(
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
// Get workspace path for tool execution
const instance = instances().get(instanceId)
const workspacePath = instance?.folder || ""
const response = await fetch("/api/opencode-zen/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -672,6 +856,8 @@ async function streamOpenCodeZenChat(
model: modelId,
messages,
stream: true,
workspacePath,
enableTools: true,
}),
})
@@ -681,7 +867,9 @@ async function streamOpenCodeZenChat(
}
const store = messageStoreBus.getOrCreate(instanceId)
store.beginStreamingUpdate()
let fullText = ""
let lastUpdateAt = 0
try {
await readSseStream(response, (data) => {
@@ -690,23 +878,78 @@ async function streamOpenCodeZenChat(
if (chunk?.error) {
throw new Error(typeof chunk.error === "string" ? chunk.error : "OpenCode Zen streaming error")
}
// Handle tool execution results (special events from backend)
if (chunk?.type === "tool_result") {
const toolResult = `\n\n✅ **Tool Executed:** ${chunk.content}\n\n`
fullText += toolResult
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
// Dispatch file change event to refresh sidebar
if (typeof window !== "undefined") {
console.log(`[Ollama] Dispatching FILE_CHANGE_EVENT for ${instanceId}`);
console.log(`[EVENT] Dispatching FILE_CHANGE_EVENT for ${instanceId}`);
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId } }))
}
// Auto-trigger preview for HTML file writes
const content = chunk.content || ""
if (content.includes("Successfully wrote") &&
(content.includes(".html") || content.includes("index.") || content.includes(".htm"))) {
if (typeof window !== "undefined") {
const htmlMatch = content.match(/to\s+([^\s]+\.html?)/)
if (htmlMatch) {
const relativePath = htmlMatch[1]
// USE PROXY URL instead of file:// to avoid "Not allowed to load local resource"
// The backend (port 9898) serves workspace files via /api/workspaces/:id/serve
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"
const apiOrigin = origin.replace(":3000", ":9898") // Fallback assumption
const previewUrl = `${apiOrigin}/api/workspaces/${instanceId}/serve/${relativePath}`
console.log(`[Ollama] Auto-preview triggered for ${relativePath}`);
console.log(`[EVENT] Auto-preview triggered for ${previewUrl}`);
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, {
detail: { url: previewUrl, instanceId }
}))
}
}
}
return
}
const delta =
chunk?.choices?.[0]?.delta?.content ??
chunk?.choices?.[0]?.message?.content
if (typeof delta !== "string" || delta.length === 0) return
fullText += delta
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
const now = Date.now()
if (now - lastUpdateAt > 40) { // Limit to ~25 updates per second
lastUpdateAt = now
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
}
} catch (error) {
if (error instanceof Error) {
throw error
}
}
})
// Always apply final text update
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
} finally {
clearTimeout(timeoutId)
store.endStreamingUpdate()
}
@@ -748,6 +991,10 @@ async function streamZAIChat(
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
// Get workspace path for tool execution
const instance = instances().get(instanceId)
const workspacePath = instance?.folder || ""
const response = await fetch("/api/zai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -756,6 +1003,8 @@ async function streamZAIChat(
model: modelId,
messages,
stream: true,
workspacePath,
enableTools: true,
}),
})
@@ -765,32 +1014,81 @@ async function streamZAIChat(
}
const store = messageStoreBus.getOrCreate(instanceId)
store.beginStreamingUpdate()
let fullText = ""
let lastUpdateAt = 0
try {
await readSseStream(response, (data) => {
try {
const chunk = JSON.parse(data)
// Check for error response from server
if (chunk?.error) {
throw new Error(chunk.error)
if (chunk?.error) throw new Error(chunk.error)
// Handle tool execution results (special events from backend)
if (chunk?.type === "tool_result") {
const toolResult = `\n\n✅ **Tool Executed:** ${chunk.content}\n\n`
fullText += toolResult
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
// Dispatch file change event to refresh sidebar
if (typeof window !== "undefined") {
console.log(`[EVENT] Dispatching FILE_CHANGE_EVENT for ${instanceId}`);
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId } }))
}
// Auto-trigger preview for HTML file writes
const content = chunk.content || ""
if (content.includes("Successfully wrote") &&
(content.includes(".html") || content.includes("index.") || content.includes(".htm"))) {
if (typeof window !== "undefined") {
const htmlMatch = content.match(/to\s+([^\s]+\.html?)/)
if (htmlMatch) {
const relativePath = htmlMatch[1]
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"
const apiOrigin = origin.replace(":3000", ":9898")
const previewUrl = `${apiOrigin}/api/workspaces/${instanceId}/serve/${relativePath}`
console.log(`[EVENT] Auto-preview triggered for ${previewUrl}`);
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, {
detail: { url: previewUrl, instanceId }
}))
}
}
}
return
}
const delta =
chunk?.choices?.[0]?.delta?.content ??
chunk?.choices?.[0]?.message?.content
if (typeof delta !== "string" || delta.length === 0) return
fullText += delta
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
const now = Date.now()
if (now - lastUpdateAt > 40) { // Limit to ~25 updates per second
lastUpdateAt = now
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
}
} catch (e) {
if (e instanceof Error) throw e
// Ignore malformed chunks
}
})
// Always apply final text update
store.applyPartUpdate({
messageId: assistantMessageId,
part: { id: assistantPartId, type: "text", text: fullText } as any,
})
} finally {
clearTimeout(timeoutId)
store.endStreamingUpdate()
}
store.upsertMessage({
@@ -941,15 +1239,17 @@ async function sendMessage(
log.info("sendMessage: upserting optimistic message", { messageId, sessionId, taskId });
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "sending",
parts: optimisticParts,
createdAt,
updatedAt: createdAt,
isEphemeral: true,
untrack(() => {
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "sending",
parts: optimisticParts,
createdAt,
updatedAt: createdAt,
isEphemeral: true,
})
})
withSession(instanceId, sessionId, () => {
@@ -957,47 +1257,62 @@ async function sendMessage(
})
const providerId = effectiveModel.providerId
const systemMessage = await mergeSystemInstructions(instanceId, sessionId, prompt)
const tPre1 = performance.now()
const systemMessage = await untrack(() => mergeSystemInstructions(instanceId, sessionId, prompt))
const tPre2 = performance.now()
if (tPre2 - tPre1 > 10) {
addDebugLog(`Merge System Instructions: ${Math.round(tPre2 - tPre1)}ms`, "warn")
}
if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai") {
const store = messageStoreBus.getOrCreate(instanceId)
const now = Date.now()
const assistantMessageId = createId("msg")
const assistantPartId = createId("part")
const tMsg1 = performance.now()
const externalMessages = await buildExternalChatMessagesWithAttachments(
instanceId,
sessionId,
systemMessage,
attachments,
)
const tMsg2 = performance.now()
if (tMsg2 - tMsg1 > 10) {
addDebugLog(`Build External Messages: ${Math.round(tMsg2 - tMsg1)}ms`, "warn")
}
store.upsertMessage({
id: assistantMessageId,
sessionId,
role: "assistant",
status: "streaming",
parts: [{ id: assistantPartId, type: "text", text: "" } as any],
createdAt: now,
updatedAt: now,
isEphemeral: true,
})
store.setMessageInfo(assistantMessageId, {
id: assistantMessageId,
role: "assistant",
providerID: effectiveModel.providerId,
modelID: effectiveModel.modelId,
time: { created: now, completed: 0 },
} as any)
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "sent",
updatedAt: now,
isEphemeral: false,
untrack(() => {
store.upsertMessage({
id: assistantMessageId,
sessionId,
role: "assistant",
status: "streaming",
parts: [{ id: assistantPartId, type: "text", text: "" } as any],
createdAt: now,
updatedAt: now,
isEphemeral: true,
})
store.setMessageInfo(assistantMessageId, {
id: assistantMessageId,
role: "assistant",
providerID: effectiveModel.providerId,
modelID: effectiveModel.modelId,
time: { created: now, completed: 0 },
} as any)
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "sent",
updatedAt: now,
isEphemeral: false,
})
})
try {
if (providerId === "ollama-cloud") {
const tStream1 = performance.now()
await streamOllamaChat(
instanceId,
sessionId,
@@ -1008,6 +1323,8 @@ async function sendMessage(
assistantMessageId,
assistantPartId,
)
const tStream2 = performance.now()
addDebugLog(`Stream Complete: ${Math.round(tStream2 - tStream1)}ms`, "info")
} else if (providerId === "opencode-zen") {
await streamOpenCodeZenChat(
instanceId,
@@ -1370,7 +1687,7 @@ async function compactSession(instanceId: string, sessionId: string): Promise<Co
const tasksCopy = session.tasks.map((task) => ({ ...task }))
withSession(instanceId, compactedSession.id, (nextSession) => {
nextSession.tasks = tasksCopy
nextSession.activeTaskId = undefined
nextSession.activeTaskId = session.activeTaskId
})
}
@@ -1632,6 +1949,48 @@ async function forkSession(instanceId: string, sessionId: string): Promise<strin
}
}
// Forcefully reset streaming state to unlock UI if stuck
function forceReset() {
const store = messageStoreBus.getOrCreate(activeInstanceId() || "")
if (!store) return
// Reset streaming count forcefully
// We don't have direct access to set count to 0, so we call end enough times
// or we assume we can just ignore it for now, but really we should expose a reset method.
// For now, let's just log and clear pending parts.
store.setState("pendingParts", {})
// If we could access the store's internal streaming count setter that would be better.
// Since we added `isStreaming` and `endStreamingUpdate` to store interface,
// we can just call end multiple times if we suspect it's stuck > 0
let safety = 0
while (store.state.streamingUpdateCount > 0 && safety < 100) {
store.endStreamingUpdate()
safety++
}
// Also reset message statuses
try {
const messages = store.state.messages;
Object.values(messages).forEach(msg => {
if (msg.status === "streaming" || msg.status === "sending") {
store.upsertMessage({
id: msg.id,
sessionId: msg.sessionId,
role: msg.role,
status: "interrupted",
updatedAt: Date.now(),
isEphemeral: msg.isEphemeral,
})
}
})
} catch (e) {
console.error("Error updating message status during reset", e)
}
addDebugLog("Force Reset Triggered: Cleared streaming state & statuses", "warn")
}
export {
abortSession,
compactSession,
@@ -1644,4 +2003,5 @@ export {
updateSessionAgent,
updateSessionModel,
updateSessionModelForSession,
forceReset, // Add to exports
}