mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Fix/usage json entity escaping (#333)
* fix(usage): 修复 JSON 视图实体转义显示 * fix(usage): 解码 OpenAI 工具参数实体
This commit is contained in:
@@ -180,6 +180,8 @@ const escapeHtml = (str: string): string => {
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jsonStringLiteral = (value: string): string => JSON.stringify(value)
|
||||||
|
|
||||||
const parseJsonToLines = (data: unknown): JsonLine[] => {
|
const parseJsonToLines = (data: unknown): JsonLine[] => {
|
||||||
const result: JsonLine[] = []
|
const result: JsonLine[] = []
|
||||||
let lineNumber = 1
|
let lineNumber = 1
|
||||||
@@ -222,7 +224,7 @@ const parseJsonToLines = (data: unknown): JsonLine[] => {
|
|||||||
id: result.length,
|
id: result.length,
|
||||||
lineNumber: lineNumber++,
|
lineNumber: lineNumber++,
|
||||||
indent,
|
indent,
|
||||||
html: keyPrefix + getTokenHtml(`"${escapeHtml(value)}"`, 'string') + comma,
|
html: keyPrefix + getTokenHtml(jsonStringLiteral(value), 'string') + comma,
|
||||||
canFold: false,
|
canFold: false,
|
||||||
blockId: '',
|
blockId: '',
|
||||||
})
|
})
|
||||||
@@ -294,7 +296,7 @@ const parseJsonToLines = (data: unknown): JsonLine[] => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
keys.forEach((key, i) => {
|
keys.forEach((key, i) => {
|
||||||
const keyHtml = getTokenHtml(`"${escapeHtml(key)}"`, 'key') + getTokenHtml(': ', 'punctuation')
|
const keyHtml = getTokenHtml(jsonStringLiteral(key), 'key') + getTokenHtml(': ', 'punctuation')
|
||||||
processValue(obj[key], indent + 1, i === keys.length - 1, keyHtml)
|
processValue(obj[key], indent + 1, i === keys.length - 1, keyHtml)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -109,4 +109,55 @@ describe('Conversation stream compatibility', () => {
|
|||||||
content: 'Hello from legacy alias',
|
content: 'Hello from legacy alias',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders HTML-entity encoded OpenAI tool arguments as formatted JSON', () => {
|
||||||
|
const requestBody = {
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Call a tool' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const responseBody = {
|
||||||
|
id: 'chatcmpl_tool_123',
|
||||||
|
object: 'chat.completion',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_123',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'skill',
|
||||||
|
arguments: '{"name":"hai-ai","user_message":"A & B < C > D 'ok'"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = renderResponse(responseBody, requestBody, 'openai:chat')
|
||||||
|
expect(rendered.error).toBeUndefined()
|
||||||
|
|
||||||
|
const firstBlock = rendered.blocks[0]
|
||||||
|
if (!firstBlock || firstBlock.type !== 'message') {
|
||||||
|
throw new Error('expected first render block to be message')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(firstBlock.content[0]).toMatchObject({
|
||||||
|
type: 'tool_use',
|
||||||
|
toolName: 'skill',
|
||||||
|
input: JSON.stringify({
|
||||||
|
name: 'hai-ai',
|
||||||
|
user_message: "A & B < C > D 'ok'",
|
||||||
|
}, null, 2),
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,63 @@ import {
|
|||||||
/** Raw JSON object from API (loosely typed) */
|
/** Raw JSON object from API (loosely typed) */
|
||||||
type RawObject = Record<string, unknown>
|
type RawObject = Record<string, unknown>
|
||||||
|
|
||||||
|
type JsonParseResult =
|
||||||
|
| { ok: true; value: unknown }
|
||||||
|
| { ok: false }
|
||||||
|
|
||||||
|
const HTML_ENTITY_MAP: Record<string, string> = {
|
||||||
|
amp: '&',
|
||||||
|
apos: "'",
|
||||||
|
gt: '>',
|
||||||
|
lt: '<',
|
||||||
|
nbsp: '\u00A0',
|
||||||
|
quot: '"',
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseJsonString = (input: string): JsonParseResult => {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(input) as unknown }
|
||||||
|
} catch {
|
||||||
|
return { ok: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeHtmlEntityToken = (entity: string): string => {
|
||||||
|
const normalized = entity.toLowerCase()
|
||||||
|
const named = HTML_ENTITY_MAP[normalized]
|
||||||
|
if (named !== undefined) {
|
||||||
|
return named
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('#x')) {
|
||||||
|
const codePoint = Number.parseInt(normalized.slice(2), 16)
|
||||||
|
if (Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10FFFF) {
|
||||||
|
return String.fromCodePoint(codePoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('#')) {
|
||||||
|
const codePoint = Number.parseInt(normalized.slice(1), 10)
|
||||||
|
if (Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10FFFF) {
|
||||||
|
return String.fromCodePoint(codePoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `&${entity};`
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeHtmlEntities = (input: string): string => {
|
||||||
|
let decoded = input
|
||||||
|
for (let pass = 0; pass < 3; pass += 1) {
|
||||||
|
const next = decoded.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (_match, entity: string) => decodeHtmlEntityToken(entity))
|
||||||
|
if (next === decoded) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
decoded = next
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAI API 格式解析器
|
* OpenAI API 格式解析器
|
||||||
*/
|
*/
|
||||||
@@ -1153,12 +1210,21 @@ export class OpenAIParser implements ApiFormatParser {
|
|||||||
*/
|
*/
|
||||||
private formatJson(input: unknown): string {
|
private formatJson(input: unknown): string {
|
||||||
if (typeof input === 'string') {
|
if (typeof input === 'string') {
|
||||||
try {
|
const parsed = parseJsonString(input)
|
||||||
const parsed = JSON.parse(input) as unknown
|
if (parsed.ok) {
|
||||||
return JSON.stringify(parsed, null, 2)
|
return JSON.stringify(parsed.value, null, 2)
|
||||||
} catch {
|
|
||||||
return input
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decoded = decodeHtmlEntities(input)
|
||||||
|
if (decoded !== input) {
|
||||||
|
const parsedDecoded = parseJsonString(decoded)
|
||||||
|
if (parsedDecoded.ok) {
|
||||||
|
return JSON.stringify(parsedDecoded.value, null, 2)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
return input
|
||||||
}
|
}
|
||||||
return JSON.stringify(input, null, 2)
|
return JSON.stringify(input, null, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user