feat: add admin permissions voice input analytics and feedback
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user