Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .dsh-plugin/client.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 55 additions & 5 deletions .dsh-plugin/client/transcript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,64 @@
// 以系统行进入历史。说话人映射(名字/颜色)也在这里。

/**
* 对话文本清洗:统一换行符;纯空白行视为空行;连续多个空行折叠为一个段落分隔;
* 去首尾空行。流式与定稿共用同一函数(contentToText/assistantToText 内应用),
* 避免模型输出成串空行占据文本框空间,且定稿切换时文本不重打。
* 剥离常见 Markdown 标记,保留纯文本内容。
*
* 处理范围:围栏代码块、行内代码、图片、链接(含参考式与自动链接)、
* 标题/引用/有序无序列表行首标记、水平分隔线、删除线、粗体与斜体强调。
* 不做完整 Markdown 解析——Galgame 台词框只需去掉视觉噪声字符(如 * ` # >)。
*
* 边界规则:斜体 `*x*`/`_x_` 要求起始符不紧跟空白、结束符不紧贴空白,
* 且两侧均不在字母数字内,避免误伤 `2 * 3`、`a*b`、`snake_case`;粗体 `**x**`/`__x__`
* 因双符成对无歧义,先用宽松规则剥离再处理斜体。未闭合的标记保持原样(安全)。
*
* @param {string} text - 原始文本(建议先按 \n 归一换行)。
* @returns {string} 去除 Markdown 标记后的纯文本。
*/
export function cleanDialogueText(text) {
export function stripMarkdown(text) {
if (typeof text !== 'string') return ''
return text
.replace(/\r\n?/g, '\n')
// 围栏代码块:```lang\n...\n``` → 保留正文(去围栏与语言标识)。
.replace(/```[^\n]*\n?([\s\S]*?)\n?```/g, '$1')
// 行内代码:`code` → code
.replace(/`([^`\n]+)`/g, '$1')
// 图片:![alt](url) → alt
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
// 链接:[text](url) → text
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
// 参考式链接:[text][ref] → text
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1')
// 自动链接:<scheme:...> → scheme(保留协议头,丢弃尖括号)
.replace(/<([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]+)>/g, '$1')
// 标题行首标记:# ## ### ...
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
// 引用行首标记:> >
.replace(/^[ \t]{0,3}>[ \t]?/gm, '')
// 有序列表行首标记:1. / 1)
.replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, '')
// 无序列表行首标记:- * +
.replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '')
// 水平分隔线(独占一行:--- *** ___)
.replace(/^[ \t]{0,3}([-*_])\1{2,}[ \t]*$/gm, '')
// 删除线:~~text~~ → text
.replace(/~~([^\n~]+)~~/g, '$1')
// 粗体:**text** / __text__ → text(双符成对,先用宽松规则)
.replace(/\*\*([^\n*]+)\*\*/g, '$1')
.replace(/__([^\n_]+)__/g, '$1')
// 斜体 *text* → text:起始符不跟空白、结束符不贴空白,两侧不在字母数字中。
.replace(/(?<![A-Za-z0-9*])\*(?!\s|\*)([^\n*]+?\S)\*(?![A-Za-z0-9*])/g, '$1')
// 斜体 _text_ → text:同上边界,避免误伤 snake_case。
.replace(/(?<![A-Za-z0-9_])_(?!\s|_)([^\n_]+?\S)_(?![A-Za-z0-9_])/g, '$1')
}

/**
* 对话文本清洗:统一换行符;剥离 Markdown 标记;纯空白行视为空行;
* 连续多个空行折叠为一个段落分隔;去首尾空行。流式与定稿共用同一函数
* (contentToText/assistantToText 内应用),避免模型输出成串空行占据文本框空间,
* 且定稿切换时文本不重打。
*/
export function cleanDialogueText(text) {
if (typeof text !== 'string') return ''
return stripMarkdown(text.replace(/\r\n?/g, '\n'))
.replace(/^[ \t]+$/gm, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
Expand Down
68 changes: 67 additions & 1 deletion tests/transcript.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
contentToText, assistantToText, partialToText, lineFromNode, nodesToLines,
speakerFor, assistantSpeaker, playerSpeaker, welcomeLine, cleanDialogueText,
speakerFor, assistantSpeaker, playerSpeaker, welcomeLine, cleanDialogueText, stripMarkdown,
playerDisplayName, assistantDisplayName, roleNameElement, partialStatus, deriveStatus,
} from '../.dsh-plugin/client/transcript.mjs'
import { defaultScene, normalizeScene } from '../.dsh-plugin/client/scene.mjs'
Expand Down Expand Up @@ -112,6 +112,72 @@ test('assistantSpeaker 失效引用回退系统', () => {
assert.equal(assistantSpeaker(named).name, '雾子')
})

test('stripMarkdown 剥离行内强调标记', () => {
assert.equal(stripMarkdown('*斜体*'), '斜体')
assert.equal(stripMarkdown('**粗体**'), '粗体')
assert.equal(stripMarkdown('~~删除线~~'), '删除线')
assert.equal(stripMarkdown('`代码`'), '代码')
assert.equal(stripMarkdown('_斜体_'), '斜体')
assert.equal(stripMarkdown('__粗体__'), '粗体')
// 混排:粗体 + 斜体共存
assert.equal(stripMarkdown('**粗体** 和 *斜体*'), '粗体 和 斜体')
// 未闭合标记保持原样(安全)
assert.equal(stripMarkdown('*未闭合'), '*未闭合')
assert.equal(stripMarkdown('**未闭合'), '**未闭合')
})

test('stripMarkdown 不误伤非强调用法', () => {
// 数学乘法(带空格)不剥离
assert.equal(stripMarkdown('2 * 3 = 6'), '2 * 3 = 6')
assert.equal(stripMarkdown('a * b * c'), 'a * b * c')
// 词内星号不剥离
assert.equal(stripMarkdown('a*b*c'), 'a*b*c')
// snake_case 不剥离
assert.equal(stripMarkdown('snake_case_var'), 'snake_case_var')
assert.equal(stripMarkdown('foo_bar 与 baz_qux'), 'foo_bar 与 baz_qux')
// 行首单独 * 作为列表项才剥离
assert.equal(stripMarkdown('* 列表项'), '列表项')
})

test('stripMarkdown 剥离行首结构与块级标记', () => {
assert.equal(stripMarkdown('# 标题'), '标题')
assert.equal(stripMarkdown('## 二级标题'), '二级标题')
assert.equal(stripMarkdown('> 引用'), '引用')
assert.equal(stripMarkdown('- 无序项'), '无序项')
assert.equal(stripMarkdown('+ 加号项'), '加号项')
assert.equal(stripMarkdown('1. 有序项'), '有序项')
assert.equal(stripMarkdown('2) 有序项'), '有序项')
// 多行列表:逐行剥离行首标记
assert.equal(stripMarkdown('* 一\n* 二\n* 三'), '一\n二\n三')
})

test('stripMarkdown 剥离链接与图片', () => {
assert.equal(stripMarkdown('[链接文本](https://example.com)'), '链接文本')
assert.equal(stripMarkdown('![替代文本](https://example.com/a.png)'), '替代文本')
assert.equal(stripMarkdown('前文 [链接](url) 后文'), '前文 链接 后文')
assert.equal(stripMarkdown('[参考][ref1]'), '参考')
assert.equal(stripMarkdown('<https://example.com>'), 'https://example.com')
})

test('stripMarkdown 剥离代码块围栏', () => {
assert.equal(stripMarkdown('```js\nconsole.log(1)\n```'), 'console.log(1)')
assert.equal(stripMarkdown('```\n纯文本代码\n```'), '纯文本代码')
})

test('stripMarkdown 剥离水平分隔线', () => {
assert.equal(stripMarkdown('上文\n---\n下文'), '上文\n\n下文')
assert.equal(stripMarkdown('上文\n***\n下文'), '上文\n\n下文')
})

test('cleanDialogueText 剥离 Markdown 标记后正常清洗', () => {
assert.equal(cleanDialogueText('**你好**,世界'), '你好,世界')
assert.equal(cleanDialogueText('这是 *斜体* 内容'), '这是 斜体 内容') // 行中斜体剥离
assert.equal(cleanDialogueText('# 标题\n正文'), '标题\n正文')
assert.equal(cleanDialogueText('- 项一\n- 项二'), '项一\n项二')
assert.equal(cleanDialogueText('[link](url) 文本'), 'link 文本')
assert.equal(cleanDialogueText(null), '')
})

test('cleanDialogueText 折叠成串空行并去首尾空行', () => {
assert.equal(cleanDialogueText('a\n\n\n\n\nb'), 'a\n\nb')
assert.equal(cleanDialogueText('a\n\n\n\n\n\n\n\n\n\nb'), 'a\n\nb')
Expand Down