feat: add admin permissions voice input analytics and feedback

This commit is contained in:
2026-08-11 17:17:59 +08:00
parent 3b9215cc3d
commit fd2da26d10
46 changed files with 1836 additions and 192 deletions

View File

@@ -44,6 +44,10 @@ const statusText = ref("连接后端中");
const toastText = ref("");
const followingOutput = ref(true);
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const feedbackDialogOpen = ref(false);
const feedbackMessageId = ref<number | null>(null);
const feedbackContent = ref("");
const feedbackSubmitting = ref(false);
const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null;
let settlementPollVersion = 0;
@@ -234,6 +238,13 @@ async function runGeneration(sessionId: number, message: string, assistantIndex:
currentAssistant().streaming = false;
currentAssistant().retryQuestion = undefined;
currentAssistant().createdAt = new Date().toISOString();
try {
const latestHistory = await api.history(sessionId);
const latestAssistant = [...latestHistory].reverse().find((item) => item.role === "assistant");
if (latestAssistant) currentAssistant().id = String(latestAssistant.id);
} catch {
// 回答已经成功,历史记录偶发刷新失败不应将本次回答标记为失败。
}
await refreshSessionList();
await refreshProfile();
} catch (error) {
@@ -252,6 +263,29 @@ async function runGeneration(sessionId: number, message: string, assistantIndex:
}
}
function openFeedback(messageId: string) {
const parsed = Number(messageId);
if (!Number.isInteger(parsed)) return;
feedbackMessageId.value = parsed;
feedbackContent.value = "";
feedbackDialogOpen.value = true;
}
async function submitFeedback() {
const content = feedbackContent.value.trim();
if (!feedbackMessageId.value || !content || feedbackSubmitting.value) return;
feedbackSubmitting.value = true;
try {
await api.submitFeedback(feedbackMessageId.value, content);
feedbackDialogOpen.value = false;
showToast("感谢反馈,管理员会查看你提交的问题");
} catch (error) {
showToast(error instanceof Error ? error.message : "反馈提交失败");
} finally {
feedbackSubmitting.value = false;
}
}
function chatErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "";
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多AI 服务暂时繁忙。";
@@ -607,6 +641,7 @@ async function copyText(text: string) {
:loading-session="loadingSession"
@follow-change="followingOutput = $event"
@retry="retryMessage"
@feedback="openFeedback"
/>
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
<SessionDrawer
@@ -626,6 +661,14 @@ async function copyText(text: string) {
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
<AppDialog v-if="feedbackDialogOpen" title="反馈这次回答" labelled-by="feedback-dialog-title" @close="feedbackDialogOpen = false">
<section class="feedback-dialog">
<p>请简单描述这次回答存在的问题管理员可以结合当时的对话记录查看</p>
<textarea v-model="feedbackContent" maxlength="200" rows="5" aria-label="反馈内容" placeholder="例如:回答没有解决我的问题、内容不准确……" />
<div class="feedback-dialog-footer"><span>{{ feedbackContent.length }}/200</span><button type="button" :disabled="!feedbackContent.trim() || feedbackSubmitting" @click="submitFeedback">{{ feedbackSubmitting ? "提交中" : "提交反馈" }}</button></div>
</section>
</AppDialog>
<PersonalCenterDialog
v-if="personalCenterOpen && user"
:user="user"

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { Send, Square } from "@lucide/vue";
import { nextTick, ref } from "vue";
import { Mic, Send, Square, X } from "@lucide/vue";
import { nextTick, onMounted, ref } from "vue";
import { useVoiceRecorder } from "../composables/useVoiceRecorder";
import { api, transcribeVoice } from "../services/api";
defineProps<{
loading: boolean;
@@ -14,6 +17,35 @@ const emit = defineEmits<{
const input = ref("");
const textarea = ref<HTMLTextAreaElement | null>(null);
const voiceEnabled = ref(false);
const voiceMaxDuration = ref(60);
const transcribing = ref(false);
const voiceError = ref("");
const { recording, elapsedSeconds, start: startRecording, stop: stopRecording, cancel: cancelRecording } = useVoiceRecorder(
async (audio) => {
transcribing.value = true;
voiceError.value = "";
try {
const result = await transcribeVoice(audio);
insertTranscription(result.text);
} catch (error) {
voiceError.value = error instanceof Error ? error.message : "语音识别失败,请重试";
} finally {
transcribing.value = false;
}
},
);
onMounted(async () => {
try {
const config = await api.voiceConfig();
voiceEnabled.value = config.enabled;
voiceMaxDuration.value = config.maxDurationSeconds;
} catch {
voiceEnabled.value = false;
}
});
function resize() {
const element = textarea.value;
@@ -49,11 +81,48 @@ function onKeydown(event: KeyboardEvent) {
send();
}
}
async function beginVoiceInput() {
voiceError.value = "";
try {
await startRecording(voiceMaxDuration.value);
} catch (error) {
const name = error instanceof DOMException ? error.name : "";
voiceError.value = name === "NotAllowedError"
? "麦克风权限未开启,请在浏览器设置中允许后重试"
: error instanceof Error ? error.message : "无法启动录音";
}
}
async function insertTranscription(text: string) {
const element = textarea.value;
const start = element?.selectionStart ?? input.value.length;
const end = element?.selectionEnd ?? start;
const prefix = input.value.slice(0, start);
const suffix = input.value.slice(end);
const spacer = prefix && !/\s$/.test(prefix) ? " " : "";
input.value = `${prefix}${spacer}${text}${suffix}`;
await nextTick();
resize();
const cursor = prefix.length + spacer.length + text.length;
element?.focus();
element?.setSelectionRange(cursor, cursor);
}
function formatSeconds(seconds: number) {
return `00:${String(seconds).padStart(2, "0")}`;
}
</script>
<template>
<form class="chat-composer" @submit.prevent="send">
<div v-if="recording" class="voice-recording-panel" role="status">
<span class="voice-pulse" aria-hidden="true"></span>
<strong>正在录音 {{ formatSeconds(elapsedSeconds) }}</strong>
<span>最长 {{ voiceMaxDuration }} </span>
</div>
<textarea
v-else
ref="textarea"
v-model="input"
rows="1"
@@ -63,13 +132,25 @@ function onKeydown(event: KeyboardEvent) {
@input="resize"
@keydown="onKeydown"
/>
<button v-if="recording" type="button" class="composer-voice cancel" aria-label="取消录音" @click="cancelRecording">
<X :size="18" aria-hidden="true" />
</button>
<button v-else-if="voiceEnabled && !loading" type="button" class="composer-voice" :disabled="disabled || transcribing" :aria-label="transcribing ? '正在识别语音' : '语音输入'" @click="beginVoiceInput">
<Mic :size="19" aria-hidden="true" />
<span>{{ transcribing ? "识别中" : "语音" }}</span>
</button>
<button v-if="recording" type="button" class="composer-voice finish" aria-label="完成录音" @click="stopRecording">
<Square :size="16" fill="currentColor" aria-hidden="true" />
<span>完成</span>
</button>
<button v-if="loading" type="button" class="composer-stop" aria-label="停止生成" @click="emit('stop')">
<Square :size="17" fill="currentColor" aria-hidden="true" />
<span>停止</span>
</button>
<button v-else type="submit" class="composer-send" :disabled="disabled || !input.trim()">
<button v-else-if="!recording" type="submit" class="composer-send" :disabled="disabled || !input.trim()">
<Send :size="18" aria-hidden="true" />
<span>发送</span>
</button>
<p v-if="voiceError" class="voice-input-error" role="alert">{{ voiceError }}</p>
</form>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Bot, RotateCcw } from "@lucide/vue";
import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
import MarkdownIt from "markdown-it";
import { computed } from "vue";
@@ -17,6 +17,7 @@ const props = defineProps<{
const emit = defineEmits<{
retry: [];
feedback: [];
}>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
@@ -111,6 +112,10 @@ const displayTime = computed(() => {
</template>
<div v-else class="message-content">{{ renderedContent }}</div>
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>
<button v-if="role === 'assistant' && !streaming && !errorMessage && /^\d+$/.test(messageId)" type="button" class="message-feedback-button" @click="emit('feedback')">
<MessageSquareWarning :size="14" aria-hidden="true" />
反馈
</button>
</div>
</article>
</template>

View File

@@ -23,6 +23,7 @@ defineProps<{
const emit = defineEmits<{
followChange: [following: boolean];
retry: [messageId: string];
feedback: [messageId: string];
}>();
const scroller = ref<HTMLElement | null>(null);
@@ -69,6 +70,7 @@ defineExpose({ scrollToBottom, scrollToMessage });
:error-message="message.errorMessage"
:can-retry="Boolean(message.retryQuestion)"
@retry="emit('retry', message.id)"
@feedback="emit('feedback', message.id)"
/>
</template>
</section>

View File

@@ -0,0 +1,118 @@
import { onBeforeUnmount, ref } from "vue";
export function useVoiceRecorder(onComplete: (audio: Blob) => Promise<void>) {
const recording = ref(false);
const elapsedSeconds = ref(0);
let stream: MediaStream | null = null;
let context: AudioContext | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let processor: ScriptProcessorNode | null = null;
let mutedOutput: GainNode | null = null;
let timer: number | null = null;
let buffers: Float32Array[] = [];
let sourceSampleRate = 48000;
let cancelled = false;
async function start(limitSeconds: number) {
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
throw new Error("当前浏览器不支持麦克风录音");
}
stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
context = new AudioContext();
await context.resume();
sourceSampleRate = context.sampleRate;
source = context.createMediaStreamSource(stream);
processor = context.createScriptProcessor(4096, 1, 1);
mutedOutput = context.createGain();
mutedOutput.gain.value = 0;
buffers = [];
cancelled = false;
elapsedSeconds.value = 0;
processor.onaudioprocess = (event) => buffers.push(new Float32Array(event.inputBuffer.getChannelData(0)));
source.connect(processor);
processor.connect(mutedOutput);
mutedOutput.connect(context.destination);
recording.value = true;
const maxSeconds = Math.max(5, Math.min(60, limitSeconds));
timer = window.setInterval(() => {
elapsedSeconds.value += 1;
if (elapsedSeconds.value >= maxSeconds) void stop();
}, 1000);
}
async function stop() {
if (!recording.value) return;
recording.value = false;
const captured = buffers;
cleanup();
if (!cancelled && captured.length) {
await onComplete(encodeWav(captured, sourceSampleRate));
}
}
function cancel() {
cancelled = true;
recording.value = false;
cleanup();
}
function cleanup() {
if (timer !== null) window.clearInterval(timer);
timer = null;
processor?.disconnect();
source?.disconnect();
mutedOutput?.disconnect();
stream?.getTracks().forEach((track) => track.stop());
void context?.close();
processor = null;
source = null;
mutedOutput = null;
stream = null;
context = null;
buffers = [];
}
onBeforeUnmount(cancel);
return { recording, elapsedSeconds, start, stop, cancel };
}
function encodeWav(buffers: Float32Array[], inputRate: number) {
const samples = merge(buffers);
const outputRate = 16000;
const ratio = inputRate / outputRate;
const outputLength = Math.floor(samples.length / ratio);
const buffer = new ArrayBuffer(44 + outputLength * 2);
const view = new DataView(buffer);
writeText(view, 0, "RIFF");
view.setUint32(4, 36 + outputLength * 2, true);
writeText(view, 8, "WAVEfmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, outputRate, true);
view.setUint32(28, outputRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeText(view, 36, "data");
view.setUint32(40, outputLength * 2, true);
for (let index = 0; index < outputLength; index += 1) {
const start = Math.floor(index * ratio);
const end = Math.max(start + 1, Math.floor((index + 1) * ratio));
let sum = 0;
for (let cursor = start; cursor < end && cursor < samples.length; cursor += 1) sum += samples[cursor];
const value = Math.max(-1, Math.min(1, sum / (end - start)));
view.setInt16(44 + index * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
}
return new Blob([buffer], { type: "audio/wav" });
}
function merge(buffers: Float32Array[]) {
const result = new Float32Array(buffers.reduce((total, item) => total + item.length, 0));
let offset = 0;
buffers.forEach((item) => { result.set(item, offset); offset += item.length; });
return result;
}
function writeText(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
}

View File

@@ -10,6 +10,8 @@ import type {
ShareDraft,
TeacherHelpCard,
UserProfile,
VoiceInputConfig,
VoiceTranscriptionResult,
} from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
@@ -116,8 +118,34 @@ export const api = {
practiceReview: () => request<PracticeReviewResult>("/user/growth-profile"),
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
voiceConfig: () => request<VoiceInputConfig>("/voice/config"),
submitFeedback: (messageId: number, content: string) => request<{ id: number }>("/feedback", { method: "POST", body: JSON.stringify({ messageId, content }) }),
};
export async function transcribeVoice(audio: Blob): Promise<VoiceTranscriptionResult> {
const form = new FormData();
form.append("audio", audio, "voice-recording");
const headers = new Headers();
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 30_000);
let response: Response;
try {
response = await fetch(`${API_BASE}/voice/transcribe`, { method: "POST", headers, body: form, signal: controller.signal });
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw new ApiError("语音识别超时,请稍后重试", 408);
throw new ApiError("语音上传失败,请检查网络后重试", 0);
} finally {
window.clearTimeout(timeout);
}
const body = (await response.json().catch(() => ({ code: response.status, message: "服务响应异常", data: null }))) as ApiResponse<VoiceTranscriptionResult>;
if (!response.ok || body.code !== 0) {
throw new ApiError(body.message || "语音识别失败", response.status, response.headers.get("X-Request-ID") ?? "");
}
return body.data;
}
export async function streamChat(
sessionId: number,
message: string,

View File

@@ -1506,7 +1506,7 @@ textarea:focus-visible {
position: relative;
z-index: 6;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: end;
gap: 10px;
padding: 12px 14px max(12px, env(safe-area-inset-bottom));
@@ -1539,7 +1539,8 @@ textarea:focus-visible {
.chat-composer textarea::placeholder { color: var(--chat-weak); }
.composer-send,
.composer-stop {
.composer-stop,
.composer-voice {
min-width: 78px;
min-height: 50px;
display: inline-flex;
@@ -1554,6 +1555,36 @@ textarea:focus-visible {
font-weight: 650;
}
.composer-voice {
min-width: 50px;
padding: 0 13px;
border: 1px solid var(--chat-control-border);
background: #ffffff;
color: var(--chat-brand-deep);
}
.composer-voice:disabled { cursor: not-allowed; opacity: 0.55; }
.composer-voice.cancel { color: var(--chat-danger); }
.composer-voice.finish { border-color: var(--chat-brand); background: var(--chat-brand); color: #ffffff; }
.voice-recording-panel {
min-height: 50px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 15px;
border: 1px solid rgba(20, 148, 119, 0.34);
border-radius: var(--chat-radius-control);
background: rgba(20, 148, 119, 0.07);
color: var(--chat-brand-deep);
}
.voice-recording-panel span:last-child { color: var(--chat-weak); font-size: 12px; }
.voice-pulse { width: 9px; height: 9px; border-radius: 50%; background: var(--chat-danger); animation: voice-pulse 1.1s ease-in-out infinite; }
.voice-input-error { grid-column: 1 / -1; margin: -2px 3px 0; color: var(--chat-danger); font-size: 12px; }
@keyframes voice-pulse { 50% { opacity: 0.35; transform: scale(0.78); } }
.composer-send { background: var(--chat-brand); }
.composer-stop { background: var(--chat-danger); }
@@ -1563,6 +1594,14 @@ textarea:focus-visible {
color: #93a39d;
}
.message-feedback-button { margin-top: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--chat-weak); font-size: 12px; }
.message-feedback-button:hover { background: rgba(20, 148, 119, 0.08); color: var(--chat-brand-deep); }
.feedback-dialog p { margin: 0 0 12px; color: var(--chat-muted); line-height: 1.65; }
.feedback-dialog textarea { width: 100%; resize: vertical; padding: 12px; border: 1px solid var(--chat-control-border); border-radius: 10px; font: inherit; }
.feedback-dialog-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--chat-weak); font-size: 12px; }
.feedback-dialog-footer button { min-height: 38px; padding: 0 18px; border: 0; border-radius: 9px; background: var(--chat-brand); color: white; font-weight: 650; }
.feedback-dialog-footer button:disabled { opacity: 0.5; }
.history-mask {
position: absolute;
inset: 0;

View File

@@ -128,6 +128,16 @@ export interface CaptchaResult {
expiresInSeconds: number;
}
export interface VoiceInputConfig {
enabled: boolean;
maxDurationSeconds: number;
}
export interface VoiceTranscriptionResult {
text: string;
durationSeconds: number;
}
export interface ChatSession {
id: number;
title: string;