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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user