新增: 跨端图片上传(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 挂起
|
maxRoundsActive.value = false // P1-E:新 generation 清 MaxRounds 挂起
|
||||||
|
|
||||||
// 本地先 push user 气泡(乐观渲染,对齐桌面 sendMessage)
|
// 本地先 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({
|
messages.value.push({
|
||||||
id: genId('user'),
|
id: genId('user'),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
|
...(userImages.length ? { images: userImages } : {}),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}); triggerRef(messages)
|
}); triggerRef(messages)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface MiniappConfig {
|
|||||||
reconnectBaseDelay: number
|
reconnectBaseDelay: number
|
||||||
/** 重连最大间隔(ms) */
|
/** 重连最大间隔(ms) */
|
||||||
reconnectMaxDelay: number
|
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,
|
heartbeatInterval: 30000,
|
||||||
reconnectBaseDelay: 1000,
|
reconnectBaseDelay: 1000,
|
||||||
reconnectMaxDelay: 30000,
|
reconnectMaxDelay: 30000,
|
||||||
|
fileUploadBaseUrl: 'https://file.1216.top',
|
||||||
|
fileUploadSource: 'devflow',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** storage key(持久化完整 MiniappConfig JSON) */
|
/** storage key(持久化完整 MiniappConfig JSON) */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { styleMarkdown } from '@/utils/mdRenderer'
|
import { styleMarkdown } from '@/utils/mdRenderer'
|
||||||
|
import { uploadImage } from '@/utils/fileUpload'
|
||||||
import { useAiChat } from '@/composables/useAiChat'
|
import { useAiChat } from '@/composables/useAiChat'
|
||||||
import type { MentionSpan, MiniContentPart } from '@/types/relay'
|
import type { MentionSpan, MiniContentPart } from '@/types/relay'
|
||||||
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
|
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
|
||||||
@@ -239,15 +240,27 @@ const pendingSkill = ref<SkillInfoLike | null>(null)
|
|||||||
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
|
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
|
||||||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||||
|
|
||||||
// —— 图片输入(2026-08-05):选图转 base64,发送时透传 ai.send parts 参数 ——
|
// —— 图片输入(2026-08-05):选图后上传 file.1216.top 拿 URL / 失败回退 base64,发送时透传 ai.send parts 参数 ——
|
||||||
// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde externally tagged。
|
// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde 内部标签 tag=type。
|
||||||
interface PendingImage {
|
interface PendingImage {
|
||||||
tempPath: string
|
tempPath: string
|
||||||
part: MiniContentPart
|
part: MiniContentPart
|
||||||
}
|
}
|
||||||
const pendingImages = ref<PendingImage[]>([])
|
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 {
|
function onPickImage(): void {
|
||||||
uni.chooseMedia({
|
uni.chooseMedia({
|
||||||
count: 1,
|
count: 1,
|
||||||
@@ -256,19 +269,26 @@ function onPickImage(): void {
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
const file = res.tempFiles && res.tempFiles[0]
|
const file = res.tempFiles && res.tempFiles[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
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({
|
uni.getFileSystemManager().readFile({
|
||||||
filePath: file.tempFilePath,
|
filePath: file.tempFilePath,
|
||||||
encoding: 'base64',
|
encoding: 'base64',
|
||||||
success: (r) => {
|
success: (r) => {
|
||||||
const b64 = r.data as string
|
const b64 = r.data as string
|
||||||
if (!b64) return
|
if (!b64) return
|
||||||
pendingImages.value.push({
|
pendingImages.value.push({ tempPath: file.tempFilePath, part: { type: 'image', base64: b64, media_type: mt } })
|
||||||
tempPath: file.tempFilePath,
|
|
||||||
part: { Image: { base64: b64, media_type: file.fileType || 'image/jpeg' } },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
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>
|
</view>
|
||||||
<template v-for="m in visibleMessages" :key="m.id">
|
<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)">
|
<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>
|
<template v-else>
|
||||||
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
|
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
|
||||||
<text v-if="m.isError" user-select>{{ m.content }}</text>
|
<text v-if="m.isError" user-select>{{ m.content }}</text>
|
||||||
@@ -1029,6 +1054,13 @@ function tokenInOf(t: TokenUsage): number {
|
|||||||
.msg.user text {
|
.msg.user text {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
/* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */
|
||||||
|
.msg-user-img {
|
||||||
|
display: block;
|
||||||
|
width: 120px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
.msg.system text {
|
.msg.system text {
|
||||||
color: #999999;
|
color: #999999;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export interface ChatMessage {
|
|||||||
toolCalls?: AiToolCallInfo[]
|
toolCalls?: AiToolCallInfo[]
|
||||||
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
||||||
incomplete?: boolean
|
incomplete?: boolean
|
||||||
|
/** 图片输入:消息携带的图片 URL 列表(预览用,发送时透传) */
|
||||||
|
images?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export interface SendMessageArgs {
|
|||||||
mention_spans?: MentionSpan[] | null
|
mention_spans?: MentionSpan[] | null
|
||||||
/**
|
/**
|
||||||
* 多模态片段(图片输入):miniapp 选图转 base64 构造 ContentPart Image 数组透传,
|
* 多模态片段(图片输入):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/空=纯文本消息(零回归)。
|
* null/undefined/空=纯文本消息(零回归)。
|
||||||
*/
|
*/
|
||||||
parts?: MiniContentPart[] | null
|
parts?: MiniContentPart[] | null
|
||||||
@@ -130,18 +130,18 @@ export interface SendMessageArgs {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 多模态内容片段(对齐后端 df-ai-core/src/types.rs ContentPart)。
|
* 多模态内容片段(对齐后端 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 =
|
export type MiniContentPart =
|
||||||
| { Text: { text: string } }
|
| { type: 'text'; text: string }
|
||||||
| {
|
| {
|
||||||
Image: {
|
type: 'image'
|
||||||
url?: string | null
|
url?: string | null
|
||||||
base64?: string | null
|
base64?: string | null
|
||||||
media_type?: string | null
|
media_type?: string | null
|
||||||
alt?: string | null
|
alt?: string | null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户消息内 mention 区间的元数据
|
* 用户消息内 mention 区间的元数据
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,6 +42,16 @@
|
|||||||
@select="selectMention"
|
@select="selectMention"
|
||||||
@update:mention-index="mentionIndex = $event"
|
@update:mention-index="mentionIndex = $event"
|
||||||
/>
|
/>
|
||||||
|
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → file.1216.top 上传 → URL 引用) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ai-img-pick-btn"
|
||||||
|
:title="$t('aiChat.pickImage')"
|
||||||
|
:aria-label="$t('aiChat.pickImage')"
|
||||||
|
@click="onPickImage"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
</button>
|
||||||
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||||
<button
|
<button
|
||||||
@@ -106,6 +116,8 @@ import { useAiStore } from '../../stores/ai'
|
|||||||
import { useProjectStore } from '../../stores/project'
|
import { useProjectStore } from '../../stores/project'
|
||||||
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
|
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
|
||||||
import { extractImageUrlParts } from '../../composables/ai/utils'
|
import { extractImageUrlParts } from '../../composables/ai/utils'
|
||||||
|
import { uploadFile } from '../../composables/ai/fileUpload'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import SkillMention from './SkillMention.vue'
|
import SkillMention from './SkillMention.vue'
|
||||||
import ImageInput from './ImageInput.vue'
|
import ImageInput from './ImageInput.vue'
|
||||||
import MentionPopover from './MentionPopover.vue'
|
import MentionPopover from './MentionPopover.vue'
|
||||||
@@ -135,17 +147,20 @@ const { t } = useI18n()
|
|||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const inputEl = ref<HTMLTextAreaElement>()
|
const inputEl = ref<HTMLTextAreaElement>()
|
||||||
|
|
||||||
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
|
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽/选择) ──
|
||||||
// 待发送图片列表(base64 data URI + media_type),handleSend 时构造 ContentPart[]。
|
// 待发送图片列表(url 或 base64 data URI + media_type),handleSend 时构造 ContentPart[]。
|
||||||
// 仅本组件级 ref,不入 store;发送后清空。预览缩略图渲染在输入框上方。
|
// 仅本组件级 ref,不入 store;发送后清空。预览缩略图渲染在输入框上方。
|
||||||
//
|
//
|
||||||
// 约束:
|
// 约束:
|
||||||
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
|
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
|
||||||
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
|
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
|
||||||
// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。
|
// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。
|
||||||
|
// - 2026-08-06:新增"📎 图片"按钮(tauri dialog 选图)→ 优先上传 file.1216.top 拿 URL
|
||||||
|
// (走 url 模式,provider 直传/Anthropic 预拉);上传失败回退 base64(dataUrl)。
|
||||||
|
// pendingImages 元素 url/dataUrl 二选一:url=file.1216.top 公开地址,dataUrl=base64。
|
||||||
const MAX_PENDING_IMAGES = 6
|
const MAX_PENDING_IMAGES = 6
|
||||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限)
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限)
|
||||||
const pendingImages = ref<{ id: string; dataUrl: string; mediaType: string }[]>([])
|
const pendingImages = ref<{ id: string; dataUrl?: string; url?: string; mediaType: string }[]>([])
|
||||||
|
|
||||||
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
|
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
|
||||||
function readImageFile(file: File): Promise<string> {
|
function readImageFile(file: File): Promise<string> {
|
||||||
@@ -157,7 +172,7 @@ function readImageFile(file: File): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast */
|
/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast。优先上传 file.1216.top 拿 URL,失败回退 base64。 */
|
||||||
async function addImageFile(file: File): Promise<void> {
|
async function addImageFile(file: File): Promise<void> {
|
||||||
if (!file.type.startsWith('image/')) return
|
if (!file.type.startsWith('image/')) return
|
||||||
if (file.size > MAX_IMAGE_BYTES) {
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
@@ -168,18 +183,55 @@ async function addImageFile(file: File): Promise<void> {
|
|||||||
emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' })
|
emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
try {
|
||||||
|
// 优先上传 file.1216.top → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。
|
||||||
|
// 失败回退 base64(dataUrl),不阻断图片输入。
|
||||||
|
const { url } = await uploadFile(file)
|
||||||
|
pendingImages.value.push({ id, url, mediaType: file.type })
|
||||||
|
} catch {
|
||||||
try {
|
try {
|
||||||
const dataUrl = await readImageFile(file)
|
const dataUrl = await readImageFile(file)
|
||||||
pendingImages.value.push({
|
pendingImages.value.push({ id, dataUrl, mediaType: file.type })
|
||||||
// id 用 timestamp+random,DOM key 唯一,移除按钮定位
|
|
||||||
id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
dataUrl,
|
|
||||||
mediaType: file.type,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
|
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
|
||||||
console.error('[AI] 图片读取失败:', e)
|
console.error('[AI] 图片读取失败:', e)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
|
||||||
|
async function onPickImage(): Promise<void> {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: true,
|
||||||
|
title: t('aiChat.pickImageTitle'),
|
||||||
|
filters: [{ name: t('aiChat.imageFilter'), extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }],
|
||||||
|
})
|
||||||
|
if (!selected) return
|
||||||
|
// tauri dialog open 返 string[] 或 string
|
||||||
|
const files = Array.isArray(selected) ? selected : [selected]
|
||||||
|
for (const path of files) {
|
||||||
|
const f = await blobFromPath(path)
|
||||||
|
if (f) await addImageFile(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从路径构造 File(Tauri dialog 返回路径,非 File;经 convertFileSrc 读字节构造)。
|
||||||
|
* convertFileSrc(path) → http://tauri.localhost/...(tauri 协议可访问本地文件,FilePreview 同款)。 */
|
||||||
|
async function blobFromPath(path: string): Promise<File | null> {
|
||||||
|
try {
|
||||||
|
const { convertFileSrc } = await import('@tauri-apps/api/core')
|
||||||
|
const res = await fetch(convertFileSrc(path))
|
||||||
|
if (!res.ok) return null
|
||||||
|
const blob = await res.blob()
|
||||||
|
// 扩展名 → MIME(tauri dialog filters 已限图片,兜底 image/jpeg)
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase() || ''
|
||||||
|
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }[ext] || 'image/jpeg'
|
||||||
|
return new File([blob], path.split(/[\\/]/).pop() || 'image', { type: mime })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AI] 读取所选文件失败:', path, e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除待发送图片 */
|
/** 移除待发送图片 */
|
||||||
@@ -729,8 +781,18 @@ async function handleSend() {
|
|||||||
// 空文本则只发图片片)
|
// 空文本则只发图片片)
|
||||||
...(text ? [{ type: 'text' as const, text }] : []),
|
...(text ? [{ type: 'text' as const, text }] : []),
|
||||||
...snapshotImgs.map(img => {
|
...snapshotImgs.map(img => {
|
||||||
|
// url 模式(file.1216.top 上传):直传 url,provider 直传 / Anthropic 预拉。
|
||||||
|
if (img.url) {
|
||||||
|
return {
|
||||||
|
type: 'image' as const,
|
||||||
|
url: img.url,
|
||||||
|
base64: null,
|
||||||
|
media_type: img.mediaType,
|
||||||
|
alt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
// dataUrl 形如 data:image/png;base64,xxxx —— 拆出纯 base64 + media_type
|
// dataUrl 形如 data:image/png;base64,xxxx —— 拆出纯 base64 + media_type
|
||||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(img.dataUrl)
|
const m = /^data:([^;]+);base64,(.*)$/s.exec(img.dataUrl || '')
|
||||||
const mediaType = m?.[1] || img.mediaType
|
const mediaType = m?.[1] || img.mediaType
|
||||||
const base64 = m?.[2] || ''
|
const base64 = m?.[2] || ''
|
||||||
return {
|
return {
|
||||||
@@ -909,6 +971,25 @@ defineExpose({
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.ai-img-pick-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.ai-img-pick-btn:hover {
|
||||||
|
background: var(--df-bg-raised);
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
.ai-send-btn--active {
|
.ai-send-btn--active {
|
||||||
background: var(--df-accent);
|
background: var(--df-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
//! 图片输入预览 — 待发送图片缩略图列表,从 ChatInput.vue 提取
|
//! 图片输入预览 — 待发送图片缩略图列表,从 ChatInput.vue 提取
|
||||||
//!
|
//!
|
||||||
//! 职责:显示待发送图片缩略图、移除按钮。不含图片读取/粘贴/拖拽逻辑。
|
//! 职责:显示待发送图片缩略图、移除按钮。不含图片读取/粘贴/拖拽逻辑。
|
||||||
|
//! 2026-08-06:支持 url 模式(file.1216.top 上传)与 dataUrl 模式(base64)双预览。
|
||||||
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 待发送图片列表 */
|
/** 待发送图片列表(url 模式用 url 预览,base64 模式用 dataUrl) */
|
||||||
pendingImages: { id: string; dataUrl: string; mediaType: string }[]
|
pendingImages: { id: string; dataUrl?: string; url?: string; mediaType: string }[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -20,7 +21,12 @@ const emit = defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="pendingImages.length" class="ai-img-preview-row">
|
<div v-if="pendingImages.length" class="ai-img-preview-row">
|
||||||
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
|
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
|
||||||
<img :src="img.dataUrl" :alt="t('aiChat.imagePreviewAlt')" class="ai-img-preview-thumb" />
|
<img
|
||||||
|
:src="img.url || img.dataUrl"
|
||||||
|
:alt="t('aiChat.imagePreviewAlt')"
|
||||||
|
class="ai-img-preview-thumb"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ai-img-preview-x"
|
class="ai-img-preview-x"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! 桌面端文件服务上传(file.1216.top)— 图片/文件 → 公开 URL
|
||||||
|
//!
|
||||||
|
//! 与小程序端 `apps/df-miniapp/src/utils/fileUpload.ts` 对称:复用 file.1216.top 的
|
||||||
|
//! `POST /upload`(multipart 'file' + X-Source 头)→ 返回公开 URL。
|
||||||
|
//! 桌面端是浏览器环境,用 fetch + FormData(区别于小程序 uni.uploadFile)。
|
||||||
|
//! file.1216.top 已确认 CORS 支持(`Access-Control-Allow-Origin: *`,X-Source 在 allow-headers)。
|
||||||
|
|
||||||
|
/** file.1216.top 上传地址(可经 appSettings 'df-file-upload-base' 覆盖) */
|
||||||
|
export const DEFAULT_FILE_UPLOAD_BASE = 'https://file.1216.top'
|
||||||
|
/** X-Source 项目标识(文件存 file.1216.top 对应目录,项目隔离) */
|
||||||
|
export const FILE_UPLOAD_SOURCE = 'devflow-desktop'
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件到 file.1216.top,返回公开 URL。失败 reject(调用方回退 base64)。
|
||||||
|
* 响应结构:`{"result":{"url":"https://..."},"retcode":0,"success":true}`
|
||||||
|
*/
|
||||||
|
export async function uploadFile(file: File, base?: string): Promise<UploadResult> {
|
||||||
|
const uploadBase = base || DEFAULT_FILE_UPLOAD_BASE
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file, file.name)
|
||||||
|
const res = await fetch(`${uploadBase}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Source': FILE_UPLOAD_SOURCE },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`上传失败:HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { result?: { url?: string }; success?: boolean; retcode?: number }
|
||||||
|
if (data?.success === true && data?.retcode === 0 && data?.result?.url) {
|
||||||
|
return { url: data.result.url }
|
||||||
|
}
|
||||||
|
throw new Error(`上传响应异常:${JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user