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:
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 托管的远程项目。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user