feat: portable mode - chat works without fork

- SSE streaming pipeline: send-stream → openaiChat → chat-store → UI
- Session key normalization: all portable mode uses 'main'
- Conversation history: last 20 messages sent with each request
- localStorage persistence: messages survive page refresh
- Thinking/content separation: Qwen3 reasoning → thinking events
- Auto-start guard: skip fork when HERMES_API_URL is external
- Legacy message normalization: ensureAssistantTextContent
- Streaming text handoff: completedStreamingTextRef bridges done→render
- Buffer cleanup disabled in portable mode (prevents message disappear)
- Test page: /test-streaming.html for direct SSE testing

Known issues:
- waitingForResponse handoff sometimes sticks (refresh fixes)
- Model pill shows wrong model name
- ClawSuite gateway banner unrelated
This commit is contained in:
outsourc-e
2026-03-31 22:03:27 -04:00
parent 3c6160a402
commit d02d8bab1c
14 changed files with 575 additions and 142 deletions

View File

@@ -78,6 +78,13 @@ const config = defineConfig(({ mode, command }) => {
const startHermesAgent = async () => {
if (hermesAgentStarted) return
// Skip auto-start when HERMES_API_URL is explicitly set to a non-local endpoint
const explicitUrl = env.HERMES_API_URL || process.env.HERMES_API_URL || hermesApiUrl || ''
if (explicitUrl && explicitUrl !== 'http://127.0.0.1:8642' && explicitUrl !== 'http://localhost:8642') {
console.log(`[hermes-agent] Skipping auto-start — using external API: ${explicitUrl}`)
hermesAgentStarted = true
return
}
if (await isHermesAgentHealthy()) {
console.log('[hermes-agent] Already running — reusing existing process')
hermesAgentStarted = true
@@ -449,6 +456,34 @@ const config = defineConfig(({ mode, command }) => {
return
}
// Portable-aware health check — returns ok if any chat backend is available
if (req.method === 'GET' && requestPath === '/api/connection-status') {
try {
// Check if the configured backend has /v1/models (works for Ollama, OpenAI, etc.)
const modelsRes = await fetch(`${hermesApiUrl}/v1/models`, {
signal: AbortSignal.timeout(3000),
})
if (modelsRes.ok) {
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: true, mode: 'portable', backend: hermesApiUrl }))
return
}
// Fall back to /health for full Hermes backends
const healthRes = await fetch(`${hermesApiUrl}/health`, {
signal: AbortSignal.timeout(3000),
})
res.statusCode = healthRes.ok ? 200 : 502
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: healthRes.ok, mode: 'enhanced', backend: hermesApiUrl }))
} catch {
res.statusCode = 502
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: false, mode: 'disconnected', backend: hermesApiUrl }))
}
return
}
if (
req.method !== 'POST' ||
requestPath !== '/api/workspace/daemon/restart'