用户三项决定落地: 1. 问AI(全网)页会话化:重写为微信式会话(欢迎/免责=AI气泡, 脱敏与敏感信息提醒保留,未启用=系统气泡+输入条禁用, 同意告知由勾选框改为首次发送前确认弹窗(会话内记忆), 答案气泡带播报+来源N条折叠(外链可点),按住说话松开即发+ 选音频,ChatComposer 复用;无tab栏子页,输入条自带安全区) 2. 配置维持不变(active_timeout/姓名字段/固定验证码按原状) 3. 知识大库开放:财务报备/销售话术/行政制度/培训视频/图片素材 (1004/1005/1007/1008/1009/1010)绑定移动端应用并授 employee/supervisor READ;1006「验收测试」为测试库, 为免污染员工检索结果暂不开放(报告中说明)。 生产验证:员工问「财务报备流程」「销售话术要点」均返回真实答案。 新需求「H5 像 APP」首批:「我」页 APP 化——折叠不删减: - 成长摘要保留核心数字,五维评分+成长路径收进「展开能力详情」 - 成长证据包默认收起(标题行+展开按钮) - 最近练过默认3条,「展开全部N条」可切换 页面高度 3072→1976px,信息全部一次点击可达。 验证:本地5项断言+生产3项复核全过(webai气泡/禁用态/composer、 profile折叠/高度、大库检索)。发布备份 h5-before-20260718092234。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
633 lines
17 KiB
Vue
633 lines
17 KiB
Vue
<template>
|
||
<view class="page webai-chat">
|
||
<view class="chat-head">
|
||
<view class="chat-head-row">
|
||
<view class="chat-head-left">
|
||
<button class="chat-back" aria-label="返回问师傅" @click="goBack">←</button>
|
||
<text class="chat-title">问 AI(全网)</text>
|
||
</view>
|
||
<text class="chat-context">互联网信息 · 独立来源</text>
|
||
</view>
|
||
<view class="source-switch chat-source-switch" aria-label="问答来源">
|
||
<button class="source-option" @click="goBack">企业问师傅</button>
|
||
<button class="source-option active" aria-current="page">问 AI(全网)</button>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view scroll-y class="chat-thread" :scroll-top="threadScrollTop" :scroll-with-animation="true">
|
||
<view class="thread-inner">
|
||
<view class="bubble-row left">
|
||
<view class="avatar ai-avatar">AI</view>
|
||
<view class="bubble master">
|
||
<text class="bubble-text">我是全网 AI,检索公开网页并保留可核验引用,不与企业 SOP 混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
|
||
<text class="bubble-subnote">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="capabilities && !capabilities.enabled" class="system-row">
|
||
<text class="system-text">全网 AI 尚未由管理员启用,不会静默改用企业知识库</text>
|
||
</view>
|
||
|
||
<view v-if="!loggedIn" class="bubble-row left">
|
||
<view class="avatar ai-avatar">AI</view>
|
||
<view class="bubble master">
|
||
<text class="bubble-text">登录后才能使用独立的全网检索额度和隐私告知。</text>
|
||
<view class="bubble-actions">
|
||
<button class="bubble-action primary" @click="goLogin">去登录</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<template v-for="msg in messages" :key="msg.id">
|
||
<view v-if="msg.role === 'user'" class="bubble-row right">
|
||
<view class="bubble user">
|
||
<view v-if="msg.voice" class="bubble-tag">🎤 语音转文字</view>
|
||
<text class="bubble-text">{{ msg.text }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else-if="msg.role === 'system'" class="system-row">
|
||
<text class="system-text">{{ msg.text }}</text>
|
||
</view>
|
||
|
||
<view v-else-if="msg.role === 'pending'" class="bubble-row left">
|
||
<view class="avatar ai-avatar">AI</view>
|
||
<view class="bubble master pending-bubble">
|
||
<text class="bubble-text">正在检索并核对来源…</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="bubble-row left">
|
||
<view class="avatar ai-avatar">AI</view>
|
||
<view class="bubble master">
|
||
<text v-if="msg.result.status === 'NO_ANSWER'" class="bubble-noevidence">已找到公开来源,但外部服务未返回可核验答案,请直接查看来源。</text>
|
||
<text v-else-if="msg.result.noEvidence" class="bubble-noevidence">未找到可核验的公开依据,本次不生成推断答案。</text>
|
||
<text v-else class="bubble-text">{{ msg.result.answer }}</text>
|
||
<text v-if="msg.result.redacted" class="bubble-redacted">已自动隐藏问题中的个人或门禁信息</text>
|
||
<text v-if="msg.result.warning" class="bubble-warning">{{ msg.result.warning }}</text>
|
||
|
||
<view class="bubble-actions">
|
||
<button v-if="msg.result.answer" class="bubble-action" @click="toggleSpeech(msg)">
|
||
{{ speechButtonLabel(`web-${msg.id}`) }}
|
||
</button>
|
||
<button v-if="msg.result.citations?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
|
||
来源 {{ msg.result.citations.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
|
||
</button>
|
||
</view>
|
||
|
||
<view v-if="msg.citationsOpen && msg.result.citations?.length" class="bubble-citations">
|
||
<a
|
||
v-for="(item, index) in msg.result.citations"
|
||
:key="`${item.url}-${index}`"
|
||
class="citation-link"
|
||
:href="item.url"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<text class="citation-title">{{ index + 1 }}. {{ item.title }}</text>
|
||
<text class="citation-meta">{{ item.domain }}{{ item.publishedAt ? ` · ${item.publishedAt}` : '' }}</text>
|
||
<text class="citation-content">{{ item.snippet }}</text>
|
||
</a>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<view :style="{ height: '12px' }" />
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<ChatComposer
|
||
ref="composerRef"
|
||
:disabled="!loggedIn || !capabilities?.enabled"
|
||
:transcribing="voiceTranscribing"
|
||
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
||
:maxlength="capabilities?.maxQuestionLength || 500"
|
||
:panel-items="[{ key: 'audio', icon: '🎵', label: '选音频转文字' }]"
|
||
@send="handleComposerSend"
|
||
@voice-blob="handleVoiceBlob"
|
||
@panel="handlePanelAction"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<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 ChatComposer from '@/components/chat/ChatComposer.vue';
|
||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||
import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
||
|
||
const path = '/pages/user/web-ai/index';
|
||
|
||
interface UserMsg {
|
||
id: number;
|
||
role: 'user';
|
||
text: string;
|
||
voice?: boolean;
|
||
}
|
||
|
||
interface AiMsg {
|
||
id: number;
|
||
role: 'ai';
|
||
result: WebAiResponse;
|
||
citationsOpen: boolean;
|
||
}
|
||
|
||
interface SystemMsg {
|
||
id: number;
|
||
role: 'system';
|
||
text: string;
|
||
}
|
||
|
||
interface PendingMsg {
|
||
id: number;
|
||
role: 'pending';
|
||
}
|
||
|
||
type ChatMsg = UserMsg | AiMsg | SystemMsg | PendingMsg;
|
||
|
||
const loggedIn = ref(false);
|
||
const capabilities = ref<WebAiCapabilities | null>(null);
|
||
const messages = ref<ChatMsg[]>([]);
|
||
const threadScrollTop = ref(0);
|
||
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
|
||
const loading = ref(false);
|
||
const voiceTranscribing = ref(false);
|
||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||
const consented = ref(false);
|
||
let msgSeq = 0;
|
||
let voiceTranscriptionGeneration = 0;
|
||
let abortVoiceTranscription: (() => void) | null = null;
|
||
|
||
const speechPlayback = createSpeechPlaybackController(
|
||
(state) => { speechState.value = state; },
|
||
(text) => { uni.showToast({ title: text, icon: 'none' }); }
|
||
);
|
||
|
||
const scrollToBottom = () => {
|
||
void nextTick(() => {
|
||
threadScrollTop.value = 100000 + msgSeq * 200;
|
||
});
|
||
};
|
||
|
||
const pushMsg = <T extends ChatMsg>(msg: Omit<T, 'id'>): T => {
|
||
const withId = { ...msg, id: ++msgSeq } as T;
|
||
messages.value = [...messages.value, withId];
|
||
scrollToBottom();
|
||
return withId;
|
||
};
|
||
|
||
const replaceMsg = (id: number, next: ChatMsg | null) => {
|
||
messages.value = next
|
||
? messages.value.map((item) => (item.id === id ? next : item))
|
||
: messages.value.filter((item) => item.id !== id);
|
||
scrollToBottom();
|
||
};
|
||
|
||
const pushSystem = (text: string) => pushMsg<SystemMsg>({ role: 'system', text });
|
||
|
||
const goBack = () => uni.switchTab({ url: '/pages/user/sop/index' });
|
||
|
||
const goLogin = () => {
|
||
rememberLoginRedirect(path);
|
||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||
};
|
||
|
||
const loadCapabilities = async () => {
|
||
if (!loggedIn.value) return;
|
||
try {
|
||
capabilities.value = await getWebAiCapabilities();
|
||
} catch (error) {
|
||
pushSystem(error instanceof Error ? error.message : '全网 AI 状态读取失败');
|
||
}
|
||
};
|
||
|
||
/** WeChat-style consent: confirm once per session before the first outbound query. */
|
||
const ensureConsent = () =>
|
||
new Promise<boolean>((resolve) => {
|
||
if (consented.value || !capabilities.value?.requiresConsent) {
|
||
resolve(true);
|
||
return;
|
||
}
|
||
uni.showModal({
|
||
title: '发送前确认',
|
||
content: '问题会先脱敏,再发送给外部公开检索与 AI 服务。是否继续?',
|
||
confirmText: '同意并发送',
|
||
cancelText: '取消',
|
||
success: (res) => {
|
||
if (res.confirm) consented.value = true;
|
||
resolve(Boolean(res.confirm));
|
||
},
|
||
fail: () => resolve(false)
|
||
});
|
||
});
|
||
|
||
const sendQuestion = async (rawText: string, options: { voice?: boolean } = {}) => {
|
||
if (loading.value) {
|
||
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
|
||
return;
|
||
}
|
||
const text = rawText.trim();
|
||
if (!text) {
|
||
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||
return;
|
||
}
|
||
if (!loggedIn.value) {
|
||
goLogin();
|
||
return;
|
||
}
|
||
if (!capabilities.value?.enabled) {
|
||
uni.showToast({ title: '全网 AI 尚未由管理员启用', icon: 'none' });
|
||
return;
|
||
}
|
||
if (!(await ensureConsent())) return;
|
||
pushMsg<UserMsg>({ role: 'user', text, voice: options.voice });
|
||
const pending = pushMsg<PendingMsg>({ role: 'pending' });
|
||
loading.value = true;
|
||
try {
|
||
const result = await queryWebAi(text, true);
|
||
replaceMsg(pending.id, { id: pending.id, role: 'ai', result, citationsOpen: false });
|
||
} catch (error) {
|
||
replaceMsg(pending.id, null);
|
||
pushSystem(error instanceof Error ? error.message : '全网查询失败,请稍后重试');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleComposerSend = (text: string) => {
|
||
void sendQuestion(text);
|
||
};
|
||
|
||
const handleVoiceBlob = async (blob: Blob) => {
|
||
if (voiceTranscribing.value) return;
|
||
const generation = ++voiceTranscriptionGeneration;
|
||
voiceTranscribing.value = true;
|
||
try {
|
||
const asr = await transcribeSpeechBlob(blob, 'web-ai-question.webm', (abort) => {
|
||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||
});
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
const text = (asr.text || '').trim();
|
||
if (!text) {
|
||
pushSystem('语音没有识别到文字,请再说一遍');
|
||
return;
|
||
}
|
||
void sendQuestion(text, { voice: true });
|
||
} catch (error) {
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
pushSystem(error instanceof Error ? error.message : '语音转写失败,请改用文字输入');
|
||
} finally {
|
||
if (generation === voiceTranscriptionGeneration) {
|
||
abortVoiceTranscription = null;
|
||
voiceTranscribing.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const handlePanelAction = async (key: string) => {
|
||
if (key !== 'audio' || voiceTranscribing.value) return;
|
||
const generation = ++voiceTranscriptionGeneration;
|
||
voiceTranscribing.value = true;
|
||
try {
|
||
const file = await chooseSpeechAudio();
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
const asr = await transcribeSpeechFile(file, (abort) => {
|
||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||
});
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
const text = (asr.text || '').trim();
|
||
if (!text) {
|
||
pushSystem('语音没有识别到文字,请再试一次');
|
||
return;
|
||
}
|
||
void sendQuestion(text, { voice: true });
|
||
} catch (error) {
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||
if (text !== '未选择音频') pushSystem(text);
|
||
} finally {
|
||
if (generation === voiceTranscriptionGeneration) {
|
||
abortVoiceTranscription = null;
|
||
voiceTranscribing.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const speechButtonLabel = (key: string) => {
|
||
if (speechState.value.key !== key) return '播报';
|
||
if (speechState.value.status === 'loading') return '生成语音中';
|
||
if (speechState.value.status === 'playing') return '停止';
|
||
return '播报';
|
||
};
|
||
|
||
const toggleSpeech = (msg: AiMsg) => {
|
||
if (!msg.result.answer) return;
|
||
void speechPlayback.toggle(`web-${msg.id}`, msg.result.answer);
|
||
};
|
||
|
||
onShow(() => {
|
||
loggedIn.value = isLoggedIn();
|
||
if (!loggedIn.value) {
|
||
messages.value = [];
|
||
consented.value = false;
|
||
}
|
||
capabilities.value = null;
|
||
void loadCapabilities();
|
||
scrollToBottom();
|
||
});
|
||
|
||
onUnload(() => {
|
||
speechPlayback.destroy();
|
||
composerRef.value?.teardown();
|
||
voiceTranscriptionGeneration += 1;
|
||
abortVoiceTranscription?.();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.webai-chat {
|
||
position: fixed;
|
||
top: var(--window-top, 0px);
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
padding: 0;
|
||
background: #f2f3f5;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.webai-chat :deep(.chat-composer-wrap) {
|
||
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.chat-head {
|
||
flex-shrink: 0;
|
||
padding: 8px 12px 6px;
|
||
background: #ffffff;
|
||
border-bottom: 1px solid #ebedf0;
|
||
}
|
||
|
||
.chat-head-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.chat-head-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chat-back {
|
||
width: 32px;
|
||
height: 32px;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 1px solid #e3e6ea;
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
color: #232a33;
|
||
font-size: 17px;
|
||
line-height: 30px;
|
||
}
|
||
|
||
.chat-title {
|
||
color: #18202b;
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.chat-context {
|
||
color: #8a93a0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.chat-source-switch {
|
||
display: flex;
|
||
padding: 3px;
|
||
border-radius: 999px;
|
||
background: #f2f3f5;
|
||
}
|
||
|
||
.chat-source-switch .source-option {
|
||
flex: 1;
|
||
margin: 0;
|
||
padding: 5px 0;
|
||
border: none;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: #5a6270;
|
||
font-size: 13px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.chat-source-switch .source-option.active {
|
||
background: #ffffff;
|
||
color: #c8001a;
|
||
font-weight: 700;
|
||
box-shadow: 0 1px 4px rgba(20, 24, 31, 0.08);
|
||
}
|
||
|
||
.chat-thread {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.thread-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 14px 12px 6px;
|
||
}
|
||
|
||
.bubble-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.bubble-row.right {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.avatar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.ai-avatar {
|
||
background: #1f2733;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.bubble {
|
||
max-width: 82%;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.bubble.master {
|
||
background: #ffffff;
|
||
border-top-left-radius: 4px;
|
||
box-shadow: 0 1px 3px rgba(20, 24, 31, 0.06);
|
||
}
|
||
|
||
.bubble.user {
|
||
background: #ffd8dc;
|
||
border-top-right-radius: 4px;
|
||
}
|
||
|
||
.bubble-text {
|
||
display: block;
|
||
color: #232a33;
|
||
font-size: 15px;
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.bubble-subnote {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: #a04a00;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.bubble-tag {
|
||
display: inline-block;
|
||
margin-bottom: 6px;
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
color: #a04a00;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.bubble-noevidence {
|
||
display: block;
|
||
color: #a04a00;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.bubble-redacted {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: #168243;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.bubble-warning {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: #a04a00;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.bubble-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.bubble-action {
|
||
margin: 0;
|
||
padding: 4px 12px;
|
||
border: 1px solid #e3e6ea;
|
||
border-radius: 999px;
|
||
background: #f7f8fa;
|
||
color: #38424e;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.bubble-action.primary {
|
||
border-color: #e70014;
|
||
background: #e70014;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.bubble-citations {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed #eceff2;
|
||
}
|
||
|
||
.citation-link {
|
||
display: block;
|
||
padding: 8px 10px;
|
||
border-left: 3px solid #cdd8e4;
|
||
border-radius: 6px;
|
||
background: #fafbfc;
|
||
color: inherit;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.citation-title {
|
||
display: block;
|
||
color: #232a33;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.citation-meta {
|
||
display: block;
|
||
margin-top: 2px;
|
||
color: #315b8f;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.citation-content {
|
||
display: block;
|
||
margin-top: 4px;
|
||
color: #4d5560;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.system-row {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 2px 0;
|
||
}
|
||
|
||
.system-text {
|
||
max-width: 86%;
|
||
padding: 4px 12px;
|
||
border-radius: 999px;
|
||
background: rgba(20, 24, 31, 0.06);
|
||
color: #6b7480;
|
||
font-size: 12px;
|
||
text-align: center;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.pending-bubble .bubble-text {
|
||
color: #8a93a0;
|
||
}
|
||
</style>
|