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
This commit is contained in:
2026-10-13 20:17:58 +08:00
parent f4d0e4d4e5
commit 244f0c24d8
5 changed files with 850 additions and 101 deletions
+52
View File
@@ -434,6 +434,58 @@ export function activityOutlineGen(data) {
})
}
/**
* AI 文件上传(喂给 Dify 开始节点的 file-list 变量)。
*
* @param {object} file `uni.chooseImage` / `uni.chooseMessageFile` 拿到的临时文件(需有 `path`)
* @param {string} [app] 应用代号(后端按代号选择 Dify 应用凭证)
*
* `app` 不是可选项而是**硬约束**:Dify 的 `/files/upload` 按应用鉴权,
* 文件只能被「上传时所用应用」的工作流引用,换个应用引用同一个 `upload_file_id`
* 会直接报 `Invalid upload file`(issue 14 实测:chat Key 上传 → 0.2s 失败,
* creativeAssistant Key 上传 → succeeded)。
*
* 后端 `/ai/file/upload` 不传 `app` 时保持旧行为(`type` 为空 → chat 凭证),
* 所以喂给创意助手 file-list 的附件**必须**传 `app: 'creativeAssistant'`。
*/
export function uploadAiFile(file, app) {
const token = getToken()
const formData = {
tenantId: DEFAULT_TENANT_ID
}
if (app) {
formData.app = app
}
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/ai/file/upload`,
filePath: file.path,
name: 'file',
header: token
? {
Authorization: token
}
: {},
formData,
success: (response) => {
try {
const result = JSON.parse(response.data || '{}')
if (result.code === 0 && result.data) {
resolve(result.data)
return
}
reject(new Error(result.message || '上传失败'))
} catch (error) {
reject(new Error('上传响应解析失败'))
}
},
fail: (error) => {
reject(new Error(error.errMsg || '上传失败'))
}
})
})
}
export function buildWebSocketUrl(userId) {
const url = String(API_BASE_URL || '')
if (/^https?:\/\//.test(url)) {
+436 -96
View File
@@ -22,7 +22,7 @@
</view>
</view>
<view class="navbar-center">
<view class="navbar-title">AI 助手</view>
<view class="navbar-title">{{ assistantTitle }}</view>
<view class="navbar-sub">
{{ activeChatTitle }}<text v-if="userName"> · {{ userName }}</text>
</view>
@@ -49,9 +49,9 @@
</view>
<view v-else-if="!messageList.length" class="empty-card">
<view class="empty-title">你好,我是 AI 团委助手</view>
<view class="empty-title">你好,我是 {{ assistantTitle }}</view>
<view class="empty-desc">
你可以直接提问团务流程问题,我会通过实时消息回复。
{{ assistantHint }}
</view>
</view>
@@ -112,6 +112,54 @@
{{ item.label }}
</view>
</view>
<view v-if="activeChatMode === 'writing'" class="option-panel">
<text class="option-label">文体</text>
<view
v-for="item in writingDocTypeOptions"
:key="item.value"
class="option-chip"
:class="{ 'option-chip--active': writingDocType === item.value, 'option-chip--disabled': loading }"
@tap="pickWritingDocType(item.value)"
>{{ item.label }}</view>
<text class="option-label">字数</text>
<view
v-for="item in writingWordCountOptions"
:key="item.value"
class="option-chip"
:class="{ 'option-chip--active': writingWordCount === item.value, 'option-chip--disabled': loading }"
@tap="pickWritingWordCount(item.value)"
>{{ item.label }}</view>
</view>
<view v-if="activeChatMode === 'image'" class="option-panel">
<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>
<view v-if="activeChatMode === 'image'" class="option-panel">
<text class="option-label">参考素材</text>
<view
class="option-chip"
:class="{ 'option-chip--disabled': !canPickImageFiles }"
@tap="chooseImageFiles"
>{{ uploadingFiles ? '上传中...' : '添加附件' }}</view>
<text class="attach-hint">
最多 {{ IMAGE_FILE_LIMIT }} 个,支持图片 / 视频 / 音频 / 文档
</text>
<view v-if="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>
<view class="attach-remove" @tap="removeImageFile(index)">移除</view>
</view>
</view>
</view>
<view class="composer-row">
<textarea
v-model="draft"
@@ -179,22 +227,25 @@
<script>
import dayjs from '../../uni_modules/uv-ui-tools/libs/util/dayjs.js'
import {
activityOutlineGen,
buildWebSocketUrl,
creativeAssistant,
getCurrentUser,
listAiChatHistory,
listAiChatList,
manuscriptGen,
removeAiChatList,
sendMessage as sendChatMessage
sendMessage as sendChatMessage,
uploadAiFile
} from './chat-service'
import {
WRITING_DOC_TYPE_OPTIONS,
WRITING_WORD_COUNT_OPTIONS,
parseWritingRequest
} from './writing-params'
import {
FALLBACK_TEXT,
FAILED_TEXT,
isDisplayableAnswer,
isFailedPayload,
pickDisplayable
isFailedPayload
} from '../../utils/ai-result'
const ASSISTANT_PENDING_TEXT = '正在思考中...'
@@ -202,16 +253,46 @@
const THINK_CLOSE_TAG = '</think>'
const CHAT_MODE_OPTIONS = [
{ label: '聊天', value: 'chat' },
{ label: '稿件', value: 'manuscript' },
{ label: '创意', value: 'creative' },
{ label: '策划', value: 'outline' }
{ 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' }
]
/** 参考素材上限,与 Dify 开始节点 file-list 变量 user_files 的 max_length 一致 */
const IMAGE_FILE_LIMIT = 5
/** 单个附件大小上限(与既有 pages/creative-assistant 口径一致) */
const MAX_UPLOAD_MB = 100
const IMAGE_FILE_EXTS = ['PNG', 'JPG', 'JPEG', 'GIF', 'WEBP', 'BMP', 'SVG']
const VIDEO_FILE_EXTS = ['MP4', 'MOV', 'MPEG', 'MPGA', 'AVI', 'M4V']
const AUDIO_FILE_EXTS = ['MP3', 'M4A', 'WAV', 'AMR', 'AAC', 'OGG', 'WEBM']
export default {
data() {
return {
ASSISTANT_PENDING_TEXT,
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',
writingWordCount: 'auto',
imageRatio: '1:1',
// 图片模式的参考素材(Dify 开始节点 file-list 变量 user_files)
imageFiles: [],
uploadingFiles: false,
statusBarHeight: 0,
draft: '',
activeChatMode: 'chat',
@@ -248,20 +329,43 @@
return this.statusBarHeight + 44
},
canSend() {
return !!this.currentUser.userId && !!this.draft.trim() && !this.loading
return (
!!this.currentUser.userId &&
!!this.draft.trim() &&
!this.loading &&
!this.uploadingFiles
)
},
canPickImageFiles() {
return !this.loading && !this.uploadingFiles && this.imageFiles.length < IMAGE_FILE_LIMIT
},
assistantTitle() {
if (this.activeChatMode === 'writing') {
return 'AI 写作助手'
}
if (this.activeChatMode === 'image') {
return 'AI 图片生成助手'
}
return 'AI 团委助手'
},
assistantHint() {
if (this.activeChatMode === 'writing') {
return '直接说需求就行,例如「帮我写一篇300字关于迎新晚会的推文,风格活泼」;也可以在下方指定文体和字数。'
}
if (this.activeChatMode === 'image') {
return '描述你要的图片,例如校园活动海报、公众号头图;还可以选择图片比例并添加参考素材。'
}
return '你可以直接提问团务流程问题,我会通过实时消息回复。'
},
inputPlaceholder() {
if (!this.currentUser.userId) {
return '请先登录后使用 AI 助手'
}
if (this.activeChatMode === 'manuscript') {
return '请输入稿件标题、活动时间地点、参与人员和亮点内容,可直接用自然语言描述'
if (this.activeChatMode === 'writing') {
return '直接描述需求即可,例如:帮我写一篇300字关于迎新晚会的推文,风格活泼,面向大一新生(也可在下方直接指定文体和字数)'
}
if (this.activeChatMode === 'creative') {
return '请输入创意生成指令,例如短视频脚本、海报排版、推文封面等需求'
}
if (this.activeChatMode === 'outline') {
return '请输入活动策划需求,建议包含主题、受众、时间、经费、地点等信息'
if (this.activeChatMode === 'image') {
return '请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画,并选择图片比例'
}
return '说说你想问的问题'
},
@@ -815,11 +919,26 @@
return isFailedPayload(payload)
},
/**
* 同 `pickString`,但会跳过不像给人看的 `answer`(如 UUID)。
* 创意模式失败态文案。
*
* 后端按失败原因给固定映射:上游生成服务容量不足时是
* 「生成服务繁忙,请稍后重试」,其余仍是「生成失败,请稍后重试」。
* 用后端的提示,用户才能区分"是我的内容有问题"还是"服务这会儿忙"。
*
* 但这份映射是后端契约,端上不能无条件相信:失败报文中 `answer`
* 历史上被填成过 `workflow_run_id` 这类 UUID(后端 extractAnswer 的
* 伴生现象),所以必须再过一遍 `isDisplayableAnswer`,
* 不像给人看的就换成固定文案(issue 01 的兜底不能丢)。
*/
pickDisplayableText(...values) {
return pickDisplayable(...values)
resolveFailedCreativeText(payload) {
const answer = payload && payload.answer
return this.isDisplayableAnswer(answer) ? String(answer).trim() : FAILED_TEXT
},
/**
* 这个 `answer` 像不像给人看的文本(UUID / 长 token 一律判否)。
*
* 判定逻辑统一在 `utils/ai-result`(issue 01 加的兜底)。
*/
isDisplayableAnswer(value) {
return isDisplayableAnswer(value)
},
@@ -832,42 +951,37 @@
}
return ''
},
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
},
buildManuscriptPayload(query) {
const fields = this.parseKeyValueInput(query)
/**
* 写作模式入参。
*
* 优先级:**界面显式选择 > 「键:值」入参 > 自然语言推断 > 默认值**。
* 解析规则全部在 `./writing-params`(与 web 端 `writing-params.ts` 等价移植)。
*
* 过去这里没有表单,只按「键:值」解析,解析不到就一律回落
* `doc_type=新闻稿` / `Number=800`;而上游提示词把这两项当**主控指令**,
* 会直接压掉用户原话里的诉求(实测见 issue 13 / 15)。
*/
buildWritingPayload(query) {
const overrides = {}
if (this.writingDocType !== 'auto') {
overrides.docType = this.writingDocType
}
if (this.writingWordCount !== 'auto') {
overrides.number = Number(this.writingWordCount)
}
return {
docType: fields['稿件类型'] || fields['类型'] || '新闻稿',
eventType: fields['活动类型'] || '校园活动',
theme: fields['稿件标题'] || fields['标题'] || fields['主题'] || query,
time: fields['活动时间'] || fields['时间'] || '',
location: fields['活动地点'] || fields['地点'] || '',
participants: fields['参与人员'] || fields['人员'] || '',
highlights: fields['亮点内容'] || fields['亮点'] || fields['内容'] || query,
number: Number(fields['建议篇幅'] || fields['字数'] || 800),
...parseWritingRequest(query, overrides),
files: []
}
},
formatManuscriptResult(payload, query) {
formatWritingResult(payload, query) {
if (typeof payload === 'string') {
return payload
}
// 与大纲 / 创意链路对齐:失败态直接给可读文案。
// 与创意链路对齐:失败态直接给可读文案。
// 否则 answer 里的失败提示会被当成正文,渲染成「标题 + 失败文案」。
if (this.isFailedPayload(payload)) {
return '生成失败,请稍后重试'
return FAILED_TEXT
}
const data = payload || {}
const title = this.pickString(
@@ -893,43 +1007,202 @@
data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
)
if (title || body) {
return `${title || this.buildManuscriptPayload(query).theme}\n\n${body}`
return `${title || this.buildWritingPayload(query).theme}\n\n${body}`
}
return this.buildFallbackText()
},
buildOutlinePayload(query) {
const fields = this.parseKeyValueInput(query)
/** 图片模式:把比例与「直接出图」的要求作为文本追加进 user_content */
buildImagePrompt(query) {
return [
query,
`图片比例:${this.imageRatio}`,
'请直接生成图片,优先输出图片文件或图片链接。'
].join('\n')
},
/**
* 图片模式入参:`user_content` 一段文本 + `user_files`(Dify file-list 变量)。
*
* 附件上限 5、类型取 image/video/audio/document —— 都来自上游开始节点的变量定义。
*/
buildImagePayload(query) {
return {
event_theme: fields['活动主题'] || fields['主题'] || query,
people: fields['面向受众'] || fields['受众'] || fields['参与人员'] || '团员青年',
event_time: fields['活动时间'] || fields['时间'] || '待定',
// 资金上游是 number 类型,不能给「待定」这类不可解析的文本
// (会让整个工作流 failed)。这里给空串表示"未提及",
// 后端也会把任何抽不出数值的原文降级为空串(见 issue 09)。
event_funding: fields['活动资金'] || fields['资金'] || fields['经费'] || '',
event_type: fields['活动形式'] || fields['形式'] || '',
event_location: fields['活动地点'] || fields['地点'] || '',
event_meme: fields['备注'] || fields['补充'] || query
user_content: this.buildImagePrompt(query),
user_files: this.imageFiles.map((item) => ({
transfer_method: 'local_file',
type: item.type,
upload_file_id: item.id,
url: ''
}))
}
},
formatOutlineResult(payload) {
// 与创意链路同理:失败态直接给可读文案,不落到整包兜底,
// 也不信任 payload.answer(失败时它可能是 UUID)。
if (this.isFailedPayload(payload)) {
return '生成失败,请稍后重试'
/**
* 按 MIME / 扩展名判定 Dify file-list 要求的 type(image/video/audio/document)。
*
* `pickedType` 是 `uni.chooseMessageFile` 给的微信侧分类(image/video/file),
* 只在拿不到 MIME 与扩展名时才作为兜底依据。
*/
resolveUploadFileType(contentType, fileName, pickedType) {
const mime = String(contentType || '').toLowerCase()
if (mime.indexOf('image/') === 0) {
return 'image'
}
const data = payload || {}
return (
this.pickDisplayableText(
data.answer,
data.raw && data.raw.answer,
data.raw && data.raw.data && data.raw.data.answer,
data.raw && data.raw.output,
data.raw && data.raw.result,
data.raw && data.raw.content,
data.raw && data.raw.message
) || this.buildFallbackText()
)
if (mime.indexOf('video/') === 0) {
return 'video'
}
if (mime.indexOf('audio/') === 0) {
return 'audio'
}
const ext = String(fileName || '').split('.').pop().toUpperCase()
if (IMAGE_FILE_EXTS.indexOf(ext) > -1) {
return 'image'
}
if (VIDEO_FILE_EXTS.indexOf(ext) > -1) {
return 'video'
}
if (AUDIO_FILE_EXTS.indexOf(ext) > -1) {
return 'audio'
}
if (pickedType === 'image' || pickedType === 'video') {
return pickedType
}
return 'document'
},
guessFileName(path, index) {
const segment = String(path || '').split(/[\\/]/).pop() || ''
return segment || `参考素材${index + 1}`
},
pickWritingDocType(value) {
if (this.loading) {
return
}
this.writingDocType = value
},
pickWritingWordCount(value) {
if (this.loading) {
return
}
this.writingWordCount = value
},
pickImageRatio(value) {
if (this.loading) {
return
}
this.imageRatio = value
},
/**
* 选参考素材。
*
* 小程序没有 web 那种「一个文件选择器覆盖所有类型」的能力,所以给两个来源:
* 相册 / 拍照(图片,最常用)与聊天记录里的文件(可覆盖视频 / 音频 / 文档)。
*/
chooseImageFiles() {
if (!this.canPickImageFiles) {
if (!this.loading && !this.uploadingFiles) {
this.showToast(`参考素材最多 ${IMAGE_FILE_LIMIT} 个`)
}
return
}
uni.showActionSheet({
itemList: ['从相册选择图片', '从聊天记录选择文件'],
success: (result) => {
if (result.tapIndex === 0) {
this.pickLocalImages()
return
}
this.pickMessageFiles()
}
})
},
pickLocalImages() {
const remain = IMAGE_FILE_LIMIT - this.imageFiles.length
uni.chooseImage({
count: remain > 0 ? remain : 1,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: (result) => {
const paths = result.tempFilePaths || []
const tempFiles = result.tempFiles || []
const picked = paths.map((path, index) => {
const info = tempFiles[index] || {}
return {
path: info.path || path,
name: this.guessFileName(info.path || path, index),
size: info.size,
type: 'image'
}
})
this.uploadPickedFiles(picked)
}
})
},
pickMessageFiles() {
const remain = IMAGE_FILE_LIMIT - this.imageFiles.length
uni.chooseMessageFile({
count: remain > 0 ? remain : 1,
type: 'all',
success: (result) => {
this.uploadPickedFiles(
(result.tempFiles || []).map((item) => ({
path: item.path,
name: item.name,
size: item.size,
type: item.type
}))
)
}
})
},
async uploadPickedFiles(files) {
const picked = (files || []).filter((item) => item && item.path)
if (!picked.length) {
return
}
const remain = IMAGE_FILE_LIMIT - this.imageFiles.length
if (remain <= 0) {
this.showToast(`参考素材最多 ${IMAGE_FILE_LIMIT} 个`)
return
}
const selected = picked.slice(0, remain)
if (picked.length > remain) {
this.showToast(`参考素材最多 ${IMAGE_FILE_LIMIT} 个,本次只上传前 ${remain} 个`)
}
this.uploadingFiles = true
try {
for (let index = 0; index < selected.length; index += 1) {
const file = selected[index]
if (file.size && file.size / 1024 / 1024 > MAX_UPLOAD_MB) {
this.showToast(`单个附件不能超过 ${MAX_UPLOAD_MB}MB`)
continue
}
// app 必须传 creativeAssistant:Dify 的 /files/upload 按应用鉴权,
// 用别的应用凭证上传的 file id,工作流会报 Invalid upload file。
const record = await uploadAiFile(file, 'creativeAssistant')
const name = record.name || file.name || this.guessFileName(file.path, index)
this.imageFiles = [
...this.imageFiles,
{
id: String(record.id),
name,
type: this.resolveUploadFileType(
record.contentType,
name,
file.type
)
}
]
}
this.showToast('参考素材已上传')
} catch (error) {
this.showToast((error && error.message) || '参考素材上传失败')
} finally {
this.uploadingFiles = false
}
},
removeImageFile(index) {
if (this.loading) {
return
}
this.imageFiles = this.imageFiles.filter((item, current) => current !== index)
},
getCreativeOutputs(payload) {
if (payload && payload.outputs && typeof payload.outputs === 'object') {
@@ -999,10 +1272,8 @@
formatCreativeResult(payload) {
// 失败态优先:此时 outputs 为空、拿不到任何业务字段,
// 必须直接给可读文案,不能落到下面的整包兜底(issue 01)。
// 注意不能信任 payload.answer:失败报文中它可能是 workflow_run_id
// 这类 UUID(后端 extractAnswer 的伴生现象),必须换成固定文案。
if (this.isFailedPayload(payload)) {
return '生成失败,请稍后重试'
return this.resolveFailedCreativeText(payload)
}
const outputs = this.getCreativeOutputs(payload)
const script = this.pickString(outputs.Script_result, outputs.script, outputs.text)
@@ -1042,23 +1313,14 @@
target.loading = false
}
}
if (this.activeChatMode === 'manuscript') {
const result = await manuscriptGen(this.buildManuscriptPayload(query))
setResult(this.formatManuscriptResult(result, query))
if (this.activeChatMode === 'writing') {
const result = await manuscriptGen(this.buildWritingPayload(query))
setResult(this.formatWritingResult(result, query))
return
}
if (this.activeChatMode === 'creative') {
const result = await creativeAssistant({
user_content: query,
user_contentString: query,
user_files: []
})
if (this.activeChatMode === 'image') {
const result = await creativeAssistant(this.buildImagePayload(query))
setResult(this.formatCreativeResult(result))
return
}
if (this.activeChatMode === 'outline') {
const result = await activityOutlineGen(this.buildOutlinePayload(query))
setResult(this.formatOutlineResult(result))
}
},
openHistoryPanel() {
@@ -1545,6 +1807,84 @@
opacity: 0.6;
}
/* 写作 / 图片模式的参数面板(文体、字数、图片比例、参考素材) */
.option-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.option-label {
margin-right: 2rpx;
font-size: 22rpx;
color: #8a7f79;
}
.option-chip {
padding: 8rpx 20rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(20, 150, 242, 0.18);
background: rgba(255, 255, 255, 0.9);
color: #1496f2;
font-size: 22rpx;
line-height: 1.4;
}
.option-chip--active {
border-color: transparent;
background: linear-gradient(145deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
}
.option-chip--disabled {
opacity: 0.5;
}
.attach-hint {
width: 100%;
font-size: 20rpx;
line-height: 1.5;
color: #a59a94;
}
.attach-list {
width: 100%;
}
.attach-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 10rpx 16rpx;
border-radius: 16rpx;
background: rgba(20, 150, 242, 0.06);
}
.attach-item + .attach-item {
margin-top: 10rpx;
}
.attach-name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 22rpx;
color: #4a5560;
text-overflow: ellipsis;
white-space: nowrap;
}
.attach-remove {
flex-shrink: 0;
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: rgba(20, 150, 242, 0.1);
color: #1496f2;
font-size: 20rpx;
}
.composer-row {
display: flex;
align-items: flex-end;
+341
View File
@@ -0,0 +1,341 @@
/**
* 「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
}
}
+3 -1
View File
@@ -413,7 +413,9 @@
this.showToast('单个附件不能超过 100MB')
continue
}
const uploaded = await uploadAiFile(file)
// app 必须传 creativeAssistant:Dify 的 /files/upload 按应用鉴权,
// 用 chat 凭证上传的 file id,工作流会报 Invalid upload file。
const uploaded = await uploadAiFile(file, 'creativeAssistant')
this.uploadedFiles = [
...this.uploadedFiles,
{
+18 -4
View File
@@ -10,8 +10,24 @@ export function creativeAssistant(data) {
})
}
export function uploadAiFile(file) {
/**
* AI 文件上传(喂给 Dify 开始节点的 file-list 变量)。
*
* @param {object} file `uni.chooseMessageFile` 拿到的临时文件(需有 `path`)
* @param {string} [app] 应用代号(后端按代号选择 Dify 应用凭证)
*
* `app` 不是可选项而是**硬约束**:Dify 的 `/files/upload` 按应用鉴权,
* 文件只能被「上传时所用应用」的工作流引用。不传 `app` 时后端走旧逻辑
* (`type` 为空 → chat 凭证),本页附件必然报 `Invalid upload file`(issue 14 实测)。
*/
export function uploadAiFile(file, app) {
const token = getToken()
const formData = {
tenantId: '10049'
}
if (app) {
formData.app = app
}
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/ai/file/upload`,
@@ -22,9 +38,7 @@ export function uploadAiFile(file) {
Authorization: token
}
: {},
formData: {
tenantId: '10049'
},
formData,
success: (response) => {
try {
const result = JSON.parse(response.data || '{}')