Files
tuanwei-uniapp/pages/assistant/chat-service.js
T
weicw-dshsa 244f0c24d8 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
2026-10-13 20:17:58 +08:00

500 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
API_BASE_URL,
DEFAULT_TENANT_ID,
TOKEN_HEADER_NAME,
clearAuthStorage,
getStoredUserInfo,
getToken,
redirectToLogin
} from '../../utils/request'
export { API_BASE_URL, getToken }
const MODULES_API_URL = API_BASE_URL
const APP_SECRET = 'ffd6eee985af45e4a75098422d1decbb'
const AI_REQUEST_TIMEOUT = 10 * 60 * 1000
let realAtob
const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
const b64re = /^(?:[A-Za-z\d+/]{4})*?(?:[A-Za-z\d+/]{2}(?:==)?|[A-Za-z\d+/]{3}=?)?$/
if (typeof atob !== 'function') {
realAtob = function(str) {
let input = String(str || '').replace(/[\t\n\f\r ]+/g, '')
if (!b64re.test(input)) {
throw new Error("Failed to execute 'atob': The string to be decoded is not correctly encoded.")
}
input += '=='.slice(2 - (input.length & 3))
let bitmap
let result = ''
let r1
let r2
let index = 0
for (; index < input.length;) {
bitmap =
(b64.indexOf(input.charAt(index++)) << 18) |
(b64.indexOf(input.charAt(index++)) << 12) |
((r1 = b64.indexOf(input.charAt(index++))) << 6) |
(r2 = b64.indexOf(input.charAt(index++)))
result +=
r1 === 64
? String.fromCharCode((bitmap >> 16) & 255)
: r2 === 64
? String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255)
: String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255, bitmap & 255)
}
return result
}
} else {
realAtob = atob
}
function b64DecodeUnicode(str) {
return decodeURIComponent(
realAtob(str)
.split('')
.map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`)
.join('')
)
}
function parseTokenPayload(token) {
const rawToken = String(token || '').replace(/^Bearer\s+/i, '')
const parts = rawToken.split('.')
if (!rawToken || parts.length !== 3) {
return null
}
try {
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(b64DecodeUnicode(normalized))
} catch (error) {
return null
}
}
function safeAdd(x, y) {
const lsw = (x & 0xffff) + (y & 0xffff)
const msw = (x >> 16) + (y >> 16) + (lsw >> 16)
return (msw << 16) | (lsw & 0xffff)
}
function bitRotateLeft(num, cnt) {
return (num << cnt) | (num >>> (32 - cnt))
}
function md5cmn(q, a, b, x, s, t) {
return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b)
}
function md5ff(a, b, c, d, x, s, t) {
return md5cmn((b & c) | (~b & d), a, b, x, s, t)
}
function md5gg(a, b, c, d, x, s, t) {
return md5cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function md5hh(a, b, c, d, x, s, t) {
return md5cmn(b ^ c ^ d, a, b, x, s, t)
}
function md5ii(a, b, c, d, x, s, t) {
return md5cmn(c ^ (b | ~d), a, b, x, s, t)
}
function binlMD5(x, len) {
x[len >> 5] |= 0x80 << len % 32
x[(((len + 64) >>> 9) << 4) + 14] = len
let i
let olda
let oldb
let oldc
let oldd
let a = 1732584193
let b = -271733879
let c = -1732584194
let d = 271733878
for (i = 0; i < x.length; i += 16) {
olda = a
oldb = b
oldc = c
oldd = d
a = md5ff(a, b, c, d, x[i], 7, -680876936)
d = md5ff(d, a, b, c, x[i + 1], 12, -389564586)
c = md5ff(c, d, a, b, x[i + 2], 17, 606105819)
b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330)
a = md5ff(a, b, c, d, x[i + 4], 7, -176418897)
d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426)
c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341)
b = md5ff(b, c, d, a, x[i + 7], 22, -45705983)
a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416)
d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417)
c = md5ff(c, d, a, b, x[i + 10], 17, -42063)
b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162)
a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682)
d = md5ff(d, a, b, c, x[i + 13], 12, -40341101)
c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290)
b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329)
a = md5gg(a, b, c, d, x[i + 1], 5, -165796510)
d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632)
c = md5gg(c, d, a, b, x[i + 11], 14, 643717713)
b = md5gg(b, c, d, a, x[i], 20, -373897302)
a = md5gg(a, b, c, d, x[i + 5], 5, -701558691)
d = md5gg(d, a, b, c, x[i + 10], 9, 38016083)
c = md5gg(c, d, a, b, x[i + 15], 14, -660478335)
b = md5gg(b, c, d, a, x[i + 4], 20, -405537848)
a = md5gg(a, b, c, d, x[i + 9], 5, 568446438)
d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690)
c = md5gg(c, d, a, b, x[i + 3], 14, -187363961)
b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501)
a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467)
d = md5gg(d, a, b, c, x[i + 2], 9, -51403784)
c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473)
b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734)
a = md5hh(a, b, c, d, x[i + 5], 4, -378558)
d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463)
c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562)
b = md5hh(b, c, d, a, x[i + 14], 23, -35309556)
a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060)
d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353)
c = md5hh(c, d, a, b, x[i + 7], 16, -155497632)
b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640)
a = md5hh(a, b, c, d, x[i + 13], 4, 681279174)
d = md5hh(d, a, b, c, x[i], 11, -358537222)
c = md5hh(c, d, a, b, x[i + 3], 16, -722521979)
b = md5hh(b, c, d, a, x[i + 6], 23, 76029189)
a = md5hh(a, b, c, d, x[i + 9], 4, -640364487)
d = md5hh(d, a, b, c, x[i + 12], 11, -421815835)
c = md5hh(c, d, a, b, x[i + 15], 16, 530742520)
b = md5hh(b, c, d, a, x[i + 2], 23, -995338651)
a = md5ii(a, b, c, d, x[i], 6, -198630844)
d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415)
c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905)
b = md5ii(b, c, d, a, x[i + 5], 21, -57434055)
a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571)
d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606)
c = md5ii(c, d, a, b, x[i + 10], 15, -1051523)
b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799)
a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359)
d = md5ii(d, a, b, c, x[i + 15], 10, -30611744)
c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380)
b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649)
a = md5ii(a, b, c, d, x[i + 4], 6, -145523070)
d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379)
c = md5ii(c, d, a, b, x[i + 2], 15, 718787259)
b = md5ii(b, c, d, a, x[i + 9], 21, -343485551)
a = safeAdd(a, olda)
b = safeAdd(b, oldb)
c = safeAdd(c, oldc)
d = safeAdd(d, oldd)
}
return [a, b, c, d]
}
function binl2rstr(input) {
let index
let output = ''
const length32 = input.length * 32
for (index = 0; index < length32; index += 8) {
output += String.fromCharCode((input[index >> 5] >>> index % 32) & 0xff)
}
return output
}
function rstr2binl(input) {
const output = Array(input.length >> 2)
let index
for (index = 0; index < output.length; index += 1) {
output[index] = 0
}
const length8 = input.length * 8
for (index = 0; index < length8; index += 8) {
output[index >> 5] |= (input.charCodeAt(index / 8) & 0xff) << index % 32
}
return output
}
function rstrMD5(value) {
return binl2rstr(binlMD5(rstr2binl(value), value.length * 8))
}
function rstr2hex(input) {
const hexTab = '0123456789abcdef'
let output = ''
let index
let value
for (index = 0; index < input.length; index += 1) {
value = input.charCodeAt(index)
output += hexTab.charAt((value >>> 4) & 0x0f) + hexTab.charAt(value & 0x0f)
}
return output
}
function str2rstrUTF8(input) {
return unescape(encodeURIComponent(input))
}
function md5(value) {
return rstr2hex(rstrMD5(str2rstrUTF8(String(value || ''))))
}
function objKeySort(obj) {
const next = {}
Object.keys(obj || {})
.sort()
.forEach((key) => {
next[key] = obj[key]
})
return next
}
function addSignature(payload) {
const form = payload || {}
form.timestamp = Date.now()
form.version = 'v3'
let sign = ''
const sorted = objKeySort(form)
Object.keys(sorted).forEach((key) => {
const value = form[key]
if (value !== null && value !== undefined && value !== '') {
sign = `${sign}${value}-`
}
})
form.sign = md5(`${sign}${APP_SECRET}`)
return form
}
function getStorageValue(keys) {
for (let index = 0; index < keys.length; index += 1) {
const value = uni.getStorageSync(keys[index])
if (value) {
return value
}
}
return ''
}
export function getCurrentUser() {
const primaryToken = getToken()
const storedUser = getStoredUserInfo()
const tokenList = [
primaryToken,
uni.getStorageSync('uni_id_token'),
uni.getStorageSync('access_token')
].filter(Boolean)
let payload = null
for (let index = 0; index < tokenList.length; index += 1) {
payload = parseTokenPayload(tokenList[index])
if (payload) {
break
}
}
if (!payload) {
return {
...storedUser,
token: primaryToken || ''
}
}
return {
...storedUser,
userId: Number(payload.userId || payload.uid || payload.id || 0) || null,
nickname: payload.nickname || payload.username || payload.nickName || '',
realName: payload.realName || payload.realname || payload.name || '',
token: primaryToken || ''
}
}
export function apiRequest({
url,
method = 'GET',
data,
withSignature = true,
withTenant = false,
timeout
}) {
const token = getToken()
const requestData = data ? { ...data } : undefined
const headers = {}
if (token) {
headers[TOKEN_HEADER_NAME] = token
}
if (requestData && withTenant) {
requestData.tenantId = DEFAULT_TENANT_ID
}
if (requestData && withSignature) {
addSignature(requestData)
}
return uni.request({
url,
method,
data: requestData,
header: headers,
...(timeout !== undefined && { timeout })
}).then((response) => {
const responseHeader = (response && response.header) || {}
const nextToken =
responseHeader[TOKEN_HEADER_NAME] ||
responseHeader[TOKEN_HEADER_NAME.toLowerCase()]
if (nextToken) {
uni.setStorageSync('access_token', nextToken)
}
const { statusCode } = response || {}
const body = (response && response.data) || {}
if (statusCode && statusCode >= 400) {
return Promise.reject(new Error(body.message || '请求失败'))
}
if (Number(body.code) === 401) {
clearAuthStorage()
redirectToLogin()
return Promise.reject(new Error(body.message || '登录已失效'))
}
if (body.code === 0) {
return body.data !== undefined ? body.data : body.message
}
return Promise.reject(new Error(body.message || '请求失败'))
})
}
export function listAiChatList(params) {
return apiRequest({
url: `${MODULES_API_URL}/ai/ai-chat-list`,
method: 'GET',
data: params
})
}
export function listAiChatHistory(params) {
return apiRequest({
url: `${MODULES_API_URL}/ai/ai-chat-history`,
method: 'GET',
data: params
})
}
export function removeAiChatList(id) {
return apiRequest({
url: `${MODULES_API_URL}/ai/ai-chat-list/${id}`,
method: 'DELETE',
withSignature: false
})
}
export function sendMessage(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/chat/message`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
export function manuscriptGen(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/manuscriptGen`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
export function creativeAssistant(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/creativeAssistant`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
export function activityOutlineGen(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/activityOutlineGen`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
/**
* 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)) {
return `${url
.replace(/^https:/, 'wss:')
.replace(/^http:/, 'ws:')
.replace(/\/api\/?$/, '')
.replace(/\/$/, '')}/chat/${userId}`
}
return `${url.replace(/\/api\/?$/, '')}/chat/${userId}`
}