Files
tuanwei-uniapp/pages/assistant/writing-params.js
T
weicw-dshsa 244f0c24d8 feat(ai): 问答助手页入口对齐 web,改为「聊天/写作/图片」(issue 15)
模式标签由「聊天/稿件/创意/策划」改为「聊天/写作/图片」,去掉策划链路;
写作与图片两个入口补上 web 端已有的表单与附件能力。

- 新增 pages/assistant/writing-params.js:web 端 writing-params.ts 的等价移植
  (零依赖纯函数)。写作模式加「文体/字数」面板,入参优先级改为
  「界面显式选择 > 键值入参 > 自然语言推断 > 默认值」——过去没有表单,
  只按「键:值」解析,解析不到就一律回落 doc_type=新闻稿 / Number=800,
  而上游提示词把这两项当主控指令,会直接压掉用户原话里的诉求(issue 13)
- 图片模式加「图片比例 + 参考素材」:附件组装成 user_files(上限 5,
  类型 image/video/audio/document),比例作为文本追加进 user_content
- chat-service 新增 uploadAiFile(file, app);上传必须带 app='creativeAssistant'
  —— Dify 的 /files/upload 按应用鉴权,不传 app 的附件必然报
  Invalid upload file(issue 14)
- 删除 buildOutlinePayload / formatOutlineResult 与 activityOutlineGen import;
  顺带清掉本次改动块内的死代码(未使用的 getCurrentUser、pickDisplayableText)

顺带修掉同源缺陷:pages/creative-assistant 的 uploadAiFile(file) 也没传应用凭证,
该页附件同样是坏的(issue 14 记的 gxmu/creativeAssistant 表单页那个缺陷)。

验证:移植等价性用例 8/8 —— 同一张用例表同时喂给小程序与 web 两端实现逐字段比对
JSON,另加 4 条常量/边界断言(防「两边一起错」);模板↔脚本静态一致性检查
(模板绑定名、this.X() 调用点、已删标识符残留)全绿,判别力经 5 处变异测试证伪;
5 个改动文件 node --check 通过,style 块花括号配平。
本机无 HBuilderX CLI 与 package.json,真机/微信开发者工具未跑。

本文件同时带有此前未提交的 issue 01 跟进改动(resolveFailedCreativeText:
创意失败态采用后端按原因映射的文案,但仍过 isDisplayableAnswer 兜底),
同一文件无法拆分。

Refs: .scratch/ai-assistant/issues/15-uniapp-assistant-modes-align-web.md
2026-10-13 20:17:58 +08:00

342 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「AI 写作助手」入参解析:把用户自然语言诉求抽取成上游 Dify 真正读取的字段。
*
* 本文件是 `tuanwei-admin/src/views/oa/chatgpt/writing-params.ts` 的**逐行等价移植**
* (去掉 TS 类型标注,风格对齐小程序仓库:Tab 缩进、单引号、无分号)。
* 两端共用同一套解析规则,避免行为漂移;改动其一请同步另一侧。
*
* 背景(实测记录见 .scratch/ai-assistant/probe/):
* 上游应用「AI 内容创作与宣传助手」是按**结构化表单**设计的,它的 user prompt 模板是
*
* 本次需要生成的文稿类型为:{doc_type}
* 篇幅要求(字数限制):{Number}
* 以下是本次活动的核心要素,请严格据此生成稿件:
* - 活动主题:{theme}
* - 活动类型:{event_type}
* - 举办时间:{time} / 举办地点:{location} / 参与人员:{participants}
* - 核心亮点与具体要求:{highlights}
*
* 其中 doc_type 与 Number 是**主控指令**,模型会显式服从、甚至忽略用户原话里的冲突要求。
* 实测思维链原文:
* I will ignore the conflicting "300字推文" instruction inside the bullet points
* because the main task says "文稿类型为:新闻稿" and "篇幅要求(字数限制):800"
*
* 而小程序问答助手页过去的「稿件」入口没有表单,只按「键:值」解析,解析不到就一律回落
* doc_type=新闻稿 / Number=800,用户自然语言里的真实诉求(推文、300 字)因此被覆盖,
* 表现为「写作模块听不懂要求」(issue 13 / 15)。
*
* 本模块做三件事:
* 1. 保留既有的「键:值」入参(向后兼容,老用法不受影响);
* 2. 从自然语言里抽取文体、字数、主题、时间、地点、人员、活动类型;
* 3. 允许用户在界面上显式指定文体 / 字数,显式选择优先于自动识别。
*
* 纯函数、零依赖。
*/
/** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */
export const WRITING_DOC_TYPE_OPTIONS = [
{ label: '自动识别', value: 'auto' },
{ label: '新闻稿', value: '新闻稿' },
{ label: '活动总结', value: '活动总结' },
{ label: '微信推文', value: '微信推文' },
{ label: '通知公告', value: '通知公告' }
]
export const WRITING_WORD_COUNT_OPTIONS = [
{ label: '自动识别', value: 'auto' },
{ label: '300字', value: '300' },
{ label: '500字', value: '500' },
{ label: '800字', value: '800' },
{ label: '1000字', value: '1000' },
{ label: '1500字', value: '1500' }
]
const DEFAULT_DOC_TYPE = '新闻稿'
const DEFAULT_EVENT_TYPE = '校园内容'
/**
* 用户没提字数时的兜底:按文体给一个常规篇幅。
*
* 之所以不干脆不下发 Number —— 上游提示词对空字数的处理是「按文体常规篇幅撰写,
* 并在文末用一行注明(未设定有效字数限制)」,那行尾注会直接出现在用户看到的正文里。
*/
const DEFAULT_WORD_COUNT_BY_DOC_TYPE = {
新闻稿: 800,
活动总结: 1000,
微信推文: 600,
通知公告: 500
}
const FIELD_ALIASES = {
docType: ['稿件类型', '文稿类型', '文体', '类型'],
eventType: ['活动类型', '场景'],
theme: ['稿件标题', '标题', '主题', '活动主题'],
time: ['活动时间', '举办时间', '时间'],
location: ['活动地点', '举办地点', '地点'],
participants: ['参与人员', '参加人员', '受众', '人员'],
highlights: ['亮点内容', '要点', '亮点', '具体要求', '要求', '内容'],
number: ['建议篇幅', '字数', '篇幅']
}
/** 活动类型关键词(与 gxmu/manuscriptGen 表单页的选项保持一致的措辞) */
const EVENT_TYPE_KEYWORDS = [
'志愿服务',
'社会实践',
'文体活动',
'会议论坛',
'品牌项目',
'校园活动',
'团日活动',
'迎新',
'讲座',
'培训',
'比赛',
'表彰',
'招聘',
'宣讲'
]
/** 既有的「键:值」解析:按行取 `键:值`,普通自然语言(无冒号)解析为空对象 */
export const parseKeyValueInput = (value) => {
const result = {}
String(value || '')
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.forEach((line) => {
const match = line.match(/^([^::]+)[::]\s*(.*)$/)
if (match) {
result[match[1].trim()] = match[2].trim()
}
})
return result
}
const pickField = (fields, aliases) => {
for (let index = 0; index < aliases.length; index += 1) {
const value = fields[aliases[index]]
if (value) {
return value
}
}
return ''
}
/** 上游没有内置模板、但用户会说的其它文体;命中就原样交给模型发挥 */
const EXTRA_DOC_TYPE_WORDS = [
'发言稿',
'演讲稿',
'致辞',
'主持词',
'短视频脚本',
'视频脚本',
'脚本',
'策划书',
'活动方案',
'简报',
'心得体会'
]
/**
* 从一段文本里识别文体关键词,认不出来返回空串。
*
* 只认「明确的文体词」,不做任何兜底 —— 它会被喂进用户的**整句话**,
* 若像 `normalizeDocType` 那样原样返回,整句话就会变成一个非法的文稿类型。
*/
export const inferDocType = (text) => {
if (/推文|公众号|微信|朋友圈|新媒体/.test(text)) {
return '微信推文'
}
if (/总结|复盘|综述/.test(text)) {
return '活动总结'
}
if (/通知|公告|告示|通告|启事/.test(text)) {
return '通知公告'
}
if (/新闻|通讯|通稿|报道|简讯|宣传稿|消息稿/.test(text)) {
return '新闻稿'
}
for (let index = 0; index < EXTRA_DOC_TYPE_WORDS.length; index += 1) {
if (text.includes(EXTRA_DOC_TYPE_WORDS[index])) {
return EXTRA_DOC_TYPE_WORDS[index]
}
}
return ''
}
/**
* 把用户给出的文体归一化到上游定义过的 4 种。
*
* 认不出来的类型(如「发言稿」「短视频脚本」)**原样返回**,交给模型自行发挥 ——
* 强行映射成「新闻稿」会生成一篇完全不是发言稿的文章,比不映射更糟。
* 注意:入参应当是「文体名」(键值或界面选择),不要传用户整句话。
*/
export const normalizeDocType = (raw) => {
const value = String(raw || '').trim()
if (!value) {
return ''
}
return inferDocType(value) || value
}
/**
* 从文本里抽取目标字数。
*
* 覆盖「300字」「300字左右」「不少于500字」「800-1000字」等写法;
* 区间取上界(用户给区间时通常不希望写少)。50~10000 之外视为无效。
*/
export const parseWordCount = (text) => {
const valid = (raw) => {
if (!raw) {
return undefined
}
const value = Number(raw)
return value >= 50 && value <= 10000 ? value : undefined
}
const source = String(text || '')
// 「键:值」入参里的字数常常就是一个裸数字(字数:500)
const bare = source.trim().match(/^(\d{2,5})\s*(?:字|字符)?$/)
if (bare) {
return valid(bare[1])
}
const range = source.match(/(\d{2,5})\s*[-~~—-至到]\s*(\d{2,5})\s*(?:字|字符)/)
if (range) {
return valid(range[2])
}
const single = source.match(/(\d{2,5})\s*(?:字|字符)/)
if (single) {
return valid(single[1])
}
const target = source.match(
/(?:不少于|不低于|至少|不超过|大约|约|控制在|限制在|需要|要)\s*(\d{2,5})\s*(?:字|字符)?/
)
return valid(target ? target[1] : undefined)
}
/**
* 主题清洗:把抽到的短语里混进来的时间、地点、指令动词和助词去掉。
* 例如「2026年3月5日在学校礼堂举办的学习雷锋志愿服务活动」→「学习雷锋志愿服务活动」。
*/
const tidyTheme = (value) => {
const cleaned = value
.replace(/^\s*(?:\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]\s*/, '')
.replace(/^\s*(?:在|于)[^,。;!?\n]{2,20}?(?:举办|举行|开展|召开|进行)的?/, '')
.replace(/^\s*(?:开展|举办|举行|组织|进行|召开)/, '')
.replace(/(?:上的|中的|里的|的)$/, '')
.replace(/[,。;!?、\s]+$/, '')
.trim()
return cleaned || value.trim()
}
/** 从「关于X的」「以X为主题」「写一篇X总结」等说法里抽取活动主题 */
export const parseTheme = (query) => {
const source = String(query || '')
const patterns = [
/关于[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?的/,
/以[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:为主题|为题|为标题)/,
/(?:写|撰写|生成|创作|拟写|草拟|做)(?:一篇|一份|一段|一个|一则|条)?[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:上的|中的|里的|的|之)?(?:新闻稿|通讯稿|通稿|报道|推文|总结|通知|公告|文案|稿件|宣传稿|发言稿|脚本|简讯)/,
/([^,。;!?\n]{2,30}?)(?:活动)?(?:总结|新闻稿|推文|通知|公告|报道|简讯)/
]
for (let index = 0; index < patterns.length; index += 1) {
const match = source.match(patterns[index])
const value = match && match[1] ? match[1].trim() : ''
if (value) {
return tidyTheme(value)
}
}
return ''
}
/** 兜底主题:清洗掉指令性词汇与字数要求后截断 */
const fallbackTheme = (query) => {
const source = String(query || '')
const cleaned = source
.replace(/[\r\n]+/g, ' ')
.replace(
/(?:请|帮我|麻烦|辛苦|需要)?\s*(?:写|撰写|生成|创作|拟写|草拟|出一?)(?:一篇|一份|一段|一个|条)?/g,
''
)
.replace(/(?:关于|围绕|以)/g, '')
.replace(/\d{2,5}\s*(?:[-~~—-至到]\s*\d{2,5})?\s*(?:字|字符)(?:左右|以内|以下|以上)?/g, '')
.replace(/[,。;!?、,.;!?]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return (cleaned || source.trim()).slice(0, 30)
}
const parseTime = (query) => {
const match = String(query || '').match(/(\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]/)
return match ? match[0].replace(/\s+/g, '') : ''
}
const parseLocation = (query) => {
const match = String(query || '').match(
/(?:在|于)\s*([^,。;!?\n]{2,20}?)\s*(?:举办|举行|开展|召开|进行)/
)
return match && match[1] ? match[1].trim() : ''
}
const parseParticipants = (query) => {
const match = String(query || '').match(/面向\s*([^,。;!?\n]{2,20})/)
return match && match[1] ? match[1].trim() : ''
}
const parseEventType = (query) => {
const source = String(query || '')
for (let index = 0; index < EVENT_TYPE_KEYWORDS.length; index += 1) {
if (source.includes(EVENT_TYPE_KEYWORDS[index])) {
return EVENT_TYPE_KEYWORDS[index]
}
}
return ''
}
/**
* 把用户输入解析成上游工作流的入参。
*
* @param {string} query 用户在写作框里输入的内容(自然语言或「键:值」)
* @param {{ docType?: string, number?: number }} overrides 界面上的显式选择,优先于自动识别
*/
export const parseWritingRequest = (query, overrides) => {
const options = overrides || {}
const fields = parseKeyValueInput(query)
const explicitDocType = options.docType && options.docType !== 'auto' ? options.docType : ''
const keyedDocType = normalizeDocType(pickField(fields, FIELD_ALIASES.docType))
const inferredDocType = inferDocType(query) || DEFAULT_DOC_TYPE
const docType = explicitDocType || keyedDocType || inferredDocType
const explicitNumber =
typeof options.number === 'number' && options.number > 0 ? options.number : undefined
const keyedNumber = parseWordCount(pickField(fields, FIELD_ALIASES.number))
const inferredNumber = parseWordCount(query)
// 不用 ?? / ?: 链:0 是合法字数但被判为「无」,这里显式按 undefined 判定
let number = explicitNumber
if (number === undefined) {
number = keyedNumber
}
if (number === undefined) {
number = inferredNumber
}
if (number === undefined) {
number = DEFAULT_WORD_COUNT_BY_DOC_TYPE[docType]
}
if (number === undefined) {
number = 800
}
const theme = pickField(fields, FIELD_ALIASES.theme) || parseTheme(query) || fallbackTheme(query)
return {
docType,
eventType: pickField(fields, FIELD_ALIASES.eventType) || parseEventType(query) || DEFAULT_EVENT_TYPE,
theme,
time: pickField(fields, FIELD_ALIASES.time) || parseTime(query),
location: pickField(fields, FIELD_ALIASES.location) || parseLocation(query),
participants: pickField(fields, FIELD_ALIASES.participants) || parseParticipants(query),
// 「核心亮点与具体要求」保留用户原话:这里放原话比放抽取结果更贴合上游用途
highlights: pickField(fields, FIELD_ALIASES.highlights) || query,
number
}
}