Files
prop-ai-hr/mobile-uni/src/pages/user/web-ai/index.vue
T
adminandClaude Fable 5 6935476e3f feat(mobile): web-ai chat rebuild (batch 3) + profile page APP-ification
用户三项决定落地:
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>
2026-07-18 09:23:51 +08:00

633 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>