Compare commits

..

5 Commits

Author SHA1 Message Date
Shantur
197dee2aea Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 00:22:32 +01:00
Shantur
045d8da8b2 feat(voice): add spoken summary mode for conversation replies 2026-03-31 00:20:26 +01:00
Pascal André
c9bd4b7395 fix(tauri): stop stale UI assets from shadowing desktop builds (#258)
## Summary
- prefer the bundled desktop UI over the downloaded cache when both
report the same version, so rebuilt installers do not keep serving stale
frontend assets
- rebuild the server workspace during the Tauri prebuild step on every
desktop package build, matching Electron's correctness boundary for
fresh UI/server assets
- add a regression test covering the equal-version bundled-vs-downloaded
UI selection path

## Why
- local desktop rebuilds should reflect the latest server and UI code
without requiring users to manually clear cached assets
- packaged updates should keep favoring the freshly bundled frontend
when the cached copy is not actually newer

## Testing
- node --import tsx --test
packages/server/src/ui/__tests__/remote-ui.test.ts
- npm run build:tauri
2026-03-30 20:54:29 +01:00
Pascal André
41a5026331 fix(tauri): sync native app version with package releases (#257)
## Summary
- sync the Tauri native version metadata from
`packages/tauri-app/package.json` so release builds pick up workspace
version bumps like `0.13.1`
- update the checked-in Tauri `Cargo.toml` and `tauri.conf.json`
versions from `0.12.3` to `0.13.1`
- document the prebuild sync behavior in `BUILD.md`

## Testing
- `node packages/tauri-app/scripts/sync-tauri-version.js`
2026-03-30 20:52:37 +01:00
codenomadbot[bot]
d1a27ac31b fix(ui): escape raw HTML in user prompt messages (#260)
## Summary
- escape raw HTML when rendering user message markdown so prompt input
is shown as text instead of injected HTML
- keep assistant and tool markdown behavior unchanged by scoping the
escape behavior to user messages
- update markdown cache keys so escaped and non-escaped render output do
not collide

## Verification
- `npm run typecheck --workspace @codenomad/ui` *(fails in this
workspace because frontend dependencies are not installed)*
- `npm run build --workspace @codenomad/ui` *(fails in this workspace
because `vite` is not installed)*

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur <shantur@Mac.home>
2026-03-30 08:48:52 +01:00
15 changed files with 261 additions and 19 deletions

View File

@@ -22,7 +22,7 @@
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app", "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app", "build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version" "bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app"
}, },
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",

View File

@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process" import { createBackgroundProcessTools } from "./lib/background-process"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) { export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig() const config = getCodeNomadConfig()
const client = createCodeNomadClient(config) const client = createCodeNomadClient(config)
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
pingTs: (event.properties as any)?.ts, pingTs: (event.properties as any)?.ts,
}, },
}).catch(() => {}) }).catch(() => {})
return
}
if (event.type === "codenomad.voiceMode") {
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
} }
}) })
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
tool: { tool: {
...backgroundProcessTools, ...backgroundProcessTools,
}, },
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
return
}
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
},
async event(input: { event: any }) { async event(input: { event: any }) {
const opencodeEvent = input?.event const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return if (!opencodeEvent || typeof opencodeEvent !== "object") return
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
}, },
} }
} }
function buildVoiceModePrompt(): string {
return [
"Voice conversation mode is enabled.",
"Prepend your reply with a fenced code block using language `spoken`.",
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
"Do not add generic phrases about whether the user should read more.",
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
"After the `spoken` block, continue with your normal detailed response.",
"Example:",
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
].join("\n\n")
}

View File

@@ -240,6 +240,10 @@ export interface SpeechSynthesisResponse {
mimeType: string mimeType: string
} }
export interface VoiceModeStateResponse {
enabled: boolean
}
export type WorkspaceEventType = export type WorkspaceEventType =
| "workspace.created" | "workspace.created"
| "workspace.started" | "workspace.started"

View File

@@ -29,6 +29,7 @@ import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth" import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service" import type { SpeechService } from "../speech/service"
import { PluginChannelManager } from "../plugins/channel"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@@ -173,6 +174,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }), logger: deps.logger.child({ component: "background-processes" }),
}) })
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
registerAuthRoutes(app, { authManager: deps.authManager }) registerAuthRoutes(app, { authManager: deps.authManager })
@@ -256,7 +258,12 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
}) })
registerSpeechRoutes(app, { speechService: deps.speechService }) registerSpeechRoutes(app, { speechService: deps.speechService })
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger }) registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: proxyLogger,
channel: pluginChannel,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import type { VoiceModeStateResponse } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager" import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus" import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
@@ -10,6 +11,7 @@ interface RouteDeps {
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
channel: PluginChannelManager
} }
const PluginEventSchema = z.object({ const PluginEventSchema = z.object({
@@ -17,9 +19,11 @@ const PluginEventSchema = z.object({
properties: z.record(z.unknown()).optional(), properties: z.record(z.unknown()).optional(),
}) })
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { const VoiceModeStateSchema = z.object({
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) enabled: z.boolean(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => { app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id) const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) { if (!workspace) {
@@ -33,10 +37,10 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.raw.flushHeaders?.() reply.raw.flushHeaders?.()
reply.hijack() reply.hijack()
const registration = channel.register(request.params.id, reply) const registration = deps.channel.register(request.params.id, reply)
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent()) deps.channel.send(request.params.id, buildPingEvent())
}, 15000) }, 15000)
const close = () => { const close = () => {
@@ -49,6 +53,24 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
request.raw.on("error", close) request.raw.on("error", close)
}) })
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const payload = VoiceModeStateSchema.parse(request.body ?? {})
deps.channel.send(request.params.id, {
type: "codenomad.voiceMode",
properties: {
enabled: payload.enabled,
formatVersion: "v1",
},
})
return { enabled: payload.enabled }
})
const handleWildcard = async (request: any, reply: any) => { const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId) const workspace = deps.workspaceManager.get(workspaceId)

View File

@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
assert.equal(result.uiStaticDir, bundledDir) assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1") assert.equal(result.uiVersion, "0.8.1")
}) })
it("prefers bundled when bundled and downloaded versions are equal", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
}) })

View File

@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: currentResolved, uiStaticDir: currentResolved,
source: "downloaded", source: "downloaded",
uiVersion: await readUiVersion(currentResolved), uiVersion: await readUiVersion(currentResolved),
priority: 2, priority: 1,
}) })
} }
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: bundledResolved, uiStaticDir: bundledResolved,
source: "bundled", source: "bundled",
uiVersion: await readUiVersion(bundledResolved), uiVersion: await readUiVersion(bundledResolved),
priority: 1, priority: 2,
}) })
} }

View File

@@ -8,6 +8,7 @@
"dev:ui": "npm run dev --workspace @codenomad/ui", "dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js", "dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui", "dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js", "prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild", "bundle:server": "npm run prebuild",
"build": "tauri build" "build": "tauri build"

View File

@@ -56,11 +56,7 @@ async function ensureMonacoAssets() {
function ensureServerBuild() { function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist") const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public") const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) { console.log("[prebuild] rebuilding server workspace for desktop packaging...")
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", { execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot, cwd: workspaceRoot,
stdio: "inherit", stdio: "inherit",

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const packageJsonPath = path.join(root, "package.json")
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
const cargoLockPath = path.join(root, "Cargo.lock")
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
function readPackageVersion() {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing version in packages/tauri-app/package.json")
}
return packageJson.version
}
function syncCargoToml(version) {
const current = fs.readFileSync(cargoTomlPath, "utf8")
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoTomlPath, updated)
return true
}
function syncCargoLock(version) {
if (!fs.existsSync(cargoLockPath)) {
return false
}
const current = fs.readFileSync(cargoLockPath, "utf8")
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoLockPath, updated)
return true
}
function syncTauriConfig(version) {
const current = fs.readFileSync(tauriConfigPath, "utf8")
const config = JSON.parse(current)
if (config.version === version) {
return false
}
config.version = version
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
return true
}
function main() {
const version = readPackageVersion()
const changed = []
if (syncCargoToml(version)) {
changed.push(path.relative(root, cargoTomlPath))
}
if (syncCargoLock(version)) {
changed.push(path.relative(root, cargoLockPath))
}
if (syncTauriConfig(version)) {
changed.push(path.relative(root, tauriConfigPath))
}
if (changed.length === 0) {
console.log(`[sync-tauri-version] already aligned to ${version}`)
return
}
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[sync-tauri-version] failed: ${message}`)
process.exit(1)
}

View File

@@ -83,6 +83,7 @@ interface MarkdownProps {
isDark?: boolean isDark?: boolean
size?: "base" | "sm" | "tight" size?: "base" | "sm" | "tight"
disableHighlight?: boolean disableHighlight?: boolean
escapeRawHtml?: boolean
onRendered?: () => void onRendered?: () => void
} }
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
const text = decodeHtmlEntitiesLocally(rawText) const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text) const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text) const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}` const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey } return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
}) })
const cacheHandle = useGlobalCache({ const cacheHandle = useGlobalCache({
@@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) {
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved() const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}` return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
@@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) {
text: snapshot.text, text: snapshot.text,
html: renderedHtml, html: renderedHtml,
theme: snapshot.themeKey, theme: snapshot.themeKey,
mode: snapshot.version, mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
} }
setHtml(renderedHtml) setHtml(renderedHtml)
cacheHandle.set(cacheEntry) cacheHandle.set(cacheEntry)
@@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) {
markdown.setMarkdownTheme(snapshot.themeKey === "dark") markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, { const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled, suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
}) })
if (latestRequestKey === snapshot.requestKey) { if (latestRequestKey === snapshot.requestKey) {
@@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) {
createEffect(() => { createEffect(() => {
const snapshot = resolved() const snapshot = resolved()
latestRequestKey = snapshot.requestKey latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version return cache.theme === snapshot.themeKey && cache.mode === cacheMode
} }
const localCache = snapshot.part.renderCache const localCache = snapshot.part.renderCache

View File

@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"} size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered} onRendered={props.onRendered}
/> />
</Show> </Show>

View File

@@ -11,6 +11,7 @@ import type {
SpeechSynthesisResponse, SpeechSynthesisResponse,
SpeechTranscriptionResponse, SpeechTranscriptionResponse,
ServerMeta, ServerMeta,
VoiceModeStateResponse,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceDescriptor, WorkspaceDescriptor,
WorkspaceFileResponse, WorkspaceFileResponse,
@@ -348,6 +349,12 @@ export const serverApi = {
{ method: "POST" }, { method: "POST" },
) )
}, },
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
method: "POST",
body: JSON.stringify({ enabled }),
})
},
fetchBackgroundProcessOutput( fetchBackgroundProcessOutput(
instanceId: string, instanceId: string,
processId: string, processId: string,

View File

@@ -11,6 +11,7 @@ let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light" let currentTheme: "light" | "dark" = "light"
let isInitialized = false let isInitialized = false
let highlightSuppressed = false let highlightSuppressed = false
let escapeRawHtmlEnabled = false
let rendererSetup = false let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
@@ -285,6 +286,14 @@ function setupRenderer(isDark: boolean) {
return `<code class="inline-code">${escapeHtml(decoded)}</code>` return `<code class="inline-code">${escapeHtml(decoded)}</code>`
} }
renderer.html = (html: string) => {
if (!escapeRawHtmlEnabled) {
return html
}
return escapeHtml(decodeHtmlEntities(html))
}
marked.use({ renderer }) marked.use({ renderer })
rendererSetup = true rendererSetup = true
} }
@@ -308,6 +317,7 @@ export async function renderMarkdown(
content: string, content: string,
options?: { options?: {
suppressHighlight?: boolean suppressHighlight?: boolean
escapeRawHtml?: boolean
}, },
): Promise<string> { ): Promise<string> {
if (!isInitialized) { if (!isInitialized) {
@@ -316,6 +326,7 @@ export async function renderMarkdown(
} }
const suppressHighlight = options?.suppressHighlight ?? false const suppressHighlight = options?.suppressHighlight ?? false
const escapeRawHtml = options?.escapeRawHtml ?? false
const decoded = decodeHtmlEntities(content) const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) { if (!suppressHighlight) {
@@ -324,13 +335,16 @@ export async function renderMarkdown(
} }
const previousSuppressed = highlightSuppressed const previousSuppressed = highlightSuppressed
const previousEscapeRawHtml = escapeRawHtmlEnabled
highlightSuppressed = suppressHighlight highlightSuppressed = suppressHighlight
escapeRawHtmlEnabled = escapeRawHtml
try { try {
// Proceed to parse immediately - highlighting will be available on next render // Proceed to parse immediately - highlighting will be available on next render
return marked.parse(decoded) as Promise<string> return marked.parse(decoded) as Promise<string>
} finally { } finally {
highlightSuppressed = previousSuppressed highlightSuppressed = previousSuppressed
escapeRawHtmlEnabled = previousEscapeRawHtml
} }
} }

View File

@@ -30,6 +30,7 @@ interface PlaybackHandle {
const log = getLogger("actions") const log = getLogger("actions")
const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map()) const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map())
const LEADING_SPOKEN_BLOCK_REGEX = /^\s*```spoken[ \t]*\r?\n([\s\S]*?)\r?\n```(?:\r?\n|$)/i
const queuedKeys = new Set<string>() const queuedKeys = new Set<string>()
const spokenKeysBySession = new Map<string, Set<string>>() const spokenKeysBySession = new Map<string, Set<string>>()
@@ -107,6 +108,9 @@ export function canUseConversationMode(): boolean {
} }
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void { export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
const previous = isConversationModeEnabled(instanceId)
if (previous === enabled) return
setConversationModeInstances((prev) => { setConversationModeInstances((prev) => {
const next = new Map(prev) const next = new Map(prev)
if (enabled) { if (enabled) {
@@ -120,6 +124,23 @@ export function setConversationModeEnabled(instanceId: string, enabled: boolean)
if (!enabled) { if (!enabled) {
clearConversationPlaybackForInstance(instanceId) clearConversationPlaybackForInstance(instanceId)
} }
void serverApi.updateVoiceMode(instanceId, enabled).catch((error) => {
log.error("Failed to update conversation mode", error)
setConversationModeInstances((prev) => {
const next = new Map(prev)
if (previous) {
next.set(instanceId, true)
} else {
next.delete(instanceId)
}
return next
})
if (!previous) {
clearConversationPlaybackForInstance(instanceId)
}
})
} }
export function toggleConversationMode(instanceId: string): void { export function toggleConversationMode(instanceId: string): void {
@@ -188,7 +209,7 @@ export function handleConversationAssistantPartUpdated(instanceId: string, part:
if (!isConversationModeEnabled(instanceId)) return if (!isConversationModeEnabled(instanceId)) return
if (!isSpeakableSession(instanceId, sessionId)) return if (!isSpeakableSession(instanceId, sessionId)) return
const text = resolveTextPartContent(part).trim() const text = extractLeadingSpokenBlock(resolveTextPartContent(part))
if (!text) return if (!text) return
const key = getEntryKey(instanceId, sessionId, messageId, partId) const key = getEntryKey(instanceId, sessionId, messageId, partId)
@@ -505,3 +526,9 @@ function createObjectUrlFromBase64(audioBase64: string, mimeType: string): strin
} }
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" })) return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
} }
function extractLeadingSpokenBlock(text: string): string {
const match = text.match(LEADING_SPOKEN_BLOCK_REGEX)
if (!match?.[1]) return ""
return match[1].trim()
}