feat(ai): 写作模式拆成自由创作与模板两条路(issue 18)
自由创作不再解析自然语言:原话整段作为 userContent 下发,docType 留空,由后端转 「AI 创意助手」。选了文体才提交 docType 等结构化条件,走「内容创作与宣传助手」。 - writing-params.js 删掉 parseWritingRequest 与关键字表,只留选项常量;`auto` 改语义为「自由创作」。 - 字数选择器只在选中文体后显示;未选文体时不下发 number。 - 撤掉图片比例选择器:上游创意助手开始节点没有比例变量,只能拼进 user_content, 那等于替上游写 prompt。要恢复需先在上游加变量。 - 稿件页同步:默认「自由创作」,选了模板才展开标题、活动类型、时间地点等可选项。
This commit is contained in:
@@ -134,8 +134,9 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 字数只在选了文体(即走了「内容创作与宣传助手」)之后才有意义 -->
|
||||
<scroll-view
|
||||
v-if="activeChatMode === 'writing'"
|
||||
v-if="activeChatMode === 'writing' && writingTemplateMode"
|
||||
scroll-x
|
||||
:show-scrollbar="false"
|
||||
class="option-scroll"
|
||||
@@ -170,24 +171,6 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view
|
||||
v-if="activeChatMode === 'image'"
|
||||
scroll-x
|
||||
:show-scrollbar="false"
|
||||
class="option-scroll"
|
||||
>
|
||||
<view class="option-track">
|
||||
<text class="option-label">比例</text>
|
||||
<view
|
||||
v-for="item in imageRatioOptions"
|
||||
:key="item.value"
|
||||
class="option-chip"
|
||||
:class="{ 'option-chip--active': imageRatio === item.value, 'option-chip--disabled': loading }"
|
||||
@tap="pickImageRatio(item.value)"
|
||||
>{{ item.label }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="activeChatMode === 'image' && imageFiles.length" class="attach-list">
|
||||
<view v-for="(item, index) in imageFiles" :key="item.id" class="attach-item">
|
||||
<text class="attach-name">{{ item.name }}</text>
|
||||
@@ -272,9 +255,9 @@
|
||||
uploadAiFile
|
||||
} from './chat-service'
|
||||
import {
|
||||
WRITING_DOC_TYPE_FREE,
|
||||
WRITING_DOC_TYPE_OPTIONS,
|
||||
WRITING_WORD_COUNT_OPTIONS,
|
||||
parseWritingRequest
|
||||
WRITING_WORD_COUNT_OPTIONS
|
||||
} from './writing-params'
|
||||
import {
|
||||
FALLBACK_TEXT,
|
||||
@@ -291,19 +274,9 @@
|
||||
{ label: '写作', value: 'writing' },
|
||||
{ label: '图片', value: 'image' }
|
||||
]
|
||||
/**
|
||||
* 图片比例选项。
|
||||
*
|
||||
* 上游「AI 创意助手」开始节点**没有**比例变量,只能把它作为一行文本追加进
|
||||
* `user_content`(与 web 端 `buildImagePrompt` 同一做法)。
|
||||
*/
|
||||
const IMAGE_RATIO_OPTIONS = [
|
||||
{ label: '1:1', value: '1:1' },
|
||||
{ label: '4:3', value: '4:3' },
|
||||
{ label: '3:4', value: '3:4' },
|
||||
{ label: '16:9', value: '16:9' },
|
||||
{ label: '9:16', value: '9:16' }
|
||||
]
|
||||
// 图片比例选择器已撤掉:上游创意助手开始节点没有比例变量(实测只有 user_content /
|
||||
// user_files),前端只能把它拼进 user_content —— 那是替上游写 prompt。
|
||||
// 要恢复这个功能,先在上游开始节点加变量,再按变量契约接上来。
|
||||
/** 参考素材上限,与 Dify 开始节点 file-list 变量 user_files 的 max_length 一致 */
|
||||
const IMAGE_FILE_LIMIT = 5
|
||||
/** 单个附件大小上限(与既有 pages/creative-assistant 口径一致) */
|
||||
@@ -319,12 +292,11 @@
|
||||
chatModeOptions: CHAT_MODE_OPTIONS,
|
||||
writingDocTypeOptions: WRITING_DOC_TYPE_OPTIONS,
|
||||
writingWordCountOptions: WRITING_WORD_COUNT_OPTIONS,
|
||||
imageRatioOptions: IMAGE_RATIO_OPTIONS,
|
||||
IMAGE_FILE_LIMIT,
|
||||
// 写作模式的显式选择:默认 auto,即由 writing-params 从用户输入里自动识别
|
||||
writingDocType: 'auto',
|
||||
// 写作模式:默认「自由创作」——不套模板,原话直接交给 AI 创意助手
|
||||
writingDocType: WRITING_DOC_TYPE_FREE,
|
||||
// 字数只在选了文体后才有意义,默认不下发
|
||||
writingWordCount: 'auto',
|
||||
imageRatio: '1:1',
|
||||
// 图片模式的参考素材(Dify 开始节点 file-list 变量 user_files)
|
||||
imageFiles: [],
|
||||
uploadingFiles: false,
|
||||
@@ -374,6 +346,10 @@
|
||||
canPickImageFiles() {
|
||||
return !this.loading && !this.uploadingFiles && this.imageFiles.length < IMAGE_FILE_LIMIT
|
||||
},
|
||||
/** 选中文体 = 套模板走「内容创作与宣传助手」;否则是自由创作走「AI 创意助手」 */
|
||||
writingTemplateMode() {
|
||||
return this.writingDocType !== WRITING_DOC_TYPE_FREE
|
||||
},
|
||||
assistantTitle() {
|
||||
if (this.activeChatMode === 'writing') {
|
||||
return 'AI 写作助手'
|
||||
@@ -385,10 +361,10 @@
|
||||
},
|
||||
assistantHint() {
|
||||
if (this.activeChatMode === 'writing') {
|
||||
return '直接说需求就行,例如「帮我写一篇300字关于迎新晚会的推文,风格活泼」;也可以在下方指定文体和字数。'
|
||||
return '默认「自由创作」:说清想写什么就行,由 AI 创意助手直接生成;选了文体则按该体裁生成,并可在下方指定字数。'
|
||||
}
|
||||
if (this.activeChatMode === 'image') {
|
||||
return '描述你要的图片,例如校园活动海报、公众号头图;还可以选择图片比例并添加参考素材。'
|
||||
return '描述你要的图片,例如校园活动海报、公众号头图;还可以添加参考素材。'
|
||||
}
|
||||
return '你可以直接提问团务流程问题,我会通过实时消息回复。'
|
||||
},
|
||||
@@ -397,10 +373,10 @@
|
||||
return '请先登录后使用 AI 助手'
|
||||
}
|
||||
if (this.activeChatMode === 'writing') {
|
||||
return '直接描述需求即可,例如:帮我写一篇300字关于迎新晚会的推文,风格活泼,面向大一新生(也可在下方直接指定文体和字数)'
|
||||
return '描述你想创作的内容,例如:帮我写一段迎新晚会的主持人开场词,口语化一点(选了文体后才需要再定字数)'
|
||||
}
|
||||
if (this.activeChatMode === 'image') {
|
||||
return '请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画,并选择图片比例'
|
||||
return '请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画'
|
||||
}
|
||||
return '说说你想问的问题'
|
||||
},
|
||||
@@ -987,29 +963,33 @@
|
||||
return ''
|
||||
},
|
||||
/**
|
||||
* 写作模式入参。
|
||||
* 写作模式入参(2026-10-21 改造后)。
|
||||
*
|
||||
* 优先级:**界面显式选择 > 「键:值」入参 > 自然语言推断 > 默认值**。
|
||||
* 解析规则全部在 `./writing-params`(与 web 端 `writing-params.ts` 等价移植)。
|
||||
* 两条路,都不解析自然语言:
|
||||
* - 自由创作(未选文体):原话整段作为 `userContent`,后端见 `docType` 为空
|
||||
* 就转「AI 创意助手」(只吃 `user_content`);
|
||||
* - 选了文体:原话整段作为 `highlights`(核心亮点与具体要求),只下发界面
|
||||
* 上显式选过的 docType / number,其余可选字段留空交给上游。
|
||||
*
|
||||
* 过去这里没有表单,只按「键:值」解析,解析不到就一律回落
|
||||
* `doc_type=新闻稿` / `Number=800`;而上游提示词把这两项当**主控指令**,
|
||||
* 会直接压掉用户原话里的诉求(实测见 issue 13 / 15)。
|
||||
* 过去这里用 `parseWritingRequest` 从原话猜 doc_type / 字数 / 主题,
|
||||
* 猜错会把垃圾值当主控指令(issue 13);现已废弃该解析,见 `./writing-params`。
|
||||
*/
|
||||
buildWritingPayload(query) {
|
||||
const overrides = {}
|
||||
if (this.writingDocType !== 'auto') {
|
||||
overrides.docType = this.writingDocType
|
||||
const content = String(query || '').trim()
|
||||
if (this.writingDocType === WRITING_DOC_TYPE_FREE) {
|
||||
return { docType: '', userContent: content }
|
||||
}
|
||||
if (this.writingWordCount !== 'auto') {
|
||||
overrides.number = Number(this.writingWordCount)
|
||||
}
|
||||
return {
|
||||
...parseWritingRequest(query, overrides),
|
||||
const payload = {
|
||||
docType: this.writingDocType,
|
||||
highlights: content,
|
||||
files: []
|
||||
}
|
||||
if (this.writingWordCount !== 'auto') {
|
||||
payload.number = Number(this.writingWordCount)
|
||||
}
|
||||
return payload
|
||||
},
|
||||
formatWritingResult(payload, query) {
|
||||
formatWritingResult(payload) {
|
||||
if (typeof payload === 'string') {
|
||||
return payload
|
||||
}
|
||||
@@ -1042,26 +1022,23 @@
|
||||
data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
|
||||
)
|
||||
if (title || body) {
|
||||
return `${title || this.buildWritingPayload(query).theme}\n\n${body}`
|
||||
// 不再从入参里补标题:改造后主题只来自界面显式填写,
|
||||
// 自由创作与问答式写作都没有主题,拼空标题只会在正文前多两行空行。
|
||||
return title ? `${title}\n\n${body}` : body
|
||||
}
|
||||
return this.buildFallbackText()
|
||||
},
|
||||
/** 图片模式:把比例与「直接出图」的要求作为文本追加进 user_content */
|
||||
buildImagePrompt(query) {
|
||||
return [
|
||||
query,
|
||||
`图片比例:${this.imageRatio}`,
|
||||
'请直接生成图片,优先输出图片文件或图片链接。'
|
||||
].join('\n')
|
||||
},
|
||||
/**
|
||||
* 图片模式入参:`user_content` 一段文本 + `user_files`(Dify file-list 变量)。
|
||||
* 图片模式入参:`user_content` 就是**用户原话**,外加 `user_files`(Dify file-list 变量)。
|
||||
*
|
||||
* 附件上限 5、类型取 image/video/audio/document —— 都来自上游开始节点的变量定义。
|
||||
*
|
||||
* 不往 `user_content` 里追加任何我们自己写的话(如「图片比例:1:1」
|
||||
* 「请直接生成图片…」):上游没有比例变量,拼进去等于替上游写 prompt。
|
||||
*/
|
||||
buildImagePayload(query) {
|
||||
return {
|
||||
user_content: this.buildImagePrompt(query),
|
||||
user_content: query,
|
||||
user_files: this.imageFiles.map((item) => ({
|
||||
transfer_method: 'local_file',
|
||||
type: item.type,
|
||||
@@ -1118,12 +1095,6 @@
|
||||
}
|
||||
this.writingWordCount = value
|
||||
},
|
||||
pickImageRatio(value) {
|
||||
if (this.loading) {
|
||||
return
|
||||
}
|
||||
this.imageRatio = value
|
||||
},
|
||||
/**
|
||||
* 选参考素材。
|
||||
*
|
||||
@@ -1351,7 +1322,7 @@
|
||||
}
|
||||
if (this.activeChatMode === 'writing') {
|
||||
const result = await manuscriptGen(this.buildWritingPayload(query))
|
||||
setResult(this.formatWritingResult(result, query))
|
||||
setResult(this.formatWritingResult(result))
|
||||
return
|
||||
}
|
||||
if (this.activeChatMode === 'image') {
|
||||
|
||||
@@ -1,341 +1,40 @@
|
||||
/**
|
||||
* 「AI 写作助手」入参解析:把用户自然语言诉求抽取成上游 Dify 真正读取的字段。
|
||||
* 「AI 写作助手」的可选项定义(2026-10-21 改造后)。
|
||||
*
|
||||
* 本文件是 `tuanwei-admin/src/views/oa/chatgpt/writing-params.ts` 的**逐行等价移植**
|
||||
* (去掉 TS 类型标注,风格对齐小程序仓库:Tab 缩进、单引号、无分号)。
|
||||
* 两端共用同一套解析规则,避免行为漂移;改动其一请同步另一侧。
|
||||
* 改造前这里还负责把用户原话解析成「键:值」入参,再据此填 doc_type / Number。
|
||||
* 现在写作模式只有两条路,都不解析自然语言:
|
||||
*
|
||||
* 背景(实测记录见 .scratch/ai-assistant/probe/):
|
||||
* 上游应用「AI 内容创作与宣传助手」是按**结构化表单**设计的,它的 user prompt 模板是
|
||||
* 1. 自由创作(value = `auto`):原话整段作为 `user_content`,交给「AI 创意助手」;
|
||||
* 2. 选了文体:原话整段作为 `highlights`(核心亮点与具体要求),连同 doc_type
|
||||
* 交给「内容创作与宣传助手」,字数只认界面上的显式选择。
|
||||
*
|
||||
* 本次需要生成的文稿类型为:{doc_type}
|
||||
* 篇幅要求(字数限制):{Number}
|
||||
* 以下是本次活动的核心要素,请严格据此生成稿件:
|
||||
* - 活动主题:{theme}
|
||||
* - 活动类型:{event_type}
|
||||
* - 举办时间:{time} / 举办地点:{location} / 参与人员:{participants}
|
||||
* - 核心亮点与具体要求:{highlights}
|
||||
* 为什么不猜:上游提示词里 doc_type / Number 是**主控指令**,模型会显式服从、
|
||||
* 甚至压掉用户原话里的冲突要求(实测思维链:「I will ignore the conflicting
|
||||
* "300字推文" instruction … because the main task says "文稿类型为:新闻稿"」)。
|
||||
* 从原话里推断还会在短祈使句上产出「帮我写个」这类残片当活动主题
|
||||
* (见 issue 13 的「2026-10-19 契约修正」)。
|
||||
*
|
||||
* 其中 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. 允许用户在界面上显式指定文体 / 字数,显式选择优先于自动识别。
|
||||
*
|
||||
* 纯函数、零依赖。
|
||||
* 本模块只剩选项常量:纯数据、零依赖。
|
||||
*/
|
||||
|
||||
/** 「自由创作」的取值,同时也是 `writingDocType` 的默认值 */
|
||||
export const WRITING_DOC_TYPE_FREE = 'auto'
|
||||
|
||||
/** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */
|
||||
export const WRITING_DOC_TYPE_OPTIONS = [
|
||||
{ label: '自动识别', value: 'auto' },
|
||||
{ label: '自由创作', value: WRITING_DOC_TYPE_FREE },
|
||||
{ label: '新闻稿', value: '新闻稿' },
|
||||
{ label: '活动总结', value: '活动总结' },
|
||||
{ label: '微信推文', value: '微信推文' },
|
||||
{ label: '通知公告', value: '通知公告' }
|
||||
]
|
||||
|
||||
/** 字数只在选了文体之后才显示;`auto` 表示不下发 */
|
||||
export const WRITING_WORD_COUNT_OPTIONS = [
|
||||
{ label: '自动识别', value: 'auto' },
|
||||
{ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
<view class="hero-kicker">AI CONTENT STUDIO</view>
|
||||
<view class="hero-title">AI 内容创作与宣传助手</view>
|
||||
<view class="hero-desc">
|
||||
面向团委宣传、活动报道、新闻稿和推文草稿的快速生成场景。填写创作条件后,页面会调用
|
||||
`/ai/manuscriptGen` 并通过 WebSocket 实时接收生成内容。
|
||||
两种用法:不选模板(自由创作)时只需描述想写什么,由「AI 创意助手」生成;选了稿件类型后再展开活动信息等可选项,
|
||||
由「内容创作与宣传助手」按体裁生成。结果通过 WebSocket 实时回传。
|
||||
</view>
|
||||
<view class="hero-actions">
|
||||
<view class="hero-action" @tap="fillDemo('news')">活动新闻稿</view>
|
||||
<view class="hero-action" @tap="fillDemo('wechat')">公众号推文</view>
|
||||
<view class="hero-action" @tap="fillDemo('speech')">发言稿提纲</view>
|
||||
<view class="hero-action" @tap="fillDemo('free')">自由创作示例</view>
|
||||
<view class="hero-action hero-action--primary" @tap="goChatPage">进入问答助手</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -19,17 +20,6 @@
|
||||
<view class="panel-card">
|
||||
<view class="panel-title">创作配置</view>
|
||||
<view class="field-list">
|
||||
<view class="field-item">
|
||||
<view class="field-label">稿件标题</view>
|
||||
<uv-input
|
||||
v-model="form.theme"
|
||||
class="field-input"
|
||||
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
|
||||
placeholder-style="color: #c0c4cc;"
|
||||
:maxlength="-1"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field-item">
|
||||
<view class="field-label">稿件类型</view>
|
||||
<picker mode="selector" :range="docTypeLabels" :value="docTypeIndex" @change="handleDocTypeChange">
|
||||
@@ -37,9 +27,22 @@
|
||||
<text class="field-value">{{ form.docType || '请选择稿件类型' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="field-tip">选「自由创作」= 不套稿件模板,只描述你想写什么;选具体模板后才会展开下方其它可选项。</view>
|
||||
</view>
|
||||
|
||||
<view class="field-item">
|
||||
<template v-if="templateMode">
|
||||
<view class="field-item">
|
||||
<view class="field-label">稿件标题</view>
|
||||
<uv-input
|
||||
v-model="form.theme"
|
||||
class="field-input"
|
||||
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
|
||||
placeholder-style="color: #c0c4cc;"
|
||||
:maxlength="-1"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field-item">
|
||||
<view class="field-label">活动类型</view>
|
||||
<picker mode="selector" :range="eventTypeLabels" :value="eventTypeIndex" @change="handleEventTypeChange">
|
||||
<view class="field-picker">
|
||||
@@ -127,6 +130,23 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<view class="field-item">
|
||||
<view class="field-label">想创作什么</view>
|
||||
<textarea
|
||||
v-model="form.freeContent"
|
||||
class="field-textarea field-textarea--free"
|
||||
placeholder="直接描述你的创作需求,例如:帮我写一段迎新晚会的主持人开场词,口语化一点"
|
||||
placeholder-style="color: #c0c4cc;"
|
||||
maxlength="-1"
|
||||
/>
|
||||
</view>
|
||||
<view class="free-tip">
|
||||
自由模式不套用稿件模板,内容将交给「AI 创意助手」生成。想按新闻稿、推文等固定体裁写,请在上方选择对应模板。
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view class="submit-bar">
|
||||
@@ -139,7 +159,8 @@
|
||||
<view class="hint-block">
|
||||
<view class="hint-title">提交说明</view>
|
||||
<view class="hint-text">
|
||||
页面会先提交 `POST /ai/manuscriptGen`,再通过 WebSocket 流式接收生成结果;如果后端直接返回正文,也会自动兼容显示。
|
||||
自由模式只提交一段内容(`docType` 留空),由后端转「AI 创意助手」;选了模板才提交 `docType`、活动类型、时间地点等结构化条件。
|
||||
两种都会先 `POST /ai/manuscriptGen`,再通过 WebSocket 流式接收生成结果;如果后端直接返回正文,也会自动兼容显示。
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -207,11 +228,14 @@
|
||||
const THINK_OPEN_TAG = '<think>'
|
||||
const THINK_CLOSE_TAG = '</think>'
|
||||
|
||||
const DOC_TYPE_OPTIONS = ['新闻稿', '公众号推文', '宣传稿', '活动总结', '发言稿', '短视频脚本']
|
||||
const DOC_TYPE_FREE = '自由创作'
|
||||
// 「自由创作」是默认项:不选具体模板时只填一段内容,后端转「AI 创意助手」。
|
||||
const DOC_TYPE_OPTIONS = [DOC_TYPE_FREE, '新闻稿', '公众号推文', '宣传稿', '活动总结', '发言稿', '短视频脚本']
|
||||
const EVENT_TYPE_OPTIONS = ['校园活动', '志愿服务', '会议论坛', '社会实践', '文体活动', '品牌项目']
|
||||
|
||||
const createDefaultForm = () => ({
|
||||
docType: '新闻稿',
|
||||
docType: DOC_TYPE_FREE,
|
||||
freeContent: '',
|
||||
eventType: '校园活动',
|
||||
theme: '',
|
||||
time: '',
|
||||
@@ -254,7 +278,16 @@
|
||||
const index = this.eventTypeLabels.indexOf(this.form.eventType)
|
||||
return index > -1 ? index : 0
|
||||
},
|
||||
freeMode() {
|
||||
return !this.form.docType || this.form.docType === DOC_TYPE_FREE
|
||||
},
|
||||
templateMode() {
|
||||
return !this.freeMode
|
||||
},
|
||||
canSubmit() {
|
||||
if (this.freeMode) {
|
||||
return !!String(this.form.freeContent || '').trim()
|
||||
}
|
||||
return !!(
|
||||
String(this.form.theme || '').trim() ||
|
||||
String(this.form.highlights || '').trim() ||
|
||||
@@ -329,6 +362,13 @@
|
||||
}
|
||||
},
|
||||
buildPayload() {
|
||||
if (this.freeMode) {
|
||||
// 自由模式:不发 docType,只发一段用户原话。后端据此转「AI 创意助手」。
|
||||
return {
|
||||
docType: '',
|
||||
userContent: String(this.form.freeContent || '').trim()
|
||||
}
|
||||
}
|
||||
return {
|
||||
docType: this.form.docType,
|
||||
eventType: this.form.eventType,
|
||||
@@ -640,6 +680,11 @@
|
||||
this.uploadedFiles = []
|
||||
},
|
||||
fillDemo(type) {
|
||||
if (type === 'free') {
|
||||
this.form.docType = DOC_TYPE_FREE
|
||||
this.form.freeContent = '帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右'
|
||||
return
|
||||
}
|
||||
if (type === 'news') {
|
||||
this.form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动'
|
||||
this.form.docType = '新闻稿'
|
||||
@@ -731,7 +776,9 @@
|
||||
return
|
||||
}
|
||||
if (!this.canSubmit) {
|
||||
this.showToast('请至少填写稿件标题、亮点内容或上传附件中的一项')
|
||||
this.showToast(this.freeMode
|
||||
? '请先描述你想创作什么'
|
||||
: '请至少填写稿件标题、亮点内容或上传附件中的一项')
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
@@ -740,7 +787,9 @@
|
||||
this.responseSummary = '正在建立 WebSocket 连接...'
|
||||
try {
|
||||
await this.connectWebSocket()
|
||||
this.responseSummary = '连接成功,已提交生成任务,等待流式返回...'
|
||||
this.responseSummary = this.freeMode
|
||||
? '自由创作已提交给 AI 创意助手,生成完成后一次性返回...'
|
||||
: '连接成功,已提交生成任务,等待流式返回...'
|
||||
const waitStream = this.createStreamWaiter()
|
||||
const result = await manuscriptGen(this.buildPayload())
|
||||
if (!this.streamStarted && this.shouldTreatAsDirectResult(result)) {
|
||||
@@ -911,6 +960,25 @@
|
||||
min-height: 220rpx;
|
||||
}
|
||||
|
||||
.field-textarea--free {
|
||||
min-height: 300rpx;
|
||||
}
|
||||
|
||||
.field-tip,
|
||||
.free-tip {
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
.free-tip {
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(20, 150, 242, 0.06);
|
||||
color: #6b7d8f;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user