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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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 || '{}')
|
||||
|
||||
Reference in New Issue
Block a user