新增: 跨端图片上传(file.1216.top URL 优先,失败回退 base64,协议对齐)
This commit is contained in:
@@ -988,10 +988,16 @@ function send(text: string, skill?: string | null, spans?: MentionSpan[] | null,
|
||||
maxRoundsActive.value = false // P1-E:新 generation 清 MaxRounds 挂起
|
||||
|
||||
// 本地先 push user 气泡(乐观渲染,对齐桌面 sendMessage)
|
||||
// images:从 parts 提取图片展示(优先 url,回退 base64 dataURL),避免 content 空时空白气泡。
|
||||
const userImages = (parts || [])
|
||||
.filter((p) => p.type === 'image')
|
||||
.map((p) => (p.type === 'image' ? (p.url || (p.base64 ? `data:${p.media_type};base64,${p.base64}` : '')) : ''))
|
||||
.filter((s) => !!s)
|
||||
messages.value.push({
|
||||
id: genId('user'),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
...(userImages.length ? { images: userImages } : {}),
|
||||
timestamp: Date.now(),
|
||||
}); triggerRef(messages)
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface MiniappConfig {
|
||||
reconnectBaseDelay: number
|
||||
/** 重连最大间隔(ms) */
|
||||
reconnectMaxDelay: number
|
||||
/** file.1216.top 文件服务上传地址(不带路径,上传时拼 /upload) */
|
||||
fileUploadBaseUrl: string
|
||||
/** 上传 X-Source 头(项目隔离,文件存到 file.1216.top 对应目录) */
|
||||
fileUploadSource: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +44,8 @@ export const defaultConfig: MiniappConfig = {
|
||||
heartbeatInterval: 30000,
|
||||
reconnectBaseDelay: 1000,
|
||||
reconnectMaxDelay: 30000,
|
||||
fileUploadBaseUrl: 'https://file.1216.top',
|
||||
fileUploadSource: 'devflow',
|
||||
}
|
||||
|
||||
/** storage key(持久化完整 MiniappConfig JSON) */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { styleMarkdown } from '@/utils/mdRenderer'
|
||||
import { uploadImage } from '@/utils/fileUpload'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
import type { MentionSpan, MiniContentPart } from '@/types/relay'
|
||||
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
|
||||
@@ -239,15 +240,27 @@ const pendingSkill = ref<SkillInfoLike | null>(null)
|
||||
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
|
||||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||
|
||||
// —— 图片输入(2026-08-05):选图转 base64,发送时透传 ai.send parts 参数 ——
|
||||
// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde externally tagged。
|
||||
// —— 图片输入(2026-08-05):选图后上传 file.1216.top 拿 URL / 失败回退 base64,发送时透传 ai.send parts 参数 ——
|
||||
// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde 内部标签 tag=type。
|
||||
interface PendingImage {
|
||||
tempPath: string
|
||||
part: MiniContentPart
|
||||
}
|
||||
const pendingImages = ref<PendingImage[]>([])
|
||||
|
||||
/** 选图:uni.chooseMedia(compressed 压缩小图)→ base64 → 入 pendingImages。 */
|
||||
/** 临时文件扩展名 → MIME 映射(fileType 是类别 'image' 非 MIME,须按扩展名推断) */
|
||||
function toMime(file: { tempFilePath?: string; fileType?: string }): string {
|
||||
const ft = file.fileType
|
||||
if (ft && /^image\//i.test(ft)) return ft // 真 MIME 优先
|
||||
const p = file.tempFilePath || ''
|
||||
if (/\.png$/i.test(p)) return 'image/png'
|
||||
if (/\.(jpe?g)$/i.test(p)) return 'image/jpeg'
|
||||
if (/\.webp$/i.test(p)) return 'image/webp'
|
||||
if (/\.gif$/i.test(p)) return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
/** 选图:uni.chooseMedia(compressed)→ 上传 file.1216.top 拿 URL;失败回退 base64。 */
|
||||
function onPickImage(): void {
|
||||
uni.chooseMedia({
|
||||
count: 1,
|
||||
@@ -256,19 +269,26 @@ function onPickImage(): void {
|
||||
success: (res) => {
|
||||
const file = res.tempFiles && res.tempFiles[0]
|
||||
if (!file) return
|
||||
uni.getFileSystemManager().readFile({
|
||||
filePath: file.tempFilePath,
|
||||
encoding: 'base64',
|
||||
success: (r) => {
|
||||
const b64 = r.data as string
|
||||
if (!b64) return
|
||||
pendingImages.value.push({
|
||||
tempPath: file.tempFilePath,
|
||||
part: { Image: { base64: b64, media_type: file.fileType || 'image/jpeg' } },
|
||||
const mt = toMime(file)
|
||||
// 先尝试上传 file.1216.top → URL 引用(公开可达,provider 直传)
|
||||
uploadImage(file.tempFilePath)
|
||||
.then(({ url }) => {
|
||||
pendingImages.value.push({ tempPath: file.tempFilePath, part: { type: 'image', url, media_type: mt } })
|
||||
})
|
||||
.catch(() => {
|
||||
// 上传失败回退 base64(Phase 0 修复后 base64 可真正到达 provider,不阻断)
|
||||
uni.getFileSystemManager().readFile({
|
||||
filePath: file.tempFilePath,
|
||||
encoding: 'base64',
|
||||
success: (r) => {
|
||||
const b64 = r.data as string
|
||||
if (!b64) return
|
||||
pendingImages.value.push({ tempPath: file.tempFilePath, part: { type: 'image', base64: b64, media_type: mt } })
|
||||
},
|
||||
fail: () => uni.showToast({ title: '图片读取失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
fail: () => uni.showToast({ title: '图片读取失败', icon: 'none' }),
|
||||
})
|
||||
uni.showToast({ title: '上传失败,已改用本地直传', icon: 'none' })
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -748,7 +768,12 @@ function tokenInOf(t: TokenUsage): number {
|
||||
</view>
|
||||
<template v-for="m in visibleMessages" :key="m.id">
|
||||
<view v-if="shouldRenderMsg(m)" class="msg" :class="[m.role, m.isError ? 'msg-error' : '']" @longpress="onCopyMessage(m)">
|
||||
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
|
||||
<view v-if="m.role === 'user'">
|
||||
<!-- 图片预览(消息带图时) -->
|
||||
<image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" />
|
||||
<!-- 文本(有文本才显,图片+文本共存;纯图片则只有图,不空白) -->
|
||||
<text v-if="m.content" user-select>{{ m.content }}</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
|
||||
<text v-if="m.isError" user-select>{{ m.content }}</text>
|
||||
@@ -1029,6 +1054,13 @@ function tokenInOf(t: TokenUsage): number {
|
||||
.msg.user text {
|
||||
color: #ffffff;
|
||||
}
|
||||
/* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */
|
||||
.msg-user-img {
|
||||
display: block;
|
||||
width: 120px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg.system text {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -139,6 +139,8 @@ export interface ChatMessage {
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
||||
incomplete?: boolean
|
||||
/** 图片输入:消息携带的图片 URL 列表(预览用,发送时透传) */
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
||||
|
||||
@@ -122,7 +122,7 @@ export interface SendMessageArgs {
|
||||
mention_spans?: MentionSpan[] | null
|
||||
/**
|
||||
* 多模态片段(图片输入):miniapp 选图转 base64 构造 ContentPart Image 数组透传,
|
||||
* 对齐后端 ai_chat_send 的 parts 参数(df-ai-core ContentPart,serde externally tagged)。
|
||||
* 对齐后端 ai_chat_send 的 parts 参数(df-ai-core ContentPart,serde 内部标签 tag=type)。
|
||||
* null/undefined/空=纯文本消息(零回归)。
|
||||
*/
|
||||
parts?: MiniContentPart[] | null
|
||||
@@ -130,17 +130,17 @@ export interface SendMessageArgs {
|
||||
|
||||
/**
|
||||
* 多模态内容片段(对齐后端 df-ai-core/src/types.rs ContentPart)。
|
||||
* serde externally tagged:Image 片段 JSON 形如 `{"Image": {base64, media_type}}`。
|
||||
* serde 内部标签 `#[serde(tag="type", rename_all="snake_case")]`:Image 片段 JSON 形如
|
||||
* `{"type":"image","url":...,"media_type":...}`。
|
||||
*/
|
||||
export type MiniContentPart =
|
||||
| { Text: { text: string } }
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
Image: {
|
||||
url?: string | null
|
||||
base64?: string | null
|
||||
media_type?: string | null
|
||||
alt?: string | null
|
||||
}
|
||||
type: 'image'
|
||||
url?: string | null
|
||||
base64?: string | null
|
||||
media_type?: string | null
|
||||
alt?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** file.1216.top 文件服务上传客户端。
|
||||
* POST /upload multipart 'file' 字段 + 'X-Source' 头 → {"result":{"url":"https://..."},"retcode":0,"success":true}
|
||||
*/
|
||||
import { getConfig } from '@/config'
|
||||
|
||||
export interface UploadResult { url: string }
|
||||
|
||||
/** 上传图片到 file.1216.top,返回公开 URL。失败 reject(调用方回退 base64)。 */
|
||||
export function uploadImage(tempFilePath: string): Promise<UploadResult> {
|
||||
const cfg = getConfig()
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${cfg.fileUploadBaseUrl}/upload`,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
header: { 'X-Source': cfg.fileUploadSource },
|
||||
timeout: 15000,
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data as string)
|
||||
const url = data?.result?.url as string | undefined
|
||||
if (data?.success === true && data?.retcode === 0 && url) {
|
||||
resolve({ url })
|
||||
} else {
|
||||
reject(new Error(`上传响应异常: ${res.data}`))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user