feat: preview Agent HTML in conversations and unblock long replies

Agent replies that produced HTML could only be read as source, so users had
to copy the markup into an external file to see the result. Completed Agent
and Subagent output now renders complete HTML documents, single-root HTML
replies, and `html` / `htm` code blocks as an isolated static preview in
place. Each preview keeps a persistent notice that scripts and network are
disabled and offers the same icon actions as Mermaid diagrams: a source
toggle and a full-screen preview. The new "render HTML in conversations"
switch under Platform Features / General is on by default, applies to the
open conversation immediately, and falls back to HTML source when disabled.
Previews cannot run scripts, reach the network, submit forms, open windows,
or load remote media; author stylesheets and inline styles are preserved so
the intended layout survives, and remote resource loading is refused by the
preview CSP instead.

Direct-model replies no longer fail once they grow large. The byte caps on
streaming responses, single stream blocks, non-streaming replies, model tool
arguments, tool request context, aggregate tool results, and accumulated
answer text are removed, so users stop seeing "模型接口流式响应超过安全限制"
on long answers. Response length is now governed by the request timeout,
cancellation, and context compression. Image generation responses keep their
decodable-image validation.

A message sent after a failed or interrupted run stayed in the pending queue
instead of running. The conversation is now released whenever a run reaches
any terminal state, not only when the renderer reports readiness again, and a
dispatched queue item whose run never starts releases its reservation and
returns to pending. Later messages dispatch without extra user action.

Release note: Agent 回复中的 HTML 现在可直接在会话内预览,支持查看源码和全屏预览,默认开启并可在“平台功能 / 通用设置”关闭;直连模型的长回复不再因大小限制中断;上一次运行失败或被中断后,新发送的消息会立即执行而不再排队等待。
This commit is contained in:
mesalogo
2026-09-05 09:10:44 +08:00
parent 9588c4b901
commit a4b64a1641
35 changed files with 1669 additions and 195 deletions
+105 -62
View File
@@ -1788,19 +1788,41 @@ describe('ModelAgentRuntime', () => {
}
})
it('bounds the total ordinary streaming response size', async () => {
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: 'x'.repeat(65_000)
}
})}\n\n`
)
it('streams long ordinary responses without a size limit', async () => {
const encoder = new TextEncoder()
const deltaChunks = 48
const deltaText = 'x'.repeat(65_000)
const body = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(chunk)
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'message_start',
message: {
id: 'message-long',
model: 'sonnet-5',
usage: { input_tokens: 5 }
}
})}\n\n`
)
)
for (let index = 0; index < deltaChunks; index += 1) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: deltaText
}
})}\n\n`
)
)
}
controller.enqueue(
encoder.encode('data: {"type":"message_stop"}\n\n')
)
controller.close()
}
})
const runtime = new ModelAgentRuntime({
@@ -1816,22 +1838,26 @@ describe('ModelAgentRuntime', () => {
})
)
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
let streamedText = ''
let done = false
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
if (event.type === 'text') {
streamedText += event.delta
}
if (event.type === 'done') {
done = true
}
}
await expect(consume()).rejects.toThrow(
'流式响应超过安全限制'
)
expect(done).toBe(true)
expect(streamedText.length).toBe(deltaChunks * deltaText.length)
})
it('preserves bounded provider error messages', async () => {
@@ -5192,29 +5218,44 @@ describe('ModelAgentRuntime', () => {
await replacement.dispose()
})
it('counts image base64 data against the aggregate tool context limit', async () => {
const response = {
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
}
it('keeps an oversized image tool result in the model round', async () => {
const imageData = Buffer.alloc(1024 * 1024 + 1).toString('base64')
const fetcher = vi.fn<typeof fetch>(async () => Response.json(response))
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
})
)
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: '图片已读取。'
}
}
]
})
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
@@ -5234,23 +5275,25 @@ describe('ModelAgentRuntime', () => {
}))
})
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
await expect(consume()).rejects.toThrow('结果总量超过 1MB')
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(fetcher.mock.calls[1]?.[1]?.body as string).toContain(
imageData
)
})
it('performs and validates a real image generation when testing an image connection', async () => {
+31 -95
View File
@@ -189,10 +189,6 @@ type ModelToolResponse = {
const maxGeneratedImageBytes = 3_900_000
const maxImageResponseBytes = 5_300_000
const maxChatResponseBytes = 2 * 1024 * 1024
const maxStreamBlockBytes = 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const defaultModelRequestTimeoutMs = 10 * 60_000
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
const retryablePreDispatchNetworkErrorCodes = new Set([
@@ -734,12 +730,16 @@ function parseGeneratedImage(value: unknown): {
throw new Error('图像生成接口返回了不支持的图片格式')
}
async function readResponseText(response: Response): Promise<string> {
if (!response.body) {
throw new Error('模型接口未返回响应内容')
}
return response.text()
}
function parseToolArguments(value: unknown): Record<string, unknown> {
let parsed = value
if (typeof value === 'string') {
if (Buffer.byteLength(value) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
try {
parsed = JSON.parse(value)
} catch (error) {
@@ -751,15 +751,11 @@ function parseToolArguments(value: unknown): Record<string, unknown> {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('模型工具参数必须是 JSON object')
}
let serialized: string
try {
serialized = JSON.stringify(parsed)
JSON.stringify(parsed)
} catch (error) {
throw new Error('模型工具参数无法序列化', { cause: error })
}
if (Buffer.byteLength(serialized) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
return parsed as Record<string, unknown>
}
@@ -1232,7 +1228,7 @@ function parseSseData(
}
}
async function* readBoundedSseBlocks(
async function* readSseBlocks(
response: Response
): AsyncGenerator<string, void, void> {
if (!response.body) {
@@ -1242,19 +1238,11 @@ async function* readBoundedSseBlocks(
const decoder = new TextDecoder()
let buffer = ''
let completed = false
let receivedBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
receivedBytes += value?.byteLength ?? 0
if (receivedBytes > maxChatResponseBytes) {
throw new Error('模型接口流式响应超过安全限制')
}
buffer += decoder.decode(value, { stream: !done })
buffer = buffer.replaceAll('\r\n', '\n')
if (Buffer.byteLength(buffer) > maxStreamBlockBytes) {
throw new Error('模型接口流式响应块超过安全限制')
}
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
@@ -1288,7 +1276,7 @@ async function* readOpenAIResponsesToolStream(
reported: false
}
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1423,7 +1411,7 @@ async function* readAnthropicToolStream(
let reasoning = ''
let stopReason: unknown
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1566,12 +1554,6 @@ async function* readAnthropicToolStream(
throw new Error('Anthropic 返回了无效流式工具参数增量')
}
current.partialJson += delta.partial_json
if (
Buffer.byteLength(current.partialJson) >
maxToolArgumentBytes
) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
}
continue
}
@@ -1952,13 +1934,14 @@ export class ModelAgentRuntime implements AgentRuntime {
)
)
})
const responseText = await readBoundedResponseText(response, {
maxBytes: response.ok && imageGeneration
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText =
response.ok && imageGeneration
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
if (!response.ok) {
let detail: string | undefined
try {
@@ -2660,13 +2643,13 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = modelRequest.response
let responseText: string
try {
responseText = await readBoundedResponseText(response, {
maxBytes: response.ok
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
responseText = response.ok
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
@@ -2828,9 +2811,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
)
)
if (Buffer.byteLength(body) > 2 * 1024 * 1024) {
throw new Error('模型工具请求上下文超过 2MB 安全限制')
}
const request = await this.fetchWithTimeout(
this.getEndpoint(),
{
@@ -2843,11 +2823,7 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = request.response
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -2890,7 +2866,7 @@ export class ModelAgentRuntime implements AgentRuntime {
let reasoning = ''
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
receivedStop = true
@@ -2972,16 +2948,6 @@ export class ModelAgentRuntime implements AgentRuntime {
? functionDelta.name
: current.name
}
if (
next.id.length > 256 ||
next.name.length > 128 ||
Buffer.byteLength(next.arguments) >
maxToolArgumentBytes
) {
throw new Error(
'OpenAI 模型接口返回的流式工具调用超过安全限制'
)
}
streamedToolCalls.set(index as number, next)
}
}
@@ -3025,11 +2991,7 @@ export class ModelAgentRuntime implements AgentRuntime {
streamed: true
}
}
const responseText = await readBoundedResponseText(response, {
maxBytes: maxChatResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let payload: unknown
try {
payload = responseText.trim()
@@ -3115,11 +3077,7 @@ export class ModelAgentRuntime implements AgentRuntime {
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -3136,7 +3094,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
@@ -3619,9 +3577,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
if (response.text) {
answer += response.text
if (Buffer.byteLength(answer) > 1024 * 1024) {
throw new Error('直连模型回答超过 1MB 安全限制')
}
if (!response.streamed) {
yield {
requestId: request.requestId,
@@ -3844,25 +3799,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
roundContextBytes += validateToolResult(result)
const retainedContextBytes = compressionState.rounds.reduce(
(total, retainedRound) =>
total + retainedRound.contextBytes,
roundContextBytes
)
if (retainedContextBytes > maxToolContextBytes) {
if (showToolActivity) {
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型工具结果超过限制:${displayName}`,
input
}
}
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
}
if (responses) {
responsesResults.push({
type: 'function_call_output',
+68 -10
View File
@@ -65,6 +65,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -76,6 +77,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -83,11 +85,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -116,7 +119,7 @@ describe('ApplicationSettingsStore', () => {
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
@@ -139,6 +142,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -165,6 +169,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -191,6 +196,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -217,6 +223,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -245,11 +252,12 @@ describe('ApplicationSettingsStore', () => {
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -279,6 +287,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -306,6 +315,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -314,9 +324,10 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -324,9 +335,10 @@ describe('ApplicationSettingsStore', () => {
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -351,6 +363,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -359,8 +372,9 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSeven,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -388,6 +402,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -397,17 +412,19 @@ describe('ApplicationSettingsStore', () => {
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
})
@@ -445,6 +462,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -459,7 +477,8 @@ describe('ApplicationSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...versionNine,
version: 10,
version: 11,
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -523,6 +542,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -546,6 +566,41 @@ describe('ApplicationSettingsStore', () => {
})
})
it('persists disabling conversation HTML rendering', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({ conversationHtmlRenderingEnabled: false })
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
})
it('enables conversation HTML rendering when migrating version 10 settings', async () => {
const { filePath, store } = await createStore()
const versionTenSettings: Record<string, unknown> = {
...defaultApplicationSettings,
version: 10,
lastSeenReleaseNotesVersion: '0.8.18'
}
delete versionTenSettings.conversationHtmlRenderingEnabled
await writeFile(filePath, JSON.stringify(versionTenSettings), 'utf8')
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionTenSettings,
version: 11,
conversationHtmlRenderingEnabled: true
})
})
it.each([
'{not-json',
JSON.stringify({
@@ -634,6 +689,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -641,11 +697,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -674,6 +731,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+44 -4
View File
@@ -27,7 +27,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 10
const CURRENT_SETTINGS_VERSION = 11
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -58,6 +58,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -71,6 +72,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -84,6 +86,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -96,6 +99,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -108,6 +112,7 @@ const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true
})
.extend({
@@ -117,13 +122,25 @@ const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.strict()
const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ localToolEnvironment: true })
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true
})
.extend({
version: z.literal(9),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionTenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ conversationHtmlRenderingEnabled: true })
.extend({
version: z.literal(10),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION),
@@ -215,6 +232,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -236,8 +254,13 @@ export class ApplicationSettingsStore {
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
'localToolEnvironment' | 'version'
> & { version: number }
| 'conversationHtmlRenderingEnabled'
| 'localToolEnvironment'
| 'version'
> & {
conversationHtmlRenderingEnabled?: boolean
version: number
}
): Promise<StoredApplicationSettings> {
const resolved = await this.legacyLocalToolEnvironmentResolver()
const selection = (
@@ -249,6 +272,8 @@ export class ApplicationSettingsStore {
: { source: 'managed' }
const next: StoredApplicationSettings = {
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
version: CURRENT_SETTINGS_VERSION,
localToolEnvironment: {
node: selection(resolved.nodeExecutablePath),
@@ -303,6 +328,17 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionTenResult =
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTenResult.success) {
const next: StoredApplicationSettings = {
...versionTenResult.data,
version: CURRENT_SETTINGS_VERSION,
conversationHtmlRenderingEnabled: true
}
await this.persist(next)
return next
}
const versionNineResult =
versionNineStoredApplicationSettingsSchema.safeParse(parsed)
if (versionNineResult.success) {
@@ -443,6 +479,8 @@ export class ApplicationSettingsStore {
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
@@ -478,6 +516,8 @@ export class ApplicationSettingsStore {
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
+215
View File
@@ -6710,6 +6710,221 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('releases the queue reservation when a dispatched run cannot start', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000745'
const itemId = '00000000-0000-4000-8000-000000000746'
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: '需要重新排队',
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
const item = {
id: itemId,
conversationId,
source: 'user' as const,
label: input.prompt,
createdAt: '2026-08-21T00:02:00.000Z'
}
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
item
)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce(
{
source: 'user',
item,
payloadJson: JSON.stringify({
input,
serializedContexts: '[]'
})
}
)
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
item
)
harness.assistantDatabase.createTask.mockImplementationOnce(() => {
throw new Error('会话记录不可用')
})
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item, input }
)
)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000747',
conversationId,
queueItemId: itemId,
runtimeSelection: input.runtimeSelection,
prompt: input.prompt,
workMode: input.workMode,
knowledgeLibraryIds: []
})
).rejects.toThrow('会话记录不可用')
expect(
harness.assistantDatabase.releaseConversationUserQueueItem
).toHaveBeenCalledWith(itemId)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000748',
conversationId,
runtimeSelection: { provider: 'auto' },
prompt: '失败后的新消息',
workMode: 'ask',
knowledgeLibraryIds: []
})
await harness.dispose()
})
it('dispatches a message queued during a run that later fails', async () => {
let failRun: ((error: Error) => void) | undefined
const runtimeStarted = vi.fn()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
// eslint-disable-next-line require-yield
async *run(request: { requestId: string }) {
runtimeStarted(request.requestId)
throw await new Promise<Error>((resolve) => {
failRun = resolve
})
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000751'
const createItem = (id: string, prompt: string) => {
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt,
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
return {
input,
item: {
id,
conversationId,
source: 'user' as const,
label: prompt,
createdAt: '2026-08-21T00:03:00.000Z'
}
}
}
const first = createItem(
'00000000-0000-4000-8000-000000000752',
'会失败的消息'
)
const second = createItem(
'00000000-0000-4000-8000-000000000753',
'失败之后的新消息'
)
harness.assistantDatabase.enqueueConversationUserInput
.mockReturnValueOnce(first.item)
.mockReturnValueOnce(second.item)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([first.item])
.mockReturnValueOnce([second.item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem
.mockReturnValueOnce({
source: 'user',
item: first.item,
payloadJson: JSON.stringify({
input: first.input,
serializedContexts: '[]'
})
})
.mockReturnValueOnce({
source: 'user',
item: second.item,
payloadJson: JSON.stringify({
input: second.input,
serializedContexts: '[]'
})
})
harness.assistantDatabase.getConversationQueueItem.mockImplementation(
(id: string) =>
id === first.item.id ? first.item : second.item
)
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), first.input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: first.item, input: first.input }
)
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000754',
conversationId,
queueItemId: first.item.id,
runtimeSelection: first.input.runtimeSelection,
prompt: first.input.prompt,
workMode: first.input.workMode,
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
// The next message arrives while the run is still in flight, so the
// renderer never reports readiness for it afterwards.
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), second.input)
failRun?.(new Error('模型接口请求失败'))
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000754',
'failed',
expect.any(String)
)
)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: second.item, input: second.input }
)
)
await harness.dispose()
})
it('publishes Runtime usage as context metrics with one settings read', async () => {
const runtime = {
runtimeId: 'continue',
+34 -3
View File
@@ -4527,9 +4527,11 @@ export function registerIpcHandlers(
pendingGoodBuddyConfigReload = true
}
await flushGoodBuddyConfigReload().catch(() => undefined)
if (readyConversationQueues.has(request.conversationId)) {
void pumpConversationQueue(request.conversationId)
}
// The renderer also reports readiness after a finished run, but a
// run that fails before publishing a terminal event never triggers
// that call, which would leave later messages queued forever.
readyConversationQueues.add(request.conversationId)
void pumpConversationQueue(request.conversationId)
}
})()
if (managedSshExecution) {
@@ -4542,6 +4544,35 @@ export function registerIpcHandlers(
}
} finally {
preparingRequestConversations.delete(parsedInput.requestId)
// A dispatched queue item whose run never started keeps the
// conversation reserved, so later messages would stay queued
// forever instead of running.
if (
parsedInput.queueItemId &&
reservedConversationQueueItems.get(
parsedInput.conversationId
) === parsedInput.queueItemId
) {
reservedConversationQueueItems.delete(
parsedInput.conversationId
)
const dispatchTimeout = queueDispatchTimers.get(
parsedInput.queueItemId
)
if (dispatchTimeout) {
clearTimeout(dispatchTimeout)
queueDispatchTimers.delete(parsedInput.queueItemId)
}
try {
assistantDatabase.releaseConversationUserQueueItem(
parsedInput.queueItemId
)
} catch {
// The renderer may have released the item already.
}
readyConversationQueues.add(parsedInput.conversationId)
publishConversationQueueChange(parsedInput.conversationId)
}
}
})
+93
View File
@@ -1007,6 +1007,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: enabled,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -2195,6 +2196,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -2296,6 +2298,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2307,6 +2310,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2408,6 +2412,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2419,6 +2424,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -4455,6 +4461,7 @@ describe('App', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10952,6 +10959,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10963,6 +10971,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -11014,6 +11023,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -11068,6 +11078,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -11101,6 +11112,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -11112,6 +11124,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -11142,12 +11155,92 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('renders Agent HTML by default and applies the platform switch immediately', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000471'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
title: 'HTML 预览会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000472',
role: 'assistant',
content: `\`\`\`html
<main><h1>Rendered dashboard</h1></main>
\`\`\``,
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
}
])
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
const updateSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => {
applicationSettings = { ...applicationSettings, ...input }
return { ...applicationSettings }
})
api.updates = {
getSettings: vi.fn(async () => ({ ...applicationSettings })),
updateSettings,
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
render(<App />)
expect(
await screen.findByTitle('Agent 回复 HTML 静态预览')
).toHaveAttribute('sandbox', '')
fireEvent.click(
await screen.findByRole('button', { name: /本地工作区/u })
)
fireEvent.click(
await screen.findByRole('tab', { name: '平台功能' })
)
const toggle = await screen.findByRole('switch', {
name: '在会话中渲染 HTML'
})
expect(toggle).toBeChecked()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
conversationHtmlRenderingEnabled: false
})
)
await waitFor(() =>
expect(
screen.queryByTitle('Agent 回复 HTML 静态预览')
).not.toBeInTheDocument()
)
expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument()
})
it('keeps platform-feature switches in Settings without navigating', async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
+16
View File
@@ -846,6 +846,7 @@ type ChatScrollSnapshot = {
function ChatHistoryPane({
active,
artifactById,
conversationHtmlRenderingEnabled,
conversation,
locale,
onCopyMessage,
@@ -866,6 +867,7 @@ function ChatHistoryPane({
}: {
active: boolean
artifactById: ReadonlyMap<string, AssistantArtifact>
conversationHtmlRenderingEnabled: boolean
conversation: Conversation
locale: TimeFormatLocale
onCopyMessage: (content: string) => Promise<void>
@@ -1169,6 +1171,7 @@ function ChatHistoryPane({
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === 'user'
? conversation.messages.at(-2)?.content
@@ -2548,6 +2551,10 @@ function App(): React.JSX.Element {
useState<ProjectChannel>()
const [remoteProjectsEnabled, setRemoteProjectsEnabled] =
useState(false)
const [
conversationHtmlRenderingEnabled,
setConversationHtmlRenderingEnabled
] = useState(true)
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [
magicNotesShowIncompleteTodoCount,
@@ -3077,6 +3084,9 @@ function App(): React.JSX.Element {
void updates
.getSettings()
.then(async (settings) => {
setConversationHtmlRenderingEnabled(
settings.conversationHtmlRenderingEnabled !== false
)
setRemoteProjectsEnabled(settings.remoteProjectsEnabled)
setMagicNotesEnabled(settings.magicNotesEnabled)
setMagicNotesShowIncompleteTodoCount(
@@ -9372,6 +9382,9 @@ function App(): React.JSX.Element {
view === 'chat' && conversation.id === activeId
}
artifactById={assistantArtifactById}
conversationHtmlRenderingEnabled={
conversationHtmlRenderingEnabled
}
conversation={conversation}
key={conversation.id}
locale={locale}
@@ -10831,6 +10844,9 @@ function App(): React.JSX.Element {
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onConversationHtmlRenderingEnabledChange={
setConversationHtmlRenderingEnabled
}
onAppearanceThemeChange={setAppearanceTheme}
onBrandingPreferencesChange={(preferences) => {
if (!saveBrandingPreferences(preferences)) {
+59 -1
View File
@@ -12,10 +12,20 @@ import {
} from './ChatTimeline'
const markdownRenderProbe = vi.hoisted(() => vi.fn())
const htmlRenderProbe = vi.hoisted(() => vi.fn())
vi.mock('./MarkdownRenderer', () => ({
MarkdownRenderer: ({ children }: { children: string }) => {
MarkdownRenderer: ({
children,
renderHtml
}: {
children: string
renderHtml?: boolean
}) => {
markdownRenderProbe(children)
if (renderHtml) {
htmlRenderProbe(children)
}
return <span>{children}</span>
}
}))
@@ -50,6 +60,54 @@ describe('ChatTimeline', () => {
vi.clearAllMocks()
})
it('enables HTML rendering only for completed Agent output', () => {
const messages: Message[] = [
{
id: 'user-message',
role: 'user',
content: '<div>User HTML</div>',
createdAt: 1_775_000_000_000,
state: 'complete'
},
{
id: 'streaming-message',
role: 'assistant',
content: '<div>Streaming HTML</div>',
createdAt: 1_775_000_001_000,
state: 'streaming'
},
{
id: 'complete-message',
role: 'assistant',
content: '<div>Complete HTML</div>',
reasoning: '<div>Reasoning HTML</div>',
createdAt: 1_775_000_002_000,
state: 'complete'
}
]
render(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
renderAssistantHtml
retryContent=""
totalMessageCount={messages.length}
/>
)
expect(htmlRenderProbe).toHaveBeenCalledTimes(1)
expect(htmlRenderProbe).toHaveBeenCalledWith(
'<div>Complete HTML</div>'
)
})
it('renders only the immutably changed streaming row and retains unchanged DOM state', () => {
const messages = createMessages()
const props = {
+38 -4
View File
@@ -252,8 +252,10 @@ function ToolExecutionList({
}
const SubagentStatusCard = memo(function SubagentStatusCard({
renderHtml,
subagent
}: {
renderHtml: boolean
subagent: SubagentActivity
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -334,7 +336,13 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
<section>
<strong>{t('chat.subagents.output')}</strong>
<div className="markdown-content">
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderHtml && subagent.state === 'completed'
}
>
{subagent.output}
</MarkdownRenderer>
</div>
</section>
) : (
@@ -355,8 +363,10 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
})
function SubagentStatusList({
renderHtml,
subagents
}: {
renderHtml: boolean
subagents: SubagentActivity[]
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -369,6 +379,7 @@ function SubagentStatusList({
{subagents.map((subagent) => (
<SubagentStatusCard
key={subagent.childTaskId}
renderHtml={renderHtml}
subagent={subagent}
/>
))}
@@ -383,6 +394,7 @@ type ChatMessageRowProps = {
greeting: boolean
locale: TimeFormatLocale
message: Message
renderAssistantHtml?: boolean
onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void
@@ -416,6 +428,7 @@ function ChatMessageRowView({
greeting,
locale,
message,
renderAssistantHtml = false,
onArticleRef,
onCopyMessage,
onDownloadImage,
@@ -594,6 +607,7 @@ function ChatMessageRowView({
) : item.kind === 'subagents' ? (
<SubagentStatusList
key={item.id}
renderHtml={renderAssistantHtml}
subagents={item.childTaskIds.flatMap((childTaskId) => {
const subagent = subagentsById.get(childTaskId)
return subagent ? [subagent] : []
@@ -610,7 +624,15 @@ function ChatMessageRowView({
className="markdown-content message__content"
key={item.block.id}
>
<MarkdownRenderer>{item.block.content}</MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderAssistantHtml &&
message.role === 'assistant' &&
message.state === 'complete'
}
>
{item.block.content}
</MarkdownRenderer>
</div>
)
)}
@@ -626,7 +648,13 @@ function ChatMessageRowView({
)}
{message.content && (
<div className="markdown-content message__content">
<MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderAssistantHtml &&
message.role === 'assistant' &&
message.state === 'complete'
}
>
{greeting ? t('conversation.greeting') : message.content}
</MarkdownRenderer>
</div>
@@ -634,7 +662,10 @@ function ChatMessageRowView({
</>
)}
{unorderedSubagents && unorderedSubagents.length > 0 && (
<SubagentStatusList subagents={unorderedSubagents} />
<SubagentStatusList
renderHtml={renderAssistantHtml}
subagents={unorderedSubagents}
/>
)}
{message.artifactIds?.map((artifactId) => {
const candidate = artifactById.get(artifactId)
@@ -1089,6 +1120,7 @@ type ChatTimelineProps = {
) => Promise<void>
onRetry: (content: string) => void
onRevealEarlier: () => void
renderAssistantHtml?: boolean
retryContent?: string
totalMessageCount: number
}
@@ -1111,6 +1143,7 @@ export function ChatTimeline({
onRespondQuestion,
onRetry,
onRevealEarlier,
renderAssistantHtml = false,
retryContent,
totalMessageCount
}: ChatTimelineProps): React.JSX.Element {
@@ -1152,6 +1185,7 @@ export function ChatTimeline({
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
renderAssistantHtml={renderAssistantHtml}
retryContent={retryContent}
/>
)
@@ -193,6 +193,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -209,6 +210,7 @@ beforeEach(() => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1233,6 +1235,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1268,6 +1271,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1386,6 +1390,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+136
View File
@@ -109,6 +109,142 @@ const ready = true
expect(container.querySelector('script')).not.toBeInTheDocument()
})
it('renders fenced HTML as an isolated static preview when enabled', () => {
render(
<MarkdownRenderer renderHtml>{`\`\`\`html
<!doctype html>
<html>
<head>
<style>body { color: navy; }</style>
<script>window.bad = true</script>
</head>
<body>
<h1>HTML dashboard</h1>
<img src="https://tracker.example/pixel.png" onerror="bad()">
<form action="https://example.com"><button>Send</button></form>
</body>
</html>
\`\`\``}</MarkdownRenderer>
)
const preview = screen.getByTitle('Agent 回复 HTML 静态预览')
const source = preview.getAttribute('srcdoc') ?? ''
expect(preview).toHaveAttribute('sandbox', '')
expect(preview).toHaveAttribute('referrerpolicy', 'no-referrer')
expect(source).toContain('HTML dashboard')
expect(source).toContain("default-src 'none'")
expect(source).not.toContain('<script')
expect(source).not.toContain('<form')
expect(source).not.toContain('https://tracker.example')
expect(
screen.getByText('静态安全预览,脚本和网络已禁用')
).toBeInTheDocument()
const sourceButton = screen.getByRole('button', {
name: '查看源码'
})
expect(sourceButton).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(sourceButton)
expect(screen.getByText(/window\.bad = true/u)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '隐藏源码' })
).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: '隐藏源码' }))
expect(
screen.queryByText(/window\.bad = true/u)
).not.toBeInTheDocument()
})
it('opens and closes the full-screen HTML preview', () => {
render(
<div className="app-shell">
<MarkdownRenderer renderHtml>{`\`\`\`html
<section><h1>Full screen page</h1></section>
\`\`\``}</MarkdownRenderer>
</div>
)
const viewerButton = screen.getByRole('button', {
name: '全屏预览'
})
viewerButton.focus()
fireEvent.click(viewerButton)
const dialog = screen.getByRole('dialog', {
name: 'HTML 全屏预览'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
const closeButton = screen.getByRole('button', {
name: '关闭全屏预览'
})
expect(closeButton).toHaveFocus()
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
const frame = screen.getByTitle('Agent 回复 HTML 全屏静态预览')
expect(frame).toHaveAttribute('sandbox', '')
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer')
expect(frame.getAttribute('srcdoc') ?? '').toContain(
'Full screen page'
)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(viewerButton).toHaveFocus()
})
it('keeps author stylesheets and inline styles in the preview', () => {
render(
<MarkdownRenderer renderHtml>{`\`\`\`html
<!doctype html>
<html>
<head>
<style>
body { background: #101014; color: #f5f5f7; }
.hero { background-image: url(https://cdn.example/bg.png); }
@import url("https://fonts.example/inter.css");
</style>
</head>
<body>
<section class="hero" style="padding: 24px; background: url(https://cdn.example/b.png)">
Styled hero
</section>
</body>
</html>
\`\`\``}</MarkdownRenderer>
)
const source =
screen
.getByTitle('Agent 回复 HTML 静态预览')
.getAttribute('srcdoc') ?? ''
expect(source).toContain('background: #101014')
expect(source).toContain('.hero {')
expect(source).toContain('padding: 24px')
})
it('renders a complete HTML reply directly and keeps HTML as code when disabled', () => {
const source = '<html><body><main>Complete page</main></body></html>'
const { rerender } = render(
<MarkdownRenderer renderHtml>{source}</MarkdownRenderer>
)
expect(
screen.getByTitle('Agent 回复 HTML 静态预览')
).toHaveAttribute('sandbox', '')
rerender(
<MarkdownRenderer>{source}</MarkdownRenderer>
)
expect(
screen.queryByTitle('Agent 回复 HTML 静态预览')
).not.toBeInTheDocument()
expect(
screen.getByText(source, { exact: true })
).toBeInTheDocument()
})
it('renders a whole Markdown fence as formatted content', () => {
const { container } = render(
<MarkdownRenderer>{`\`\`\`markdown
+55 -5
View File
@@ -12,6 +12,7 @@ import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import type { Components } from 'react-markdown'
import { useTranslation } from 'react-i18next'
import { StaticHtmlPreview } from './StaticHtmlPreview'
const MermaidDiagram = lazy(() =>
import('./MermaidDiagram').then((module) => ({
@@ -34,7 +35,8 @@ const linkComponent: Components['a'] = ({
function markdownComponents(
tableAriaLabel: string,
mermaidLoadingLabel: string
mermaidLoadingLabel: string,
renderHtml: boolean
): Components {
return {
a: linkComponent,
@@ -70,6 +72,22 @@ function markdownComponents(
</Suspense>
)
}
if (
renderHtml &&
isValidElement<{
children?: React.ReactNode
className?: string
}>(child) &&
/(?:^|\s)language-html?(?:\s|$)/iu.test(
child.props.className ?? ''
)
) {
const source = String(child.props.children ?? '').replace(
/\n$/u,
''
)
return <StaticHtmlPreview source={source} />
}
return <pre {...properties}>{children}</pre>
},
table: ({ children, node, ...properties }) => {
@@ -90,6 +108,7 @@ function markdownComponents(
type MarkdownRendererProps = {
children: string
renderHtml?: boolean
}
const inlineMarkdownComponents: Components = {
@@ -213,18 +232,49 @@ function unwrapMarkdownFence(content: string): string {
return fencedMarkdown?.[1] ?? content
}
function standaloneHtmlSource(content: string): string | undefined {
const trimmed = content.trim()
if (
/^(?:<!doctype\s+html(?:\s[^>]*)?>\s*)?<html(?:\s|>)/iu.test(
trimmed
) ||
/^<[a-z][a-z0-9:-]*(?:\s[^<>]*?)?>[\s\S]*<\/[a-z][a-z0-9:-]*>\s*$/iu.test(
trimmed
)
) {
return trimmed
}
return undefined
}
export const MarkdownRenderer = memo(function MarkdownRenderer({
children
children,
renderHtml = false
}: MarkdownRendererProps): React.JSX.Element {
const { t } = useTranslation('app')
const normalizedContent = normalizeLatexDelimiters(
unwrapMarkdownFence(children)
)
const components = useMemo(
() =>
markdownComponents(
t('markdown.scrollableTable'),
t('markdown.mermaidLoading')
t('markdown.mermaidLoading'),
renderHtml
),
[t]
[renderHtml, t]
)
const standaloneHtml = standaloneHtmlSource(normalizedContent)
if (standaloneHtml) {
return renderHtml ? (
<StaticHtmlPreview source={standaloneHtml} />
) : (
<pre>
<code className="language-html">{standaloneHtml}</code>
</pre>
)
}
return (
<ReactMarkdown
@@ -245,7 +295,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
]}
skipHtml
>
{normalizeLatexDelimiters(unwrapMarkdownFence(children))}
{normalizedContent}
</ReactMarkdown>
)
})
@@ -44,6 +44,9 @@ import {
import { displayErrorMessage } from './error-message'
type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: (
enabled: boolean
) => void
onMagicNotesEnabledChange: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange: (
enabled: boolean
@@ -74,6 +77,7 @@ const shortcutErrorTranslationKeys: Record<
}
export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange,
onMagicNotesEnabledChange,
onMagicNotesShowIncompleteTodoCountChange,
onRemoteProjectsEnabledChange,
@@ -486,6 +490,34 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeConversationHtmlRendering = async (
enabled: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
conversationHtmlRenderingEnabled: enabled
})
setSettings(nextSettings)
onConversationHtmlRenderingEnabledChange(
nextSettings.conversationHtmlRenderingEnabled
)
} catch {
setError(
t(
'platformFeatures.errors.saveConversationHtmlRenderingFailed'
)
)
} finally {
setSaving(false)
}
}
const changeRemoteProjects = async (
enabled: boolean
): Promise<void> => {
@@ -723,6 +755,36 @@ export function PlatformFeaturesSettingsSection({
</p>
)}
</article>
{settings && (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t('platformFeatures.conversationHtml.title')}
</strong>
<small>
{t('platformFeatures.conversationHtml.description')}
</small>
</div>
</div>
<label className="toggle-row">
<input
checked={settings.conversationHtmlRenderingEnabled}
disabled={saving}
onChange={(event) =>
void changeConversationHtmlRendering(
event.target.checked
)
}
role="switch"
type="checkbox"
/>
<span>
{t('platformFeatures.conversationHtml.enabled')}
</span>
</label>
</article>
)}
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
+35
View File
@@ -546,6 +546,7 @@ let applicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -836,6 +837,7 @@ describe('SettingsPanel runtime files', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -1612,6 +1614,39 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('disables conversation HTML rendering from General settings', async () => {
const onConversationHtmlRenderingEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const toggle = await screen.findByRole('switch', {
name: '在会话中渲染 HTML'
})
expect(toggle).toBeChecked()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
conversationHtmlRenderingEnabled: false
})
)
expect(
onConversationHtmlRenderingEnabledChange
).toHaveBeenCalledWith(false)
expect(toggle).not.toBeChecked()
})
it('does not guess a model download source when settings fail to load', async () => {
getApplicationSettings.mockRejectedValueOnce(
new Error('read failed')
+7
View File
@@ -237,6 +237,9 @@ type SettingsPanelProps = {
) => boolean
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onConversationHtmlRenderingEnabledChange?: (
enabled: boolean
) => void
onMagicNotesEnabledChange?: (enabled: boolean) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange?: (
@@ -646,6 +649,7 @@ export function SettingsPanel({
onBrandingPreferencesChange = () => false,
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {},
onMagicNotesEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onMagicNotesShowIncompleteTodoCountChange = () => {},
@@ -2255,6 +2259,9 @@ export function SettingsPanel({
{activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection
onDirtyChange={setPlatformFeaturesDirty}
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
+255
View File
@@ -0,0 +1,255 @@
import DOMPurify from 'dompurify'
import { Code2, FileCode2, Maximize2, X } from 'lucide-react'
import {
memo,
useEffect,
useId,
useMemo,
useRef,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
const forbiddenTags = [
'base',
'embed',
'form',
'iframe',
'link',
'meta',
'object',
'script'
]
const forbiddenAttributes = [
'action',
'formaction',
'ping',
'srcset'
]
const staticPreviewPolicy = [
"default-src 'none'",
"base-uri 'none'",
"connect-src 'none'",
'font-src data:',
"form-action 'none'",
"frame-src 'none'",
'img-src data:',
"media-src 'none'",
"object-src 'none'",
"script-src 'none'",
"style-src 'unsafe-inline'"
].join('; ')
const safeInlineImagePattern =
/^data:image\/(?:gif|jpeg|png|webp);base64,[a-z0-9+/=\s]+$/iu
function createStaticHtmlDocument(source: string): string {
const sanitized = DOMPurify.sanitize(source, {
ALLOW_UNKNOWN_PROTOCOLS: false,
FORBID_ATTR: forbiddenAttributes,
FORBID_TAGS: forbiddenTags,
WHOLE_DOCUMENT: true
})
const documentNode = new DOMParser().parseFromString(
sanitized,
'text/html'
)
for (const element of documentNode.querySelectorAll('*')) {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase()
if (name.startsWith('on')) {
element.removeAttribute(attribute.name)
continue
}
if (name === 'href' || name === 'xlink:href') {
if (!attribute.value.trim().startsWith('#')) {
element.removeAttribute(attribute.name)
}
continue
}
if (name === 'src' || name === 'poster') {
if (
element.localName !== 'img' ||
!safeInlineImagePattern.test(attribute.value.trim())
) {
element.removeAttribute(attribute.name)
}
}
}
}
const policy = documentNode.createElement('meta')
policy.httpEquiv = 'Content-Security-Policy'
policy.content = staticPreviewPolicy
documentNode.head.prepend(policy)
const viewport = documentNode.createElement('meta')
viewport.name = 'viewport'
viewport.content = 'width=device-width, initial-scale=1'
documentNode.head.append(viewport)
return `<!doctype html>\n${documentNode.documentElement.outerHTML}`
}
function StaticHtmlViewer({
documentSource,
onClose
}: {
documentSource: string
onClose: () => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const titleId = useId()
const hintId = useId()
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(
() => activateModalFocus(() => closeRef.current),
[]
)
return createPortal(
<div
className="html-preview-viewer-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<section
aria-describedby={hintId}
aria-labelledby={titleId}
aria-modal="true"
className="html-preview-viewer"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header className="html-preview-viewer__header">
<div>
<strong id={titleId}>
{t('markdown.htmlViewerTitle')}
</strong>
<small id={hintId}>
{t('markdown.htmlPreviewNotice')}
</small>
</div>
<button
aria-label={t('markdown.htmlCloseViewer')}
className="icon-button"
onClick={onClose}
ref={closeRef}
title={t('markdown.htmlCloseViewer')}
type="button"
>
<X aria-hidden="true" size={18} />
</button>
</header>
<iframe
referrerPolicy="no-referrer"
sandbox=""
srcDoc={documentSource}
title={t('markdown.htmlViewerFrame')}
/>
</section>
</div>,
document.body
)
}
export const StaticHtmlPreview = memo(function StaticHtmlPreview({
source
}: {
source: string
}): React.JSX.Element {
const { t } = useTranslation('app')
const sourceId = useId()
// Tracking the source these panels were opened for resets both when the
// reply changes, without an effect that cascades renders.
const [visibleSource, setVisibleSource] = useState<string>()
const [viewerSource, setViewerSource] = useState<string>()
const sourceVisible = visibleSource === source
const viewerOpen = viewerSource === source
const documentSource = useMemo(
() => createStaticHtmlDocument(source),
[source]
)
const sourceLabel = t(
sourceVisible
? 'markdown.htmlHideSource'
: 'markdown.htmlViewSource'
)
return (
<figure className="message-html-preview">
<figcaption className="message-html-preview__header">
<span>
<FileCode2 aria-hidden="true" size={15} />
<span>
<strong>{t('markdown.htmlPreview')}</strong>
<small>{t('markdown.htmlPreviewNotice')}</small>
</span>
</span>
<div
aria-label={t('markdown.htmlActions')}
className="message-html-preview__actions"
role="group"
>
<button
aria-controls={sourceId}
aria-expanded={sourceVisible}
aria-label={sourceLabel}
className={`icon-button${
sourceVisible ? ' icon-button--active' : ''
}`}
onClick={() =>
setVisibleSource(sourceVisible ? undefined : source)
}
title={sourceLabel}
type="button"
>
<Code2 aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.htmlOpenViewer')}
className="icon-button"
onClick={() => setViewerSource(source)}
title={t('markdown.htmlOpenViewer')}
type="button"
>
<Maximize2 aria-hidden="true" size={17} />
</button>
</div>
</figcaption>
<iframe
loading="lazy"
referrerPolicy="no-referrer"
sandbox=""
srcDoc={documentSource}
title={t('markdown.htmlPreviewFrame')}
/>
{sourceVisible && <pre id={sourceId}>{source}</pre>}
{viewerOpen && (
<StaticHtmlViewer
documentSource={documentSource}
onClose={() => setViewerSource(undefined)}
/>
)}
</figure>
)
})
@@ -25,6 +25,7 @@ describe('UpdateSettingsSection', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+13 -1
View File
@@ -703,6 +703,18 @@ export const app = {
mermaidZoomIn: 'Zoom in diagram',
mermaidResetZoom: 'Reset zoom',
mermaidZoomLevel: 'Current zoom level',
mermaidCloseViewer: 'Close large Mermaid diagram'
mermaidCloseViewer: 'Close large Mermaid diagram',
htmlPreview: 'HTML preview',
htmlPreviewNotice:
'Static safe preview with scripts and network access disabled',
htmlPreviewFrame: 'Static HTML preview from the Agent reply',
htmlActions: 'HTML preview actions',
htmlViewSource: 'View source',
htmlHideSource: 'Hide source',
htmlOpenViewer: 'Full-screen preview',
htmlViewerTitle: 'Full-screen HTML preview',
htmlViewerFrame:
'Full-screen static HTML preview from the Agent reply',
htmlCloseViewer: 'Close full-screen preview'
}
} satisfies TranslationShape<typeof chineseApp>
@@ -540,6 +540,8 @@ export const settingsSections = {
serviceUnavailable:
'Application settings are not available in this version',
readFailed: 'Could not load platform feature settings',
saveConversationHtmlRenderingFailed:
'Could not save the conversation HTML rendering setting. Try again.',
saveRemoteProjectsFailed:
'Could not save the Remote Projects setting. Try again.',
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
@@ -558,6 +560,12 @@ export const settingsSections = {
general: 'General',
magicNotes: 'Magic Notes'
},
conversationHtml: {
title: 'Conversation HTML rendering',
description:
'Shows complete HTML documents and HTML code blocks in Agent replies as static previews. Previews cannot run scripts, access the network, submit forms, or open windows.',
enabled: 'Render HTML in conversations'
},
remoteProjects: {
title: 'Remote Projects (Technical Preview)',
description:
+11 -1
View File
@@ -647,6 +647,16 @@ export const app = {
mermaidZoomIn: '放大图表',
mermaidResetZoom: '重置缩放',
mermaidZoomLevel: '当前缩放比例',
mermaidCloseViewer: '关闭 Mermaid 大图'
mermaidCloseViewer: '关闭 Mermaid 大图',
htmlPreview: 'HTML 预览',
htmlPreviewNotice: '静态安全预览,脚本和网络已禁用',
htmlPreviewFrame: 'Agent 回复 HTML 静态预览',
htmlActions: 'HTML 预览操作',
htmlViewSource: '查看源码',
htmlHideSource: '隐藏源码',
htmlOpenViewer: '全屏预览',
htmlViewerTitle: 'HTML 全屏预览',
htmlViewerFrame: 'Agent 回复 HTML 全屏静态预览',
htmlCloseViewer: '关闭全屏预览'
}
} as const
@@ -515,6 +515,8 @@ export const settingsSections = {
errors: {
serviceUnavailable: '当前版本未提供应用设置服务',
readFailed: '读取平台功能设置失败',
saveConversationHtmlRenderingFailed:
'保存会话 HTML 渲染设置失败,请重试',
saveRemoteProjectsFailed: '保存远程项目设置失败,请重试',
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
saveIncompleteTodoCountFailed:
@@ -529,6 +531,12 @@ export const settingsSections = {
general: '通用设置',
magicNotes: '魔法笔记'
},
conversationHtml: {
title: '会话 HTML 渲染',
description:
'将 Agent 回复中的完整 HTML 或 HTML 代码块直接显示为静态预览。预览不会执行脚本、联网、提交表单或打开窗口。',
enabled: '在会话中渲染 HTML'
},
remoteProjects: {
title: '远程项目(技术预览)',
description: '启用 SSH Host 管理和由 GoodBuddy 托管的远程项目。',
+120
View File
@@ -5598,6 +5598,126 @@ button > svg {
margin: 0;
}
.message-html-preview {
display: grid;
min-width: 0;
margin: 0;
gap: var(--space-2);
}
.message-html-preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.message-html-preview__header > span {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.message-html-preview__header > span > span {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.message-html-preview__header small {
color: var(--text-secondary);
}
.message-html-preview__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
}
.message-html-preview > iframe {
width: 100%;
height: 480px;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.message-html-preview > pre {
max-height: 480px;
margin: 0;
overflow: auto;
}
.html-preview-viewer-backdrop {
position: fixed;
z-index: 120;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.html-preview-viewer {
display: grid;
width: min(1280px, 100%);
height: min(860px, calc(100vh - 70px));
min-height: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr);
}
.html-preview-viewer__header {
display: flex;
min-width: 0;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
justify-content: space-between;
gap: var(--space-3);
}
.html-preview-viewer__header > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.html-preview-viewer__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.html-preview-viewer__header small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.4;
}
.html-preview-viewer > iframe {
width: 100%;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0 0 var(--radius-card) var(--radius-card);
background: var(--surface-raised);
}
@media (max-width: 720px) {
.html-preview-viewer-backdrop {
padding: var(--space-2);
}
.html-preview-viewer {
height: calc(100vh - 54px);
}
}
.markdown-content > :first-child {
margin-top: 0;
}
@@ -36,6 +36,7 @@ const applicationPreferencesSchema = z
updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
localToolEnvironment: localToolEnvironmentSettingsSchema,
conversationHtmlRenderingEnabled: z.boolean(),
remoteProjectsEnabled: z.boolean(),
magicNotesEnabled: z.boolean(),
magicNotesShowIncompleteTodoCount: z.boolean(),
@@ -137,6 +137,7 @@ describe('GoodBuddy configuration contracts', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,