From c17d83fc59c36eb3fec0c8f11c22befd56b84ee2 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 19 Jul 2026 22:02:49 +0800 Subject: [PATCH] feat(web-ai): render markdown answers safely --- .../common/core/constant/SystemConstants.java | 2 +- .../aihr/webai/AihrWebAiSecurityTest.java | 8 ++ mobile-uni/src/pages/user/web-ai/index.vue | 73 ++++++++++- mobile-uni/src/utils/markdown.ts | 120 ++++++++++++++++++ mobile-uni/tests/web-ai-markdown.test.mjs | 24 ++++ 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 mobile-uni/src/utils/markdown.ts create mode 100644 mobile-uni/tests/web-ai-markdown.test.mjs diff --git a/backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java b/backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java index e38d9fd2..de5b7860 100644 --- a/backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java +++ b/backend/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java @@ -85,7 +85,7 @@ public interface SystemConstants { /** * 排除敏感属性字段 */ - String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword" }; + String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword", "apiKey", "api_key" }; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java index a7aab0f3..4d724523 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java @@ -8,6 +8,7 @@ import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult; import org.dromara.aihr.webai.AihrWebSearchClient.WebSource; import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest; import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider; +import org.dromara.common.core.constant.SystemConstants; import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -54,6 +55,13 @@ class AihrWebAiSecurityTest { assertFalse(mapper.writeValueAsString(request).contains("must-not-reach-logs")); } + @Test + @Tag("dev") + void requestLogMasksProviderApiKeys() { + assertTrue(List.of(SystemConstants.EXCLUDE_PROPERTIES).contains("apiKey")); + assertTrue(List.of(SystemConstants.EXCLUDE_PROPERTIES).contains("api_key")); + } + @Test @Tag("dev") void searchEndpointRejectsLocalAndPrivateNetworks() { diff --git a/mobile-uni/src/pages/user/web-ai/index.vue b/mobile-uni/src/pages/user/web-ai/index.vue index c2005f10..d99f74ac 100644 --- a/mobile-uni/src/pages/user/web-ai/index.vue +++ b/mobile-uni/src/pages/user/web-ai/index.vue @@ -62,7 +62,7 @@ 已找到公开来源,但外部服务未返回可核验答案,请直接查看来源。 未找到可核验的公开依据,本次不生成推断答案。 - {{ msg.result.answer }} + 已自动隐藏问题中的个人或门禁信息 {{ msg.result.warning }} @@ -119,6 +119,7 @@ import ChatComposer from '@/components/chat/ChatComposer.vue'; import { isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai'; +import { renderMarkdown } from '@/utils/markdown'; const path = '/pages/user/web-ai/index'; @@ -505,6 +506,76 @@ onUnload(() => { white-space: pre-wrap; } +.bubble-markdown { + white-space: normal; +} + +.bubble-markdown :deep(p), +.bubble-markdown :deep(h1), +.bubble-markdown :deep(h2), +.bubble-markdown :deep(h3), +.bubble-markdown :deep(blockquote), +.bubble-markdown :deep(ul), +.bubble-markdown :deep(ol), +.bubble-markdown :deep(pre) { + margin: 0 0 8px; +} + +.bubble-markdown :deep(h1), +.bubble-markdown :deep(h2), +.bubble-markdown :deep(h3) { + color: #18202b; + font-weight: 800; + line-height: 1.4; +} + +.bubble-markdown :deep(h1) { font-size: 18px; } +.bubble-markdown :deep(h2) { font-size: 17px; } +.bubble-markdown :deep(h3) { font-size: 16px; } + +.bubble-markdown :deep(ul), +.bubble-markdown :deep(ol) { + padding-left: 20px; +} + +.bubble-markdown :deep(li + li) { + margin-top: 4px; +} + +.bubble-markdown :deep(blockquote) { + padding-left: 10px; + border-left: 3px solid #cdd8e4; + color: #5d6875; +} + +.bubble-markdown :deep(a) { + color: #315b8f; + text-decoration: underline; +} + +.bubble-markdown :deep(code) { + padding: 1px 4px; + border-radius: 4px; + background: #eef1f4; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; +} + +.bubble-markdown :deep(pre) { + max-width: 100%; + box-sizing: border-box; + padding: 8px; + overflow-x: auto; + border-radius: 6px; + background: #eef1f4; + white-space: pre; +} + +.bubble-markdown :deep(pre code) { + padding: 0; + background: transparent; +} + .bubble-subnote { display: block; margin-top: 8px; diff --git a/mobile-uni/src/utils/markdown.ts b/mobile-uni/src/utils/markdown.ts new file mode 100644 index 00000000..418c001f --- /dev/null +++ b/mobile-uni/src/utils/markdown.ts @@ -0,0 +1,120 @@ +const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}[char] || char)); + +const safeHref = (value: string) => { + try { + const url = new URL(value.trim()); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : ''; + } catch { + return ''; + } +}; + +const inline = (source: string): string => { + const tokens: string[] = []; + const token = (html: string) => `\uE000${tokens.push(html) - 1}\uE001`; + let value: string = source + .replace(/`([^`\n]+)`/g, (_, code: string) => token(`${escapeHtml(code)}`)) + .replace(/\[([^\]\n]+)]\(([^)\n]+)\)/g, (_, label: string, rawHref: string) => { + const href = safeHref(rawHref); + return href ? token(`${inline(label)}`) : _; + }); + + value = escapeHtml(value) + .replace(/\*\*([^*\n]+)\*\*/g, '$1') + .replace(/__([^_\n]+)__/g, '$1') + .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2') + .replace(/(^|[^_])_([^_\n]+)_/g, '$1$2'); + + return value.replace(/\uE000(\d+)\uE001/g, (_: string, index: string) => tokens[Number(index)] || ''); +}; + +/** Converts the small Markdown subset returned by Web AI into safe rich-text HTML. */ +export const renderMarkdown = (value: string): string => { + const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n'); + const output: string[] = []; + let paragraph: string[] = []; + let quote: string[] = []; + let listType: 'ol' | 'ul' | null = null; + let listItems: string[] = []; + let code: string[] | null = null; + + const flushParagraph = () => { + if (paragraph.length) output.push(`

${paragraph.map(inline).join('
')}

`); + paragraph = []; + }; + const flushQuote = () => { + if (quote.length) output.push(`
${quote.join('
')}
`); + quote = []; + }; + const flushList = () => { + if (listType) output.push(`<${listType}>${listItems.map((item) => `
  • ${item}
  • `).join('')}`); + listType = null; + listItems = []; + }; + const flushBlocks = () => { + flushParagraph(); + flushQuote(); + flushList(); + }; + + for (const line of lines) { + if (/^```/.test(line)) { + flushBlocks(); + if (code) { + output.push(`
    ${escapeHtml(code.join('\n'))}
    `); + code = null; + } else { + code = []; + } + continue; + } + if (code) { + code.push(line); + continue; + } + if (!line.trim()) { + flushBlocks(); + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.+)$/); + if (heading) { + flushBlocks(); + output.push(`${inline(heading[2])}`); + continue; + } + const quoted = line.match(/^>\s?(.*)$/); + if (quoted) { + flushParagraph(); + flushList(); + quote.push(inline(quoted[1])); + continue; + } + const unordered = line.match(/^\s*[-+*]\s+(.+)$/); + const ordered = line.match(/^\s*\d+\.\s+(.+)$/); + if (unordered || ordered) { + flushParagraph(); + flushQuote(); + const type = ordered ? 'ol' : 'ul'; + if (listType !== type) { + flushList(); + listType = type; + } + listItems.push(inline((ordered || unordered)![1])); + continue; + } + flushQuote(); + flushList(); + paragraph.push(line); + } + + if (code) output.push(`
    ${escapeHtml(code.join('\n'))}
    `); + flushBlocks(); + return output.join('') || `

    ${escapeHtml(String(value || ''))}

    `; +}; diff --git a/mobile-uni/tests/web-ai-markdown.test.mjs b/mobile-uni/tests/web-ai-markdown.test.mjs new file mode 100644 index 00000000..df08bd8a --- /dev/null +++ b/mobile-uni/tests/web-ai-markdown.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import ts from 'typescript'; + +const source = await readFile(new URL('../src/utils/markdown.ts', import.meta.url), 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 } +}).outputText; +const { renderMarkdown } = await import(`data:text/javascript,${encodeURIComponent(compiled)}`); + +test('全网答案 Markdown 只渲染白名单标签并转义原始 HTML', () => { + const html = renderMarkdown('### 标题\n**加粗** 和 *强调*,含 [官网](https://example.com)。\n\n- 一项\n- 二项\n\n> 引用\n\n`行内`\n\n```\n\n```'); + + assert.match(html, /

    标题<\/h3>/); + assert.match(html, /加粗<\/strong>/); + assert.match(html, /强调<\/em>/); + assert.match(html, /href="https:\/\/example\.com\//); + assert.match(html, /
    • 一项<\/li>
    • 二项<\/li><\/ul>/); + assert.match(html, /
      引用<\/blockquote>/); + assert.match(html, /<script>alert\(1\)<\/script>/); + assert.equal(renderMarkdown('普通文本'), '

      普通文本

      '); + assert.doesNotMatch(renderMarkdown('[危险](javascript:alert(1))'), /href=/); +});