✨ 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
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
|
import { addDebugLog } from "./debug-overlay"
|
|
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
|
import type { TextPart, RenderCache } from "../types/message"
|
|
import { getLogger } from "../lib/logger"
|
|
const log = getLogger("session")
|
|
|
|
const markdownRenderCache = new Map<string, RenderCache>()
|
|
|
|
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean) {
|
|
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
|
}
|
|
|
|
interface MarkdownProps {
|
|
part: TextPart
|
|
isDark?: boolean
|
|
size?: "base" | "sm" | "tight"
|
|
disableHighlight?: boolean
|
|
onRendered?: () => void
|
|
instanceId: string
|
|
}
|
|
|
|
export function Markdown(props: MarkdownProps) {
|
|
const [html, setHtml] = createSignal("")
|
|
let containerRef: HTMLDivElement | undefined
|
|
let latestRequestedText = ""
|
|
|
|
const notifyRendered = () => {
|
|
Promise.resolve().then(() => props.onRendered?.())
|
|
}
|
|
|
|
createEffect(() => {
|
|
const part = props.part
|
|
const rawText = typeof part.text === "string" ? part.text : ""
|
|
const text = decodeHtmlEntities(rawText)
|
|
const dark = Boolean(props.isDark)
|
|
const themeKey = dark ? "dark" : "light"
|
|
const highlightEnabled = !props.disableHighlight
|
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "__anonymous__"
|
|
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled)
|
|
|
|
latestRequestedText = text
|
|
|
|
// 1. Check Synchronous Local Cache
|
|
const localCache = part.renderCache
|
|
if (localCache && localCache.text === text && localCache.theme === themeKey) {
|
|
setHtml(localCache.html)
|
|
notifyRendered()
|
|
return
|
|
}
|
|
|
|
// 2. Check Global Cache
|
|
const globalCache = markdownRenderCache.get(cacheKey)
|
|
if (globalCache && globalCache.text === text) {
|
|
setHtml(globalCache.html)
|
|
part.renderCache = globalCache
|
|
notifyRendered()
|
|
return
|
|
}
|
|
|
|
// 3. Throttle/Debounce Rendering for new content
|
|
// We delay the expensive async render to avoid choking the main thread during rapid streaming
|
|
const performRender = async () => {
|
|
if (latestRequestedText !== text) return // Stale
|
|
|
|
try {
|
|
const rendered = await renderMarkdown(text, { suppressHighlight: !highlightEnabled })
|
|
|
|
if (latestRequestedText === text) {
|
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
|
|
setHtml(rendered)
|
|
part.renderCache = cacheEntry
|
|
markdownRenderCache.set(cacheKey, cacheEntry)
|
|
notifyRendered()
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to render markdown:", error)
|
|
if (latestRequestedText === text) {
|
|
setHtml(text) // Fallback
|
|
}
|
|
}
|
|
}
|
|
|
|
// Heuristic: If text length matches cache length + small amount, it's streaming.
|
|
// We can debounce. If it's a huge jump (initial load), render immediately.
|
|
// For now, always debounce slightly to unblock main thread.
|
|
// Using 200ms (was 50ms) for less frequent but smoother updates
|
|
const timerId = setTimeout(performRender, 200)
|
|
|
|
onCleanup(() => clearTimeout(timerId))
|
|
})
|
|
|
|
onMount(() => {
|
|
const handleClick = async (e: Event) => {
|
|
const target = e.target as HTMLElement
|
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
|
|
|
if (copyButton) {
|
|
e.preventDefault()
|
|
const code = copyButton.getAttribute("data-code")
|
|
if (code) {
|
|
const decodedCode = decodeURIComponent(code)
|
|
await navigator.clipboard.writeText(decodedCode)
|
|
const copyText = copyButton.querySelector(".copy-text")
|
|
if (copyText) {
|
|
copyText.textContent = "Copied!"
|
|
setTimeout(() => {
|
|
copyText.textContent = "Copy"
|
|
}, 2000)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
const previewButton = target.closest(".code-block-preview") as HTMLButtonElement
|
|
if (previewButton) {
|
|
e.preventDefault()
|
|
const code = previewButton.getAttribute("data-code")
|
|
const lang = previewButton.getAttribute("data-lang")
|
|
|
|
if (code && lang === "html") {
|
|
const decodedCode = decodeURIComponent(code)
|
|
|
|
// Try to find a filename in the text part
|
|
const contentText = props.part.text || ""
|
|
const fileMatch = contentText.match(/(\w+\.html)/)
|
|
const fileName = fileMatch ? fileMatch[1] : null
|
|
|
|
window.dispatchEvent(new CustomEvent("MANUAL_PREVIEW_EVENT", {
|
|
detail: {
|
|
code: decodedCode,
|
|
fileName: fileName,
|
|
instanceId: props.instanceId
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
containerRef?.addEventListener("click", handleClick)
|
|
|
|
// Register listener for language loading completion
|
|
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
|
if (props.disableHighlight) {
|
|
return
|
|
}
|
|
|
|
const part = props.part
|
|
const rawText = typeof part.text === "string" ? part.text : ""
|
|
const text = decodeHtmlEntities(rawText)
|
|
|
|
if (latestRequestedText !== text) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const rendered = await renderMarkdown(text)
|
|
if (latestRequestedText === text) {
|
|
setHtml(rendered)
|
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
|
part.renderCache = { text, html: rendered, theme: themeKey }
|
|
notifyRendered()
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to re-render markdown after language load:", error)
|
|
}
|
|
})
|
|
|
|
onCleanup(() => {
|
|
containerRef?.removeEventListener("click", handleClick)
|
|
cleanupLanguageListener()
|
|
})
|
|
})
|
|
|
|
const proseClass = () => "markdown-body"
|
|
|
|
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
|
}
|