新增: 跨端图片上传(file.1216.top URL 优先,失败回退 base64,协议对齐)
This commit is contained in:
@@ -42,6 +42,16 @@
|
||||
@select="selectMention"
|
||||
@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 三态,
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
@@ -106,6 +116,8 @@ import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
|
||||
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 ImageInput from './ImageInput.vue'
|
||||
import MentionPopover from './MentionPopover.vue'
|
||||
@@ -135,17 +147,20 @@ const { t } = useI18n()
|
||||
const inputText = ref('')
|
||||
const inputEl = ref<HTMLTextAreaElement>()
|
||||
|
||||
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
|
||||
// 待发送图片列表(base64 data URI + media_type),handleSend 时构造 ContentPart[]。
|
||||
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽/选择) ──
|
||||
// 待发送图片列表(url 或 base64 data URI + media_type),handleSend 时构造 ContentPart[]。
|
||||
// 仅本组件级 ref,不入 store;发送后清空。预览缩略图渲染在输入框上方。
|
||||
//
|
||||
// 约束:
|
||||
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
|
||||
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
|
||||
// - 仅接受 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_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 字段) */
|
||||
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> {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
@@ -168,17 +183,54 @@ async function addImageFile(file: File): Promise<void> {
|
||||
emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' })
|
||||
return
|
||||
}
|
||||
const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
try {
|
||||
const dataUrl = await readImageFile(file)
|
||||
pendingImages.value.push({
|
||||
// id 用 timestamp+random,DOM key 唯一,移除按钮定位
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
dataUrl,
|
||||
mediaType: file.type,
|
||||
})
|
||||
// 优先上传 file.1216.top → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。
|
||||
// 失败回退 base64(dataUrl),不阻断图片输入。
|
||||
const { url } = await uploadFile(file)
|
||||
pendingImages.value.push({ id, url, mediaType: file.type })
|
||||
} catch {
|
||||
try {
|
||||
const dataUrl = await readImageFile(file)
|
||||
pendingImages.value.push({ id, dataUrl, mediaType: file.type })
|
||||
} catch (e) {
|
||||
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
|
||||
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) {
|
||||
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
|
||||
console.error('[AI] 图片读取失败:', e)
|
||||
console.error('[AI] 读取所选文件失败:', path, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,8 +781,18 @@ async function handleSend() {
|
||||
// 空文本则只发图片片)
|
||||
...(text ? [{ type: 'text' as const, text }] : []),
|
||||
...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
|
||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(img.dataUrl)
|
||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(img.dataUrl || '')
|
||||
const mediaType = m?.[1] || img.mediaType
|
||||
const base64 = m?.[2] || ''
|
||||
return {
|
||||
@@ -909,6 +971,25 @@ defineExpose({
|
||||
transition: all 0.15s;
|
||||
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 {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! 图片输入预览 — 待发送图片缩略图列表,从 ChatInput.vue 提取
|
||||
//!
|
||||
//! 职责:显示待发送图片缩略图、移除按钮。不含图片读取/粘贴/拖拽逻辑。
|
||||
//! 2026-08-06:支持 url 模式(file.1216.top 上传)与 dataUrl 模式(base64)双预览。
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
/** 待发送图片列表 */
|
||||
pendingImages: { id: string; dataUrl: string; mediaType: string }[]
|
||||
/** 待发送图片列表(url 模式用 url 预览,base64 模式用 dataUrl) */
|
||||
pendingImages: { id: string; dataUrl?: string; url?: string; mediaType: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -20,7 +21,12 @@ const emit = defineEmits<{
|
||||
<template>
|
||||
<div v-if="pendingImages.length" class="ai-img-preview-row">
|
||||
<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
|
||||
type="button"
|
||||
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