feat(web-ai): support qwen image search safely
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
<view v-if="msg.role === 'user'" class="bubble-row right">
|
||||
<view class="bubble user">
|
||||
<view v-if="msg.voice" class="bubble-tag">🎤 语音转文字</view>
|
||||
<view v-if="msg.image" class="bubble-tag">📷 图片识图{{ msg.imageName ? ` · ${msg.imageName}` : '' }}</view>
|
||||
<text class="bubble-text">{{ msg.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -103,10 +104,16 @@
|
||||
:transcribing="voiceTranscribing"
|
||||
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
||||
:maxlength="capabilities?.maxQuestionLength || 500"
|
||||
:panel-items="[{ key: 'audio', icon: 'sound', label: '选音频转文字' }]"
|
||||
:media-name="selectedImage?.name || ''"
|
||||
media-kind="image"
|
||||
:panel-items="[
|
||||
{ key: 'image', icon: 'image', label: '图片识图', disabled: !capabilities?.supportsImage },
|
||||
{ key: 'audio', icon: 'sound', label: '选音频转文字' }
|
||||
]"
|
||||
@send="handleComposerSend"
|
||||
@voice-blob="handleVoiceBlob"
|
||||
@panel="handlePanelAction"
|
||||
@clear-media="clearImage"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@@ -114,11 +121,12 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { SpeechPlaybackStatus, WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||
import type { KnowledgeMediaSelectedFile, SpeechPlaybackStatus, WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
||||
import { getWebAiCapabilities, queryWebAi, queryWebAiWithImage } from '@/services/web-ai';
|
||||
import { renderMarkdown } from '@/utils/markdown';
|
||||
|
||||
const path = '/pages/user/web-ai/index';
|
||||
@@ -128,6 +136,8 @@ interface UserMsg {
|
||||
role: 'user';
|
||||
text: string;
|
||||
voice?: boolean;
|
||||
image?: boolean;
|
||||
imageName?: string;
|
||||
}
|
||||
|
||||
interface AiMsg {
|
||||
@@ -157,8 +167,10 @@ const threadScrollTop = ref(0);
|
||||
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
|
||||
const loading = ref(false);
|
||||
const voiceTranscribing = ref(false);
|
||||
const selectedImage = ref<KnowledgeMediaSelectedFile | null>(null);
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const consented = ref(false);
|
||||
const imageConsented = ref(false);
|
||||
let msgSeq = 0;
|
||||
let voiceTranscriptionGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
@@ -208,19 +220,24 @@ const loadCapabilities = async () => {
|
||||
};
|
||||
|
||||
/** WeChat-style consent: confirm once per session before the first outbound query. */
|
||||
const ensureConsent = () =>
|
||||
const ensureConsent = (hasImage = false) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
if (consented.value || !capabilities.value?.requiresConsent) {
|
||||
if ((hasImage ? imageConsented.value : consented.value) || !capabilities.value?.requiresConsent) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
uni.showModal({
|
||||
title: '发送前确认',
|
||||
content: '问题会先脱敏,再发送给外部公开检索与 AI 服务。是否继续?',
|
||||
content: hasImage
|
||||
? '图片和问题会发送给外部公开检索与 AI 服务,图片不会保存到企业知识库。请勿上传含个人信息、门禁或未公开资料的图片。是否继续?'
|
||||
: '问题会先脱敏,再发送给外部公开检索与 AI 服务。是否继续?',
|
||||
confirmText: '同意并发送',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) consented.value = true;
|
||||
if (res.confirm) {
|
||||
if (hasImage) imageConsented.value = true;
|
||||
else consented.value = true;
|
||||
}
|
||||
resolve(Boolean(res.confirm));
|
||||
},
|
||||
fail: () => resolve(false)
|
||||
@@ -232,7 +249,8 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const text = rawText.trim();
|
||||
const image = selectedImage.value;
|
||||
const text = rawText.trim() || (image ? '请识别图片内容,并联网检索相关公开资料。' : '');
|
||||
if (!text) {
|
||||
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||||
return;
|
||||
@@ -245,12 +263,17 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
uni.showToast({ title: '全网 AI 尚未由管理员启用', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureConsent())) return;
|
||||
pushMsg<UserMsg>({ role: 'user', text, voice: options.voice });
|
||||
if (image && !capabilities.value?.supportsImage) {
|
||||
uni.showToast({ title: '当前全网检索未启用图片识图', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureConsent(Boolean(image)))) return;
|
||||
pushMsg<UserMsg>({ role: 'user', text, voice: options.voice, image: Boolean(image), imageName: image?.name });
|
||||
selectedImage.value = null;
|
||||
const pending = pushMsg<PendingMsg>({ role: 'pending' });
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await queryWebAi(text, true);
|
||||
const result = image ? await queryWebAiWithImage(image, text, true) : await queryWebAi(text, true);
|
||||
replaceMsg(pending.id, { id: pending.id, role: 'ai', result, citationsOpen: false });
|
||||
} catch (error) {
|
||||
replaceMsg(pending.id, null);
|
||||
@@ -291,6 +314,23 @@ const handleVoiceBlob = async (blob: Blob) => {
|
||||
};
|
||||
|
||||
const handlePanelAction = async (key: string) => {
|
||||
if (key === 'image') {
|
||||
if (loading.value || !capabilities.value?.supportsImage) return;
|
||||
try {
|
||||
const image = await chooseKnowledgeMedia();
|
||||
if (image.kind !== 'image' || !/\.(jpe?g|png|bmp)$/i.test(image.name)) {
|
||||
throw new Error('仅支持 JPG、PNG 或 BMP 图片');
|
||||
}
|
||||
if (image.size > 4 * 1024 * 1024) throw new Error('图片不能超过 4MB');
|
||||
selectedImage.value = image;
|
||||
composerRef.value?.setDraft('');
|
||||
uni.showToast({ title: '已选择图片,发送时将联网识图', icon: 'none' });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '图片选择失败';
|
||||
if (text !== '未选择附件') uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key !== 'audio' || voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
@@ -319,6 +359,10 @@ const handlePanelAction = async (key: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearImage = () => {
|
||||
if (!loading.value) selectedImage.value = null;
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '播报';
|
||||
if (speechState.value.status === 'loading') return '生成语音中';
|
||||
@@ -335,7 +379,9 @@ onShow(() => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
if (!loggedIn.value) {
|
||||
messages.value = [];
|
||||
selectedImage.value = null;
|
||||
consented.value = false;
|
||||
imageConsented.value = false;
|
||||
}
|
||||
capabilities.value = null;
|
||||
void loadCapabilities();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import type { KnowledgeMediaSelectedFile, WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
type RawCapabilities = {
|
||||
enabled: boolean;
|
||||
supportsImage?: boolean;
|
||||
provider: string;
|
||||
requiresConsent: boolean;
|
||||
maxQuestionLength: number;
|
||||
@@ -24,6 +25,22 @@ const domainOf = (value: string) => {
|
||||
try { return new URL(value).hostname; } catch { return ''; }
|
||||
};
|
||||
|
||||
const normalizeResponse = (raw: RawWebAiResponse): WebAiResponse => ({
|
||||
requestId: `web:${Date.now()}`,
|
||||
status: raw.status,
|
||||
provider: raw.provider,
|
||||
answer: raw.answer || '',
|
||||
citations: (raw.sources || []).map((source) => ({
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
domain: domainOf(source.url),
|
||||
snippet: source.snippet
|
||||
})),
|
||||
noEvidence: raw.status !== 'OK',
|
||||
warning: raw.notice,
|
||||
redacted: raw.redacted
|
||||
});
|
||||
|
||||
export const getWebAiCapabilities = async (): Promise<WebAiCapabilities> => {
|
||||
const raw = await apiRequest<RawCapabilities>({
|
||||
url: '/api/aihr/web-ai/capabilities',
|
||||
@@ -32,6 +49,7 @@ export const getWebAiCapabilities = async (): Promise<WebAiCapabilities> => {
|
||||
});
|
||||
return {
|
||||
enabled: raw.enabled,
|
||||
supportsImage: raw.supportsImage,
|
||||
requiresConsent: raw.requiresConsent,
|
||||
providerName: raw.provider,
|
||||
maxQuestionLength: raw.maxQuestionLength,
|
||||
@@ -46,19 +64,25 @@ export const queryWebAi = async (question: string, consent: boolean): Promise<We
|
||||
data: { question, consent },
|
||||
timeout: 45000
|
||||
});
|
||||
return {
|
||||
requestId: `web:${Date.now()}`,
|
||||
status: raw.status,
|
||||
provider: raw.provider,
|
||||
answer: raw.answer || '',
|
||||
citations: (raw.sources || []).map((source) => ({
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
domain: domainOf(source.url),
|
||||
snippet: source.snippet
|
||||
})),
|
||||
noEvidence: raw.status !== 'OK',
|
||||
warning: raw.notice,
|
||||
redacted: raw.redacted
|
||||
};
|
||||
return normalizeResponse(raw);
|
||||
};
|
||||
|
||||
export const queryWebAiWithImage = (file: KnowledgeMediaSelectedFile, question: string, consent: boolean) =>
|
||||
new Promise<WebAiResponse>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: apiUrl('/api/aihr/web-ai/query-image'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: { question, consent: String(consent) },
|
||||
timeout: 60000,
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(normalizeResponse(readTextPayload<RawWebAiResponse>(response.statusCode, response.data)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('图片上传或联网识图失败'))
|
||||
});
|
||||
});
|
||||
|
||||
@@ -581,6 +581,7 @@ export interface DailyDrillAnswerResponse {
|
||||
|
||||
export interface WebAiCapabilities {
|
||||
enabled: boolean;
|
||||
supportsImage?: boolean;
|
||||
requiresConsent: boolean;
|
||||
providerName?: string;
|
||||
maxQuestionLength: number;
|
||||
|
||||
Reference in New Issue
Block a user