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:
+436
-96
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user