244f0c24d8
模式标签由「聊天/稿件/创意/策划」改为「聊天/写作/图片」,去掉策划链路; 写作与图片两个入口补上 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
342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
/**
|
||
* 「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
|
||
}
|
||
}
|