feat(ai): 创意助手各页下线参考素材上传,图片模式改传 mode(issue 19)

- 聊天页「图片」模式与独立创意助手页都删掉上传入口、上传状态与相关样式;
  上游出图工具只做文生图,开始节点的 file-list 变量没有任何节点消费(issue 16)。
- 图片模式请求体改为 { user_content, mode: 'image' },由后端包装意图声明;
  独立页不传 mode(通用入口,视频/脚本/图片靠用户文字说明)。
This commit is contained in:
2026-10-21 15:43:02 +08:00
parent eaeba6120a
commit 5ce66be025
2 changed files with 30 additions and 455 deletions
+11 -239
View File
@@ -235,30 +235,6 @@
</scroll-view>
</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
class="option-chip"
:class="{ 'option-chip--disabled': !canPickImageFiles }"
@tap="chooseImageFiles"
>{{ uploadingFiles ? '上传中...' : '添加附件' }}</view>
<text class="option-hint">最多 {{ IMAGE_FILE_LIMIT }} 个</text>
</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>
<view class="attach-remove" @tap="removeImageFile(index)">移除</view>
</view>
</view>
<view class="composer-row">
<textarea
@@ -334,7 +310,6 @@
manuscriptGen,
removeAiChatList,
sendMessage as sendChatMessage,
uploadAiFile
} from './chat-service'
import {
WRITING_DOC_TYPE_FREE,
@@ -360,13 +335,7 @@
// 图片比例选择器已撤掉:上游创意助手开始节点没有比例变量(实测只有 user_content /
// user_files),前端只能把它拼进 user_content —— 那是替上游写 prompt。
// 要恢复这个功能,先在上游开始节点加变量,再按变量契约接上来。
/** 参考素材上限,与 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']
// user_files 本身也已不再上传:开始节点挂着这个变量,但没有任何下游节点消费它(issue 16)。
export default {
data() {
@@ -376,7 +345,6 @@
writingDocTypeOptions: WRITING_DOC_TYPE_OPTIONS,
writingWordCountOptions: WRITING_WORD_COUNT_OPTIONS,
writingEventTypeOptions: WRITING_EVENT_TYPE_OPTIONS,
IMAGE_FILE_LIMIT,
// 写作模式:默认「自由创作」——不套模板,原话直接交给 AI 创意助手
writingDocType: WRITING_DOC_TYPE_FREE,
// 字数只在选了文体后才有意义,默认不下发
@@ -391,9 +359,6 @@
location: '',
participants: ''
},
// 图片模式的参考素材(Dify 开始节点 file-list 变量 user_files)
imageFiles: [],
uploadingFiles: false,
statusBarHeight: 0,
draft: '',
activeChatMode: 'chat',
@@ -430,7 +395,7 @@
return this.statusBarHeight + 44
},
canSend() {
if (!this.currentUser.userId || this.loading || this.uploadingFiles) {
if (!this.currentUser.userId || this.loading) {
return false
}
if (this.draft.trim()) {
@@ -443,9 +408,6 @@
this.writingParamCount > 0
)
},
canPickImageFiles() {
return !this.loading && !this.uploadingFiles && this.imageFiles.length < IMAGE_FILE_LIMIT
},
/** 选中文体 = 套模板走「内容创作与宣传助手」;否则是自由创作走「AI 创意助手」 */
writingTemplateMode() {
return this.writingDocType !== WRITING_DOC_TYPE_FREE
@@ -474,7 +436,7 @@
return '默认「自由创作」:说清想写什么就行,由 AI 创意助手直接生成;选了文体则按该体裁生成,并可在下方指定字数。'
}
if (this.activeChatMode === 'image') {
return '描述你要的图片,例如校园活动海报、公众号头图;还可以添加参考素材。'
return '描述你要的图片,例如校园活动海报、公众号头图、志愿服务插画。'
}
return '你可以直接提问团务流程问题,我会通过实时消息回复。'
},
@@ -1166,60 +1128,22 @@
return this.buildFallbackText()
},
/**
* 图片模式入参:`user_content` 就是**用户原话**,外加 `user_files`(Dify file-list 变量)。
* 图片模式入参。
*
* 附件上限 5、类型取 image/video/audio/document —— 都来自上游开始节点的变量定义。
* 只下发 `user_content`(用户原话)与 `mode: 'image'`。
*
* 不往 `user_content` 里追加任何我们自己写的话(如「图片比例:1:1」
* 「请直接生成图片…」):上游没有比例变量,拼进去等于替上游写 prompt。
* 参考素材(`user_files`)已下线:上游出图工具只做文生图,开始节点虽挂着 file-list
* 变量,但没有节点消费它(见 issue 16),传上去只是让用户白等上传。
*
* `mode` 不进 `user_content`:后端在 image 模式下统一包装成「生成图片」的意图声明,
* 让上游「问题分类器」不把画面描述判成文案分支(见 issue 19)。
*/
buildImagePayload(query) {
return {
user_content: query,
user_files: this.imageFiles.map((item) => ({
transfer_method: 'local_file',
type: item.type,
upload_file_id: item.id,
url: ''
}))
mode: 'image'
}
},
/**
* 按 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'
}
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
@@ -1244,122 +1168,6 @@
}
this.writingParams.eventType = value
},
/**
* 选参考素材。
*
* 小程序没有 web 那种「一个文件选择器覆盖所有类型」的能力,所以给两个来源:
* 相册 / 拍照(图片,最常用)与聊天记录里的文件(可覆盖视频 / 音频 / 文档)。
*/
chooseImageFiles() {
if (!this.canPickImageFiles) {
if (!this.loading && !this.uploadingFiles) {
this.showToast(`参考素材最多 ${IMAGE_FILE_LIMIT} 个`)
}
return
}
uni.showActionSheet({
// 支持的类型写在选项里(面板上那行 hint 只留「最多 5 个」,省横向空间)
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') {
return payload.outputs
@@ -2113,42 +1921,6 @@
color: #b8ada7;
}
.attach-list {
margin-bottom: 12rpx;
}
.attach-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 14rpx;
border-radius: 16rpx;
background: rgba(20, 150, 242, 0.06);
}
.attach-item + .attach-item {
margin-top: 8rpx;
}
.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;
+19 -216
View File
@@ -4,7 +4,7 @@
<view class="hero-card">
<view class="hero-title">AI 创意助手</view>
<view class="hero-desc">
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像 / 排版结果。
支持文本指令驱动工作流,自动返回视频、脚本 Markdown 或图像 / 排版结果。
</view>
<view class="hero-actions">
<view class="hero-action" @tap="fillExample('video')">短视频创意</view>
@@ -21,35 +21,12 @@
<textarea
v-model="content"
class="field-textarea field-textarea--large"
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
placeholder="请输入用户的文本指令或需求描述,例如:围绕迎新晚会,生成一条公众号推文封面及 60 秒短视频脚本。"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">上传附件</view>
<view class="upload-bar">
<view class="upload-btn" @tap="chooseFiles">{{ uploading ? '上传中...' : '添加附件' }}</view>
<view class="upload-tip">支持图片、视频、音频和常见文档类型。</view>
</view>
<view v-if="uploadedFiles.length" class="file-list">
<view
v-for="(item, index) in uploadedFiles"
:key="`${item.url || item.path || item.name}-${index}`"
class="file-item-card"
>
<view class="file-main">
<view class="file-badge">{{ getFileTypeLabel(item) }}</view>
<view class="file-text">
<view class="file-name">{{ item.name || getFileName(item) }}</view>
<view class="file-url">{{ resolveFileUrl(item) || '-' }}</view>
</view>
</view>
<view class="file-remove" @tap="removeUploadedFile(index)">移除</view>
</view>
</view>
</view>
</view>
<view class="submit-bar">
@@ -93,7 +70,7 @@
</view>
<view v-else-if="!hasAnyResult" class="result-empty">
填写文本指令并上传附件后执行工作流
填写文本指令后执行工作流
</view>
<view v-else class="result-body">
@@ -170,35 +147,14 @@
</template>
<script>
import { creativeAssistant, uploadAiFile } from './service'
import { creativeAssistant } from './service'
const DOCUMENT_FILE_EXTS = new Set([
'TXT',
'MD',
'MARKDOWN',
'PDF',
'HTML',
'XLSX',
'XLS',
'DOCX',
'CSV',
'EML',
'MSG',
'PPTX',
'PPT',
'XML',
'EPUB'
])
const AUDIO_FILE_EXTS = new Set(['MP3', 'M4A', 'WAV', 'WEBM', 'AMR', 'AAC', 'OGG'])
const VIDEO_FILE_EXTS = new Set(['MP4', 'MOV', 'MPEG', 'MPGA', 'WEBM', 'AVI', 'M4V'])
export default {
data() {
return {
loading: false,
uploading: false,
content: '',
uploadedFiles: [],
workflowResponse: null,
workflowOutputs: null,
loadingSteps: [
@@ -219,7 +175,7 @@
},
computed: {
canSubmit() {
return !!String(this.content || '').trim() || this.uploadedFiles.length > 0
return !!String(this.content || '').trim()
},
videoFiles() {
return (this.workflowOutputs && this.workflowOutputs.video_result) || []
@@ -271,12 +227,6 @@
return String(status || '').trim().toLowerCase() === 'failed'
},
loadingTitle() {
if (this.uploadedFiles.length && String(this.content || '').trim()) {
return '正在分析指令并处理附件'
}
if (this.uploadedFiles.length) {
return '正在解析附件并生成结果'
}
return '正在根据文本指令生成内容'
},
loadingDescription() {
@@ -344,23 +294,6 @@
}
return ['mp3', 'wav', 'aac', 'm4a', 'ogg', 'amr'].includes(this.getFileExt(file))
},
getUploadFileType(file) {
const contentType = String((file && file.contentType) || '').toLowerCase()
const ext = this.getFileExt(file).toUpperCase()
if (contentType.startsWith('image/')) {
return 'image'
}
if (AUDIO_FILE_EXTS.has(ext)) {
return 'audio'
}
if (VIDEO_FILE_EXTS.has(ext)) {
return 'video'
}
if (DOCUMENT_FILE_EXTS.has(ext)) {
return 'document'
}
return 'document'
},
getFileTypeLabel(file) {
if (this.isImageFile(file)) {
return '图片'
@@ -379,84 +312,27 @@
},
resetAll() {
this.content = ''
this.uploadedFiles = []
this.clearResult()
},
removeUploadedFile(index) {
this.uploadedFiles = this.uploadedFiles.filter((item, current) => current !== index)
},
async chooseFiles() {
if (this.uploading) {
return
}
try {
const result = await new Promise((resolve, reject) => {
uni.chooseMessageFile({
count: 9,
type: 'all',
success: resolve,
fail: reject
})
})
const tempFiles = (result && result.tempFiles) || []
if (!tempFiles.length) {
return
}
this.uploading = true
uni.showLoading({
title: '上传中',
mask: true
})
for (let index = 0; index < tempFiles.length; index += 1) {
const file = tempFiles[index]
if (file.size && file.size / 1024 / 1024 > 100) {
this.showToast('单个附件不能超过 100MB')
continue
}
// app 必须传 creativeAssistant:Dify 的 /files/upload 按应用鉴权,
// 用 chat 凭证上传的 file id,工作流会报 Invalid upload file。
const uploaded = await uploadAiFile(file, 'creativeAssistant')
this.uploadedFiles = [
...this.uploadedFiles,
{
id: uploaded.id,
name: uploaded.name || file.name,
path: uploaded.path,
url: uploaded.url,
downloadUrl: uploaded.downloadUrl,
contentType: uploaded.contentType
}
]
}
this.showToast('附件上传成功')
} catch (error) {
if (error && error.errMsg && String(error.errMsg).includes('cancel')) {
return
}
this.showToast((error && error.message) || '上传失败')
} finally {
this.uploading = false
uni.hideLoading()
}
},
/**
* 请求体。
*
* 附件已下线:上游出图工具只做文生图,开始节点的 file-list 变量没有节点消费
* (见 issue 16),继续传只是让用户白等上传。
*
* 不传 mode:这是通用创意助手入口,视频/脚本/图片需求都由用户文字说明;
* 只有「用户显式选了图片模式」的聊天页才传 mode=image(见 issue 19)。
*/
buildRequestPayload() {
const payload = {
user_content: String(this.content || '').trim(),
user_contentString: String(this.content || '').trim()
const value = String(this.content || '').trim()
return {
user_content: value,
user_contentString: value
}
if (this.uploadedFiles.length) {
payload.user_files = this.uploadedFiles.map((item) => ({
transfer_method: 'local_file',
type: this.getUploadFileType(item),
upload_file_id: item.id,
url: ''
}))
}
return payload
},
async runWorkflow() {
if (!this.canSubmit) {
this.showToast('请先输入文本指令或上传附件')
this.showToast('请先输入文本指令')
return
}
if (this.loading) {
@@ -655,54 +531,6 @@
color: #303133;
}
.upload-bar {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.upload-btn {
align-self: flex-start;
padding: 14rpx 24rpx;
border-radius: 20rpx;
background: rgba(20, 150, 242, 0.1);
color: #1496f2;
font-size: 24rpx;
font-weight: 600;
}
.upload-tip {
font-size: 22rpx;
line-height: 1.7;
color: #88715f;
}
.file-list {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-top: 16rpx;
}
.file-item-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: #f4faff;
border: 1rpx solid #d6eafb;
}
.file-main {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.file-badge {
flex-shrink: 0;
min-width: 72rpx;
@@ -715,37 +543,12 @@
font-weight: 600;
}
.file-text {
flex: 1;
min-width: 0;
}
.file-name,
.file-url,
.result-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-name {
color: #3b2c20;
font-size: 24rpx;
font-weight: 600;
}
.file-url {
margin-top: 8rpx;
color: #8c7462;
font-size: 20rpx;
}
.file-remove {
flex-shrink: 0;
color: #ff4d4f;
font-size: 22rpx;
}
.submit-bar,
.result-actions {
display: grid;