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',
|
||||
|
||||
Reference in New Issue
Block a user