fix: 讯飞鉴权HTTP兼容+AI段落紧凑+对话模式JSON降级

- ReportEditor: 讯飞鉴权改用crypto-js HMAC-SHA256,兼容HTTP非安全环境
- defaultContent.ts+ReportEditor: AI区域<p>标签去掉margin-bottom,段落紧密排布
- ReportEditor: 纯对话模式下JSON解析失败时降级为直接文本回复
- ReportEditor: 对话模式systemPrompt强化JSON格式约束
- deps: 新增crypto-js用于讯飞鉴权
This commit is contained in:
2026-04-20 01:18:57 +08:00
parent ea789cee26
commit d235ced187
4 changed files with 33 additions and 10 deletions

14
package-lock.json generated
View File

@@ -10,7 +10,9 @@
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/crypto-js": "^4.2.2",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"crypto-js": "^4.2.0",
"diff": "^9.0.0", "diff": "^9.0.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
@@ -1492,6 +1494,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1904,6 +1912,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",

View File

@@ -13,7 +13,9 @@
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/crypto-js": "^4.2.2",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"crypto-js": "^4.2.0",
"diff": "^9.0.0", "diff": "^9.0.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",

View File

@@ -13,6 +13,7 @@ import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print'; import { printDocument } from '../utils/print';
import { storage, getDefaultApiKey } from '../utils/storage'; import { storage, getDefaultApiKey } from '../utils/storage';
import { diffChars } from 'diff'; import { diffChars } from 'diff';
import CryptoJS from 'crypto-js';
export default function ReportEditor() { export default function ReportEditor() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -897,11 +898,8 @@ export default function ReportEditor() {
const host = 'iat-api.xfyun.cn'; const host = 'iat-api.xfyun.cn';
const date = new Date().toUTCString(); const date = new Date().toUTCString();
const signatureOrigin = `host: "${host}"\ndate: "${date}"\nGET /v2/iat HTTP/1.1`; const signatureOrigin = `host: "${host}"\ndate: "${date}"\nGET /v2/iat HTTP/1.1`;
const encoder = new TextEncoder(); const signature = CryptoJS.HmacSHA256(signatureOrigin, apiSecret).toString(CryptoJS.enc.Base64);
const key = await crypto.subtle.importKey('raw', encoder.encode(apiSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signatureOrigin));
const signatureBase64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signatureBase64}"`;
const authorization = btoa(authorizationOrigin); const authorization = btoa(authorizationOrigin);
return `wss://iat-api.xfyun.cn/v2/iat?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(date)}&host=${encodeURIComponent(host)}`; return `wss://iat-api.xfyun.cn/v2/iat?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(date)}&host=${encodeURIComponent(host)}`;
} }
@@ -1054,7 +1052,7 @@ export default function ReportEditor() {
} }
const systemPrompt = aiModifyEnabled const systemPrompt = aiModifyEnabled
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】直接重写并输出目标区域的 HTML。\n\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用 <p> 标签包裹,段落间绝对不要使用 <br> 标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json' ? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】直接重写并输出目标区域的 HTML。\n\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用 <p> 标签包裹,段落间绝对不要使用 <br> 标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json'
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】并根据【医生指令】进行专业解答。\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记'; : '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】并根据【医生指令】进行专业解答。\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记\n5. 无论图片内容是什么,请严格以纯 JSON 格式输出,禁止任何多余的开头解释';
const payload: any = { const payload: any = {
model: modelName, model: modelName,
messages: [ messages: [
@@ -1106,9 +1104,18 @@ export default function ReportEditor() {
responseJson = JSON.parse(cleanedText); responseJson = JSON.parse(cleanedText);
} catch { } catch {
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/); const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
if (jsonMatch) responseJson = JSON.parse(jsonMatch[0]); if (jsonMatch) {
try {
responseJson = JSON.parse(jsonMatch[0]);
} catch {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON'); else throw new Error('AI 返回格式异常,无法解析 JSON');
} }
} else {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
}
if (responseJson.reply) { if (responseJson.reply) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]); setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
} }
@@ -1121,7 +1128,7 @@ export default function ReportEditor() {
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>'); cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
cleanHtml = cleanHtml.trim(); cleanHtml = cleanHtml.trim();
cleanHtml = cleanHtml.replace(/>(\s+)</g, '><'); cleanHtml = cleanHtml.replace(/>(\s+)</g, '><');
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">'); cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">');
if (targetRegionEl) { if (targetRegionEl) {
setDiffModal({ setDiffModal({
isOpen: true, isOpen: true,

View File

@@ -57,7 +57,7 @@ export const defaultReportContent = `
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"> <div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;">
<div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none;">手术步骤、术中出现的情况及处理-AI可编辑区域</div> <div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none;">手术步骤、术中出现的情况及处理-AI可编辑区域</div>
<div class="ai-content" style="min-height: 20px;"><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">1患者仰卧位麻醉成功后常规消毒术野、铺无菌巾于脐下穿刺建立CO2气腹气腹压力为12mmHg进镜探查无穿刺损伤分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">2腹腔镜探查腹腔内无腹水形成无明显粘连肝脏色红质软无明显结节硬化改变胆囊大小约 cm× cm× cm壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">3.切除胆囊钳夹胆囊颈部并解剖胆囊三角游离出胆囊动脉及胆囊管明确胆囊与胆总管的关系距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。</p></div> <div class="ai-content" style="min-height: 20px;"><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">1患者仰卧位麻醉成功后常规消毒术野、铺无菌巾于脐下穿刺建立CO2气腹气腹压力为12mmHg进镜探查无穿刺损伤分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">2腹腔镜探查腹腔内无腹水形成无明显粘连肝脏色红质软无明显结节硬化改变胆囊大小约 cm× cm× cm壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">3.切除胆囊钳夹胆囊颈部并解剖胆囊三角游离出胆囊动脉及胆囊管明确胆囊与胆总管的关系距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。</p></div>
</div> </div>
<!-- 手术图片说明表格 --> <!-- 手术图片说明表格 -->