Просмотр исходного кода

Merge branch 'beta' of https://git.cocorobo.cn/jack/PPT into beta

jack 1 неделя назад
Родитель
Сommit
32bfbf5827

+ 2 - 2
src/plugins/icon.ts

@@ -129,7 +129,7 @@ import {
   Switch,
   More,
   LoadingFour, // 引入loadingIcon
-  UpTwo
+  UpTwo,
 } from '@icon-park/vue-next'
 
 export interface Icons {
@@ -264,7 +264,7 @@ export const icons: Icons = {
   IconSwitch: Switch,
   IconMore: More,
   IconLoading: LoadingFour, // 添加loadingIcon
-  UpTwo: UpTwo
+  UpTwo: UpTwo,
 }
 
 export default {

+ 10 - 0
src/services/course.ts

@@ -199,6 +199,16 @@ export const getWorkPageId = (params: any): Promise<any> => {
 }
 
 
+/**
+ * 
+ * 获取年级
+ * @param any 班级id
+ * @returns Promise<any>
+ */
+
+export const getClassById = (params: any): Promise<any> => {
+  return axios.get(`${API_URL}getClassById`, { params: params })
+}
 
 
 

+ 197 - 0
src/tools/aiChat.ts

@@ -1,8 +1,12 @@
 import axios, { cancelToken } from '@/services/config'
 import { v4 as uuidv4 } from 'uuid'
 import { fetchEventSource } from '@microsoft/fetch-event-source'
+import { ref } from 'vue'
 
 const model = {}
+let organizeId = ''
+const userId2 = ref('')
+const userName = ref('')
 
 interface ChatParams {
   id: string;
@@ -241,4 +245,197 @@ export const chat_no_stream2 = async (prompt: any[] = [], response_format = {
         resolve(false)
       })
   })
+}
+
+export const agentlistloading = ref(false)
+
+export const getAgentChatList = async (id: string, userId: string): Promise<any[]> => {
+  if (!id) {
+    return []
+  }
+  agentlistloading.value = true
+  if (!organizeId || userId2.value !== userId) {
+    userId2.value = userId
+    const res = await axios.get('https://pbl.cocorobo.cn/api/pbl/selectUser', {
+      params: { userid: userId }
+    })
+    userName.value = res[0][0].name
+    organizeId = res[0][0].organizeId
+  }
+
+  try {
+    const response = await axios.post('https://gpt4.cocorobo.cn/get_agent_chat', {
+      userid: userId,
+      groupid: id,
+    }, {
+      headers: {
+        'Content-Type': 'application/json',
+        'hwMac': organizeId,
+      },
+    })
+
+    const chat_list = JSON.parse(response?.FunctionResponse || '[]')
+    const messages = []
+
+    chat_list.forEach((item: any, index: number) => {
+      const json: any = {
+        role: 'user' as const,
+        userName: item.username,
+        content: decodeURIComponent(item.problem),
+        uid: id,
+        AI: 'AI',
+        aiContent: decodeURIComponent(item.answer),
+        reasoning: item.reasoning_content,
+        oldContent: decodeURIComponent(new DOMParser().parseFromString(
+          item.answer,
+          'text/html'
+        ).documentElement.textContent),
+        isShowSynchronization: false,
+        filename: item.filename,
+        index: index,
+        createtime: item.createtime,
+        is_mind_map: item.problem.includes('思维导图') ||
+          item.problem.includes('思維導圖') ||
+          item.problem.includes('mindMap'),
+        graph: item.problem === '知识图谱', // 使用默认值,因为无法访问this.lang
+      }
+
+      try {
+        json.jsonData = item.jsonData ? JSON.parse(decodeURIComponent(item.jsonData)) : null
+        // 从 jsonData 中读取 syncTranscriptionText 值
+        if (json.jsonData && json.jsonData.syncTranscriptionText !== undefined) {
+          json.syncTranscriptionText = json.jsonData.syncTranscriptionText
+        }
+        else {
+          // 如果没有 jsonData 或 syncTranscriptionText,使用默认值
+          json.syncTranscriptionText = false
+        }
+        
+        // 从 jsonData 中读取 contentType 值
+        if (json.jsonData && json.jsonData.contentType !== undefined) {
+          json.contentType = json.jsonData.contentType
+        }
+        else {
+          // 如果没有 jsonData 或 contentType,使用默认值
+          json.contentType = 'text'
+        }
+
+        // 新增:从 jsonData 中恢复用户音频播放所需字段
+        if (json.jsonData && json.jsonData.audio) {
+          json.audio = json.jsonData.audio
+        }
+        if (json.jsonData && json.jsonData.durationSec) {
+          json.durationSec = json.jsonData.durationSec
+        }
+      }
+      catch (error) {
+        console.error('Error parsing jsonData:', error)
+        json.jsonData = null
+        json.syncTranscriptionText = false
+        json.contentType = 'text'
+        agentlistloading.value = false
+      }
+
+      messages.push(json)
+    })
+    agentlistloading.value = false
+    return messages
+  }
+  catch (error) {
+    console.error('Error fetching agent chat list:', error)
+    return []
+  }
+}
+
+export interface InsertChatParams {
+  answer: string;
+  problem: string;
+  type: string;
+  alltext: string;
+  assistant_id: string;
+  userId: string;
+  userName: string;
+  fileId?: string;
+  latestMessage?: any;
+  agentHeadUrl?: string;
+  agentAssistantName?: string;
+}
+
+export interface InsertChatResult {
+  success: boolean;
+  questions?: string[];
+}
+
+export const insertChat = async (params: InsertChatParams): Promise<InsertChatResult> => {
+  const {
+    answer,
+    problem,
+    type,
+    alltext,
+    assistant_id,
+    fileId,
+    latestMessage,
+    agentHeadUrl,
+    agentAssistantName
+  } = params
+
+  const jsonData: any = {
+    headUrl: agentHeadUrl || '',
+    assistantName: agentAssistantName || ''
+  }
+
+  // 如果存在sourceArray,则添加到jsonData中
+  if (latestMessage && latestMessage.jsonData && latestMessage.jsonData.sourceArray) {
+    jsonData.sourceArray = latestMessage.jsonData.sourceArray
+  }
+
+  try {
+    const response = await axios.post('https://gpt4.cocorobo.cn/insert_chat', {
+      userId: userId2.value,
+      userName: userName.value,
+      groupId: assistant_id,
+      answer: encodeURIComponent(answer),
+      problem: encodeURIComponent(problem),
+      file_id: type === 'chat' ? '' : fileId,
+      alltext,
+      type,
+      jsonData: encodeURIComponent(JSON.stringify(jsonData))
+    }, {
+      headers: {
+        'Content-Type': 'application/json',
+        hwMac: organizeId,
+      },
+    })
+
+    // 处理返回的问题结果
+    if (response?.FunctionResponse?.questions_result) {
+      const data = response?.FunctionResponse.questions_result
+      if (data.includes('\n')) {
+        const arr = data.split('\n')
+        let questions: string[] = []
+        
+        if (arr.length > 3) {
+          questions = arr.slice(0, 3)
+        }
+        else {
+          questions = [...arr]
+        }
+        
+        return {
+          success: true,
+          questions
+        }
+      }
+    }
+
+    return {
+      success: true
+    }
+  }
+  catch (error) {
+    console.error('Error inserting chat:', error)
+    return {
+      success: false
+    }
+  }
 }

+ 1083 - 0
src/views/Student/components/aiChat.vue

@@ -0,0 +1,1083 @@
+<template>
+  <div class="ai-chat-popup" :style="popupStyle" ref="popupRef">
+    <div class="fullscreen-spin mask2" v-if="agentlistloading">
+      <div class="spin">
+        <div class="spinner"></div>
+        <div class="text">加载中...</div>
+      </div>
+    </div>
+    <!-- 弹窗头部 -->
+    <div class="ai-chat-header">
+      <h3>{{ lang.ssClassroomAiAssistant }}</h3>
+      <button class="close-btn" @click="$emit('close')">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+          stroke-linejoin="round">
+          <line x1="18" y1="6" x2="6" y2="18"></line>
+          <line x1="6" y1="6" x2="18" y2="18"></line>
+        </svg>
+      </button>
+    </div>
+    <!-- 聊天区域 v-if="messages.length > 0" -->
+    <div class="chat-section" ref="chatSection">
+      <!-- 消息列表 -->
+      <div v-for="(message, index) in messages" :key="index" class="chat-message">
+        <div class="message-content user-message chat" v-if="message.content">
+          <div v-html="message.content"></div>
+          <!-- 显示上传的文件 -->
+          <div class="message-files" v-if="message.sourceFiles && message.sourceFiles.length > 0">
+            <div v-for="(file, index) in message.sourceFiles" :key="index" class="message-file-item">
+              <span>{{ file.title }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="message-content ai-message chat" v-if="message.aiContent || message.loading">
+          <div v-if="message.aiContent" v-html="message.aiContent"></div>
+          <svg v-else xmlns="http://www.w3.org/2000/svg" width="32" height="32"
+            viewBox="0 0 24 24"><!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
+            <circle cx="4" cy="12" r="3" fill="currentColor">
+              <animate id="svgSpinners3DotsBounce0" attributeName="cy" begin="0;svgSpinners3DotsBounce1.end+0.25s"
+                calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+            </circle>
+            <circle cx="12" cy="12" r="3" fill="currentColor">
+              <animate attributeName="cy" begin="svgSpinners3DotsBounce0.begin+0.1s" calcMode="spline" dur="0.6s"
+                keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+            </circle>
+            <circle cx="20" cy="12" r="3" fill="currentColor">
+              <animate id="svgSpinners3DotsBounce1" attributeName="cy" begin="svgSpinners3DotsBounce0.begin+0.2s"
+                calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+            </circle>
+          </svg>
+        </div>
+      </div>
+    </div>
+    <!-- 输入区域 -->
+    <div class="input-section">
+      <div class="input-wrapper">
+        <div class="file-box" v-show="files.length">
+          <div v-for="(file, index) in files" :key="index" class="file-item">
+            <span class="file-name">{{ file.title }}</span>
+            <button class="remove-file-btn" @click="removeFile(index)">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round">
+                <line x1="18" y1="6" x2="6" y2="18"></line>
+                <line x1="6" y1="6" x2="18" y2="18"></line>
+              </svg>
+            </button>
+          </div>
+        </div>
+        <textarea class="ai-input" placeholder="请输入你的问题..." v-model="inputText" @keyup.enter.exact="sendMessage"
+          />
+
+      </div>
+      <div class="input-actions">
+        <!-- <FileInput accept="*" @change="handleFileUpload" >
+                        <button class="attach-btn">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                                <path
+                                    d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48">
+                                </path>
+                            </svg>
+                        </button>
+                    </FileInput> -->
+        <button class="send-btn" @click="sendMessage" v-if="!chatLoading">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round">
+            <line x1="22" y1="2" x2="11" y2="13"></line>
+            <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
+          </svg>
+        </button>
+        <button class="send-btn stop" @click="stopMessage" v-if="chatLoading">
+          <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <rect width="32" height="32" rx="16" fill="black" fill-opacity="0.4"></rect>
+            <path
+              d="M11.3333 12.333C11.3333 11.7807 11.781 11.333 12.3333 11.333H19.6666C20.2189 11.333 20.6666 11.7807 20.6666 12.333V19.6663C20.6666 20.2186 20.2189 20.6663 19.6666 20.6663H12.3333C11.781 20.6663 11.3333 20.2186 11.3333 19.6663V12.333Z"
+              fill="white" fill-opacity="0.9"></path>
+          </svg>
+        </button>
+      </div>
+      <!-- 输入时的快捷操作弹出 -->
+      <div class="quick-actions-popup" v-if="showQuickActions">
+        <button v-for="(action, index) in quickActions" :key="index" class="quick-action-btn"
+          @click="sendQuickAction(action)">{{ action }}</button>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, useTemplateRef, nextTick, watch, computed } from 'vue'
+import { chat_no_stream, chat_stream, getAgentModel, chat_no_stream2, getAgentChatList, agentlistloading, insertChat } from '@/tools/aiChat'
+import { useSlidesStore } from '@/store'
+import { lang } from '@/main'
+import MarkdownIt from 'markdown-it'
+import { getWorkPageId } from '@/services/course'
+import FileInput from '@/components/FileInput2.vue'
+import axios from '@/services/config'
+import message from '@/utils/message'
+import { getClassById } from '@/services/course'
+
+interface ChatMessage {
+  uid?: string
+  role: 'ai' | 'user'
+  content?: string
+  aiContent?: string
+  oldContent?: string
+  loading?: boolean
+  chatloading?: boolean
+  gLoading?: boolean
+  rawContent?: string
+  timestamp?: Date
+  like?: boolean
+  unlike?: boolean
+  isTyping?: boolean
+  AI?: string
+  isShowSynchronization?: boolean
+  filename?: string
+  is_mind_map?: boolean
+  sourceFiles?: Array<{
+    title: string
+    id?: string
+    url?: string
+  }>
+  jsonData?: {
+    gType?: string
+    isGenerate?: boolean
+    headUrl?: string
+    assistantName?: string
+    files?: Array<{
+      title: string
+      id?: string
+      url?: string
+    }>
+    sourceArray?: Array<{
+      text?: string
+      id?: string
+      title?: string
+    }>
+  }
+}
+
+const props = withDefaults(defineProps<{
+  userid?: string | null
+  position?: { x: number; y: number }
+  workJson?: any
+  visible?: boolean
+  cid?: string | null
+}>(), {
+  userid: null,
+  position: () => ({ x: 0, y: 0 }),
+  workJson: () => ({}),
+  visible: false,
+  cid: null
+})
+
+const emit = defineEmits(['close'])
+
+const grade = ref('')
+
+watch(() => props.visible, (newVal) => {
+  if (newVal) {
+    session_name.value = props.workJson.id || ''
+    console.log('workJson', props.workJson)
+    getAgentChatList(props.workJson.id, props.userid || '').then((res) => {
+      console.log('res', res)
+      messages.value = res
+      if (messages.value.length === 0) {
+        messages.value.push({
+          role: 'user',
+          content: '',
+        })
+        messages.value.at(-1).loading = true
+        messages.value.at(-1).chatloading = true
+        messages.value.at(-1).sourceFiles = files.value.filter(file => file.id !== null).map(file => ({
+          title: file.title,
+          id: file.id
+        }))
+        chatLoading.value = true
+        sendAction('')
+      }
+    })
+  }
+})
+
+watch(() => props.cid, (newVal) => {
+  if (newVal) {
+    getClassById({
+      id: newVal
+    }).then(res => {
+      console.log('res年级', res)
+      grade.value = res[0][0].name || ''
+    })
+  }
+})
+
+// 计算弹窗样式
+// 弹窗引用
+const popupRef = useTemplateRef<HTMLElement>('popupRef')
+
+// 计算弹窗样式
+const popupStyle = computed(() => {
+  // 获取slideListWrap的尺寸和位置
+  const slideListWrap = document.querySelector('.slide-list-wrap')
+  if (!slideListWrap) {
+    // 如果找不到slideListWrap,使用默认位置
+    return {
+      right: `${props.position.x + 10}px`,
+      bottom: `${props.position.y + 60}px`
+    }
+  }
+  
+  const wrapRect = slideListWrap.getBoundingClientRect()
+  // 使用实际的弹窗宽高,考虑边距
+  const popupWidth = popupRef.value?.clientWidth || 320 // 实际弹窗宽度
+  const popupHeight = popupRef.value?.clientHeight || 400 // 实际弹窗高度
+  const buttonWidth = 105 // 按钮宽度
+  const buttonHeight = 50 // 按钮高度
+  const margin = 10 // 弹窗与屏幕边缘的最小距离
+  
+  // 计算按钮在slideListWrap内的实际位置
+  const buttonRight = props.position.x
+  const buttonBottom = props.position.y
+  const buttonLeft = wrapRect.width - buttonRight - buttonWidth
+  const buttonTop = wrapRect.height - buttonBottom - buttonHeight
+  
+  console.log('按钮位置:', buttonTop, buttonLeft)
+  console.log('弹窗尺寸:', popupHeight, popupWidth)
+  // 判断弹窗显示方向
+  const style: any = {}
+  
+  // 判断垂直方向
+  if (buttonTop < popupHeight + margin + buttonHeight) {
+    // 按钮在上方,弹窗向下显示
+    style.top = `${wrapRect.top + wrapRect.height - buttonBottom - margin}px`
+  }
+  else {
+    // 按钮在下方,弹窗向上显示
+    style.bottom = `${(buttonBottom) + buttonHeight + margin}px`
+  }
+  
+  // 判断水平方向
+  if (buttonRight < popupWidth + margin) {
+    // 按钮在右侧,弹窗向左显示
+    style.right = `${buttonRight}px`
+  }
+  else {
+    // 按钮在左侧,弹窗向右显示
+    style.left = `${buttonLeft}px`
+  }
+  
+  return style
+})
+
+const inputText = ref('')
+const messages = ref<ChatMessage[]>([])
+const chatSection = useTemplateRef<HTMLElement>('chatSection')
+const chatLoading = ref(false)
+const showQuickActions = ref(false)
+const streamController = ref<{ abort: () => void } | null>(null)
+const noStreamController = ref<{ promise: Promise<string>; abort: () => void } | null>(null)
+const files = ref<Array<{ title: string; id?: string | null; url?: string; isProcessing?: boolean; cancel?: () => void }>>([])
+
+// 快捷操作短语数组
+const quickActions = [
+  lang.ssAiChatQuickAction1,
+  lang.ssAiChatQuickAction2,
+]
+
+// 监听输入变化,当输入"/"时显示快捷操作
+watch(inputText, (newValue) => {
+  // if (messages.value.length > 0 && newValue === '/') {
+  //   showQuickActions.value = true
+  // }
+  // else if (newValue !== '/') {
+  //   showQuickActions.value = false
+  // }
+})
+
+const sendMessage = () => {
+  if (chatLoading.value) {
+    return
+  }
+  // 检查是否有文件正在处理中
+  const hasProcessingFile = files.value.some(file => file.isProcessing)
+  if (hasProcessingFile) {
+    message.error(lang.ssAiChatWaitUpload)
+    return
+  }
+  if (inputText.value.trim() || files.value.length > 0) {
+    // 添加用户消息
+    messages.value.push({
+      role: 'user',
+      content: inputText.value,
+    })
+    // 模拟AI回复
+    // setTimeout(() => {
+
+    //   setTimeout(() => {
+    //     messages.value.at(-1).aiContent = '课程生成完成!为您创建了5个内容页面和3个互动工具。您可以查看底部课程大纲,或在中央区域开始编辑。',
+    //     messages.value.at(-1).jsonData = {
+    //       isChoice: true
+    //     }
+    //   }, 1000)
+    // }, 500)
+    prevChatResult()
+    messages.value.at(-1).loading = true
+    messages.value.at(-1).chatloading = true
+    messages.value.at(-1).sourceFiles = files.value.filter(file => file.id !== null).map(file => ({
+      title: file.title,
+      id: file.id
+    }))
+    chatLoading.value = true
+    sendAction(inputText.value)
+    inputText.value = ''
+    files.value = []
+  }
+}
+
+const stopMessage = () => {
+  if (streamController.value) {
+    streamController.value.abort()
+    streamController.value = null
+  }
+  if (noStreamController.value) {
+    noStreamController.value.abort()
+    noStreamController.value = null
+  }
+  chatLoading.value = false
+  if (messages.value.length > 0) {
+    messages.value.at(-1).chatloading = false
+    messages.value.at(-1).loading = false
+  }
+}
+
+// 处理文件上传
+const handleFileUpload = async (files2: File[]) => {
+  const maxSize = 10 * 1024 * 1024 // 10MB
+  const uploadPromises = []
+
+  for (let i = 0; i < files2.length; i++) {
+    const file = files2[i]
+    if (file.size > maxSize) {
+      message.error(lang.ssAiChatFileSizeLimit)
+      continue
+    }
+    // 先添加文件到列表,显示解析中状态
+    const fileIndex = files.value.length
+    files.value.push({
+      title: file.name + ' (' + lang.ssAiChatParsing + ')',
+      id: null,
+      isProcessing: true,
+      cancel: null
+    })
+    // 创建取消控制器
+    const controller = new AbortController()
+    files.value[fileIndex].cancel = () => {
+      controller.abort()
+      files.value.splice(fileIndex, 1)
+    }
+    // 创建上传Promise并添加到数组
+    const uploadPromise = uploadFile2(file, controller.signal).then(res => {
+      if (!res) {
+        files.value.splice(fileIndex, 1)
+        return
+      }
+      // 上传成功,更新文件状态
+      files.value[fileIndex] = {
+        title: file.name,
+        id: res.results.document_id,
+        isProcessing: false
+      }
+    }).catch(error => {
+      if (error.name !== 'AbortError') {
+        console.error(lang.ssAiChatUploadFailed, error)
+        files.value.splice(fileIndex, 1)
+      }
+    })
+
+    uploadPromises.push(uploadPromise)
+  }
+
+  // 等待所有文件上传完成
+  await Promise.allSettled(uploadPromises)
+}
+
+// 移除文件
+const removeFile = (index: number) => {
+  const file = files.value[index]
+  if (file && file.isProcessing && file.cancel) {
+    file.cancel()
+  }
+  files.value.splice(index, 1)
+}
+
+const prevChatResult = () => {
+  nextTick(() => {
+    if (chatSection.value) {
+      chatSection.value.scrollTop = chatSection.value.scrollHeight
+    }
+  })
+}
+
+const sendQuickAction = (action: string) => {
+  inputText.value = action
+  sendMessage()
+}
+
+import { v4 as uuidv4 } from 'uuid'
+const session_name = ref('')
+const slidesStore = useSlidesStore()
+const gType = ref('chat')
+
+const sendAction = async (action: string) => {
+  const md = new MarkdownIt()
+
+  let choice = ''
+  switch (props.workJson.atool) {
+    case '45':
+      choice = '选择题'
+      break
+    case '15':
+      choice = '问答题'
+      break
+    default:
+      choice = ''
+      break
+  }
+
+  let question_content = ''
+  let correct_answer = ''
+  let student_answer = ''
+  const workContent = JSON.parse(decodeURIComponent(props.workJson.content))
+  if (props.workJson.atool === '45') {
+    const testJson = workContent.testJson || []
+    for (let i = 0; i < testJson.length; i++) {
+      question_content += `第${i + 1}题:${testJson[i].teststitle}\n 选项:${testJson[i].checkList}`
+      correct_answer += `第${i + 1}题:${testJson[i].answer}` + '\n'
+      student_answer += `第${i + 1}题:${testJson[i].userAnswer}` + '\n'
+    }
+  }
+  else if (props.workJson.atool === '15') {
+    question_content = workContent.answerQ || ''
+    correct_answer = workContent.evaluationCriteria || '无'
+    student_answer = workContent.answer || ''
+  }
+
+  let promptText = ``
+  if (props.workJson.atool === '45') {
+    promptText += `
+     当前题目信息:
+    - 题目类型:${choice}
+    - 题目内容:${question_content}
+    - 正确答案:${correct_answer}(0,1,3是选项的下标,学生的答案也是)
+    - 学生提交的回答:${student_answer}
+    `
+  }
+  else if (props.workJson.atool === '15') {
+    promptText += `
+      当前题目信息:
+      - 题目类型:${choice}
+      - 题目标题:${question_content}
+      - 评分要点:${correct_answer}
+      - 学生提交的回答:${student_answer}
+    `
+  }
+  
+  const prompt = `
+  当前课程信息:
+  - 课程年级:${grade.value}}
+
+  ${promptText}
+  query:${action}
+  `
+  chat_stream(prompt, agentid2.value, props.userid || '', lang.lang, (event) => {
+    if (event.type === 'message') {
+      messages.value.at(-1).aiContent = md.render(event.data)
+
+      messages.value.at(-1).loading = false
+      prevChatResult()
+    }
+    else if (event.type === 'messageEnd') {
+      messages.value.at(-1).aiContent = md.render(event.data)
+      messages.value.at(-1).chatloading = false
+      chatLoading.value = false
+      prevChatResult()
+      insertChat({
+        answer: messages.value.at(-1).aiContent,
+        problem: messages.value.at(-1).content,
+        type: 'chat',
+        alltext: messages.value.at(-1).aiContent,
+        assistant_id: props.workJson.id
+      })
+    }
+  }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
+    streamController.value = controller
+  }).catch(err => {
+    chatLoading.value = false
+    console.log('err', err)
+    stopMessage()
+  })
+}
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+const { createSlide } = useSlideHandler()
+const { createFrameElement } = useCreateElement()
+
+
+const setPageId = async (tool: any, json: any) => {
+  const res = await getWorkPageId({
+    userid: props.userid || '',
+    type: tool,
+    json: json
+  })
+  return res[0][0].id
+}
+
+// 上传文件
+const uploadFile2 = async (file: File, signal?: AbortSignal): Promise<any> => {
+  try {
+    const uuid = uuidv4()
+    const formData = new FormData()
+    const timestamp = Date.now()
+    const finalExtension = file.name.split('.').pop()?.toLowerCase() || ''
+    const baseName = file.name.slice(0, -(finalExtension.length + 1))
+
+    formData.append(
+      'file',
+      new File([file], `${baseName}${timestamp}.${finalExtension}`)
+    )
+    formData.append('collection_ids', JSON.stringify([]))
+    formData.append('id', uuid)
+    formData.append('metadata', JSON.stringify({ title: file.name }))
+    formData.append('ingestion_mode', 'fast')
+    formData.append('run_with_orchestration', 'true')
+
+    // 同步知识库
+    const res = await axios.post(
+      'https://r2rserver.cocorobo.cn/v3/documents',
+      formData,
+      {
+        headers: {
+          'Content-Type': 'multipart/form-data',
+        },
+        signal: signal
+      }
+    )
+
+    console.log(res)
+    return res
+  }
+  catch (error) {
+    console.log('err', error)
+    if (error.name === 'AbortError') {
+      throw error
+    }
+    return ''
+  }
+}
+
+
+
+// const agentid1 = ref('cbb29b41-2a4a-4453-bf8d-357929ced4bd')// 判断意图
+const agentid2 = ref('bdcb2d5b-9dd6-4b1b-8cef-b34ce5579c5e')// 生成内容
+
+
+onMounted(() => {
+  // session_name.value = uuidv4()
+  // getAgentModel(agentid1.value)
+  getAgentModel(agentid2.value)
+  if (props.cid) {
+    getClassById({
+      id: props.cid
+    }).then(res => {
+      console.log('res年级', props.cid, res)
+      grade.value = res[0][0].name || ''
+    })
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.ai-chat-container {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  padding: 16px;
+  gap: 16px;
+}
+
+.ai-chat-popup {
+  position: absolute;
+  width: 400px;
+  height: 500px;
+  background: #fffefa;
+  display: flex;
+  flex-direction: column;
+  // padding: 16px;
+  gap: 16px;
+  z-index: 9999;
+  border-radius: 10px;
+  border: 2px solid #fcefc6;
+  overflow: hidden;
+}
+
+.ai-chat-header {
+  display: flex;
+  align-items: center;
+  height: 60px;
+  width: 100%;
+  padding: 0 16px;
+  border-bottom: 2px solid #fcefc6;
+  box-sizing: border-box;
+
+  .close-btn {
+    margin-left: auto;
+    width: 30px;
+    height: 30px;
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.3s ease;
+    color: #000;
+    background: #fff;
+    border-radius: 5px;
+    border: 2px solid #fcefc6;
+    box-sizing: border-box;
+
+    svg {
+      width: 20px;
+      height: 20px;
+    }
+  }
+}
+
+.chat-section {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  position: relative;
+}
+
+.input-section {
+  display: flex;
+  gap: 10px;
+  padding: 16px;
+  width: 100%;
+  border-top: 2px solid #fcefc6;
+}
+
+.input-wrapper {
+  position: relative;
+  display: flex;
+  background: #fff;
+  border: 2px solid #fcefc6;
+  border-radius: 8px;
+  padding: 8px 12px;
+  min-height: 70px;
+  flex: 1;
+}
+
+.ai-input {
+  flex: 1;
+  border: none;
+  background: transparent;
+  font-size: 14px;
+  color: #374151;
+  outline: none;
+  resize: none;
+  // min-height: 80px;
+
+  &::placeholder {
+    color: #9CA3AF;
+  }
+}
+
+.input-actions {
+  display: flex;
+  // justify-content: space-between;
+  // align-items: center;
+  margin-top: auto;
+}
+
+.attach-btn {
+  width: 32px;
+  height: 32px;
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.3s ease;
+  color: #6b7280;
+
+  svg {
+    width: 20px;
+    height: 20px;
+  }
+
+  &:hover {
+    background: #FFF4E5;
+    color: #F78B22;
+    border-radius: 4px;
+  }
+
+  input[type="file"] {
+    display: none;
+  }
+}
+
+.file-box {
+  margin-bottom: 8px;
+  min-height: 24px;
+  max-height: 70px;
+  overflow-y: auto;
+
+  .file-item {
+    display: flex;
+    align-items: center;
+    background: #f5f5f5;
+    padding: 4px 8px;
+    border-radius: 4px;
+    margin-bottom: 4px;
+
+    .file-name {
+      flex: 1;
+      font-size: 12px;
+      color: #374151;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .remove-file-btn {
+      background: none;
+      border: none;
+      cursor: pointer;
+      color: #9CA3AF;
+      font-size: 12px;
+      padding: 2px;
+      margin-left: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      svg {
+        width: 14px;
+        height: 14px;
+      }
+
+      &:hover {
+        color: #EF4444;
+      }
+    }
+  }
+}
+
+.send-btn {
+  margin-left: auto;
+  width: 32px;
+  height: 32px;
+  border: none;
+  background: #FF9300;
+  color: #fff;
+  border-radius: 50%;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.3s ease;
+
+  svg {
+    width: 20px;
+    height: 20px;
+  }
+
+  &:hover {
+    background: #E68A00;
+  }
+
+  &.stop {
+    background: unset;
+    width: auto;
+
+    svg {
+      width: 32px;
+      height: 32px;
+    }
+  }
+}
+
+.quick-actions {
+  // display: flex;
+  // flex-direction: column;
+  // gap: 8px;
+
+  .quick-action-btn {
+    padding: 5px 10px;
+    border: 1px solid #f7c58f;
+    background: #FFF9F2;
+    color: #6b4a1f;
+    border-radius: 16px;
+    font-size: 12px;
+    cursor: pointer;
+    text-align: left;
+    display: block;
+
+    &:hover {
+      border-color: #F78B22;
+      background: #FFF4E5;
+      color: #111827;
+    }
+
+    +.quick-action-btn {
+      margin-top: 8px;
+    }
+  }
+}
+
+
+
+.chat-section {
+  // flex: 1;
+  height: calc(100% - 155px);
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  // gap: 16px;
+  padding: 0 10px;
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: #F3F4F6;
+    border-radius: 3px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: #D1D5DB;
+    border-radius: 3px;
+
+    &:hover {
+      background: #9CA3AF;
+    }
+  }
+}
+
+.chat-message {
+  max-width: 100%;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: column;
+
+  .message-content {
+    display: flex;
+    flex-direction: column;
+    border-radius: 8px;
+    padding: 8px 10px;
+    font-size: 14px;
+    line-height: 1.5;
+    color: #374151;
+    width: fit-content;
+
+    +.message-content {
+      margin-top: 10px;
+    }
+  }
+}
+
+.message-content {
+  &.ai-message {
+    align-self: flex-start;
+    background: #fff;
+    border: 1.5px solid #fcefc6;
+    border-bottom-left-radius: 2px;
+
+    &>svg {
+      width: 17px;
+      height: 17px;
+    }
+  }
+}
+
+.message-content {
+  &.user-message {
+    align-self: flex-end;
+    background: #FFF4E5;
+    border: 1.5px solid #F78B22;
+    border-bottom-right-radius: 2px;
+  }
+}
+
+.initial-state {
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  align-items: flex-start;
+  // padding: 24px;
+  gap: 16px;
+}
+
+
+.confirm-btn {
+  margin-top: 10px;
+  padding: 6px 15px;
+  background: #FF9300;
+  color: white;
+  border: none;
+  border-radius: 8px;
+  font-size: 14px;
+  cursor: pointer;
+  margin-left: auto;
+  transition: all 0.3s ease;
+
+  &:hover {
+    background: #E68A00;
+  }
+
+  &.disabled {
+    background: #9CA3AF;
+    cursor: not-allowed;
+  }
+}
+
+ul {
+  margin: 8px 0;
+  padding-left: 20px;
+
+  li {
+    margin: 4px 0;
+  }
+}
+
+.quick-actions-popup {
+  position: absolute;
+  bottom: 100%;
+  left: 0;
+  right: 0;
+  background: white;
+  border: 1px solid #E5E7EB;
+  border-radius: 8px;
+  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+  padding: 8px;
+  z-index: 100;
+  margin-bottom: 8px;
+
+  .quick-action-btn {
+    width: 100%;
+    text-align: left;
+    padding: 10px 12px;
+    background: white;
+    border: none;
+    border-radius: 6px;
+    font-size: 14px;
+    color: #374151;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background: #fff4e5;
+    }
+  }
+}
+
+.fullscreen-spin {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 100;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  &.mask2 {
+    background-color: rgba($color: #f1f1f1, $alpha: .7);
+  }
+}
+.spin {
+  width: 200px;
+  height: 200px;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  margin-top: -100px;
+  margin-left: -100px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+.spinner {
+  width: 36px;
+  height: 36px;
+  border: 3px solid $themeColor;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: spinner .8s linear infinite;
+}
+.text {
+  margin-top: 20px;
+  color: $themeColor;
+}
+@keyframes spinner {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+</style>
+
+<style>
+.chat table {
+  text-align: center;
+  border-spacing: 0;
+  border-left: 1px solid #000;
+  border-bottom: 1px solid #000;
+}
+
+.chat table td,
+.chat table th {
+  border-top: 1px solid #000;
+  border-right: 1px solid #000;
+  padding: 10px;
+}
+
+.message-files {
+  margin-top: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.message-file-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #6b7280;
+  background: #f3f4f6;
+  padding: 4px 8px;
+  border-radius: 4px;
+  max-width: 200px;
+  overflow: hidden;
+}
+
+.message-file-item span {
+  flex: 1;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

+ 177 - 12
src/views/Student/index.vue

@@ -72,7 +72,7 @@
                     :manualExitFullscreen="() => { }" /> -->
 
         <!-- 不全屏时:使用编辑模式的显示比例和居中逻辑 -->
-        <div class="slide-list-wrap" :class="{'slide-list-wrap-n': !isFullscreen, 'laser-pen': laserPen }" :style="{
+        <div class="slide-list-wrap" ref="slideListWrapRef" :class="{'slide-list-wrap-n': !isFullscreen, 'laser-pen': laserPen }" :style="{
           width: isFullscreen ? '100%' : (slideWidth * canvasScale) + 'px',
           height: isFullscreen ? '100%' : (slideHeight * canvasScale) + 'px',
           left: isFullscreen ? '0' : `${(containerWidth - slideWidth * canvasScale) / 2}px`,
@@ -88,6 +88,13 @@
               <div class="homework-check-box-item-title">{{ lang.ssAnswer }}</div>
             </div>
           </div>
+          <div class="aiBtn" ref="aiBtnRef" v-if="isQuestionFrame && hasWork && false" 
+            :style="{ right: aiBtnPosition.x + 'px', bottom: aiBtnPosition.y + 'px' }" @click="openAiChat">
+            <IconComment class="aiBtn-icon" />
+            <span>AI对话</span>
+          </div>
+          <aiChat v-show="visibleAIChat && props.cid" :position="aiBtnPosition" @close="visibleAIChat = false" :userid="props.userid" :workJson="myWork" :visible="visibleAIChat" :cid="props.cid"/>
+         <!-- && props.type == '2' -->
           <div class="viewport" v-if="false">
             <div class="background" :style="backgroundStyle"></div>
 
@@ -396,6 +403,7 @@ import { WebsocketProvider } from 'y-websocket'
 import { Refresh } from '@icon-park/vue-next'
 import answerTheResult from './components/answerTheResult.vue'
 import choiceQuestionDetailDialog from './components/choiceQuestionDetailDialog.vue'
+import aiChat from './components/aiChat.vue'
 
 // 生成标准 UUID v4 格式(36位,符合 [0-9a-fA-F-] 格式)
 const generateUUID = (): string => {
@@ -841,6 +849,130 @@ const isChoiceQuestion = computed(() => {
   return frame?.toolType === 45
 })
 
+const isQuestionFrame = computed(() => {
+  const frame = elementList.value.find(element => element.type === ElementTypes.FRAME)
+  return frame?.toolType === 45 || frame?.toolType === 15
+})
+
+const hasWork = computed(() => {
+  return workArray.value.find(work => work.userid === props.userid) !== undefined
+})
+
+const myWork = computed(() => {
+  return workArray.value.find(work => work.userid === props.userid)
+})
+
+// AI按钮拖动相关状态
+const aiBtnPosition = ref({ x: 80, y: 70 }) // 初始位置(从右下角计算)
+const isDragging = ref(false)
+const dragStart = ref({ x: 0, y: 0 })
+const slideListWrapRef = ref<HTMLElement | null>(null)
+const aiBtnRef = ref<HTMLElement | null>(null)
+
+// 处理AI按钮开始拖动
+const handleAiBtnPointerDown = (e: PointerEvent) => {
+  isDragging.value = true
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    // 设置指针捕获,确保即使鼠标移出元素也能继续接收事件
+    aiBtn.setPointerCapture(e.pointerId)
+  }
+  // 获取slide-list-wrap元素的位置和尺寸
+  const slideListWrap = slideListWrapRef.value
+  if (slideListWrap) {
+    const rect = slideListWrap.getBoundingClientRect()
+    dragStart.value = {
+      x: e.clientX - (rect.right - aiBtnPosition.value.x),
+      y: e.clientY - (rect.bottom - aiBtnPosition.value.y)
+    }
+  }
+  e.preventDefault()
+}
+
+// 处理拖动中
+const handleAiBtnPointerMove = (e: PointerEvent) => {
+  if (isDragging.value) {
+    const slideListWrap = slideListWrapRef.value
+    if (slideListWrap) {
+      const rect = slideListWrap.getBoundingClientRect()
+      // 计算新位置(从slide-list-wrap右下角计算)
+      const newX = rect.right - (e.clientX - dragStart.value.x)
+      const newY = rect.bottom - (e.clientY - dragStart.value.y)
+      // 限制在slide-list-wrap范围内
+      const aiBtnWidth = 120 // 估计AI按钮宽度
+      const aiBtnHeight = 40 // 估计AI按钮高度
+      aiBtnPosition.value = {
+        x: Math.max(20, Math.min(newX, rect.width - aiBtnWidth)),
+        y: Math.max(20, Math.min(newY, rect.height - aiBtnHeight))
+      }
+    }
+  }
+}
+
+// 处理拖动结束
+const handleAiBtnPointerUp = (e: PointerEvent) => {
+  isDragging.value = false
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    // 释放指针捕获
+    aiBtn.releasePointerCapture(e.pointerId)
+  }
+}
+
+// 处理指针取消(如浏览器标签页切换)
+const handleAiBtnPointerCancel = (e: PointerEvent) => {
+  isDragging.value = false
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    // 释放指针捕获
+    aiBtn.releasePointerCapture(e.pointerId)
+  }
+}
+
+// 监听isQuestionFrame和hasWork的变化,当按钮显示时添加事件监听器
+watch([isQuestionFrame, hasWork], ([newIsQuestionFrame, newHasWork]) => {
+  if (newIsQuestionFrame && newHasWork) {
+    // 按钮显示了,添加事件监听器
+    nextTick(() => {
+      const aiBtn = aiBtnRef.value
+      if (aiBtn) {
+        aiBtn.addEventListener('pointerdown', handleAiBtnPointerDown)
+        aiBtn.addEventListener('pointermove', handleAiBtnPointerMove)
+        aiBtn.addEventListener('pointerup', handleAiBtnPointerUp)
+        aiBtn.addEventListener('pointercancel', handleAiBtnPointerCancel)
+      }
+    })
+  }
+  else {
+    // 按钮隐藏了,移除事件监听器
+    const aiBtn = aiBtnRef.value
+    if (aiBtn) {
+      aiBtn.removeEventListener('pointerdown', handleAiBtnPointerDown)
+      aiBtn.removeEventListener('pointermove', handleAiBtnPointerMove)
+      aiBtn.removeEventListener('pointerup', handleAiBtnPointerUp)
+      aiBtn.removeEventListener('pointercancel', handleAiBtnPointerCancel)
+    }
+  }
+})
+
+
+onUnmounted(() => {
+  // 移除事件监听器
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    aiBtn.removeEventListener('pointerdown', handleAiBtnPointerDown)
+    aiBtn.removeEventListener('pointermove', handleAiBtnPointerMove)
+    aiBtn.removeEventListener('pointerup', handleAiBtnPointerUp)
+    aiBtn.removeEventListener('pointercancel', handleAiBtnPointerCancel)
+  }
+})
+
+const visibleAIChat = ref(false)
+// 打开AI对话框
+const openAiChat = () => {
+  visibleAIChat.value = !visibleAIChat.value
+}
+
 // 检测当前幻灯片是否包含iframe元素
 const currentSlideHasIframe = computed(() => {
   console.log('elementList.value', elementList.value)
@@ -1613,7 +1745,7 @@ const importJSON = (jsonData: any) => {
         setTimeout(() => {
           showSlideList.value = true
           // 只有当当前页面存在iframe时才获取作业数据
-          if (currentSlideHasIframe.value && props.type == '1') {
+          if (currentSlideHasIframe.value) { // && props.type == '1'
             getWork()
           }
           selectCourseSLook(1)
@@ -2428,7 +2560,7 @@ const checkPPTFile = async (jsonObj: any) => {
     pptJsonFileid.value = data1[0].fileid
   }
   else {
-    const pptJsonFile = new File([jsonObj], courseDetail.value.title + '.json', { type: 'application/json' })
+    const pptJsonFile = new File([jsonObj], courseDetail.value.title + '.txt', { type: 'text/plain' })
     uploadFile2(pptJsonFile, props.courseid as string)
   }
 }
@@ -2455,19 +2587,22 @@ const getCourseDetail = async () => {
         jsonStr = new TextDecoder('utf-8').decode(uint8Array)
         try {
           const jsonObj = JSON.parse(jsonStr)
-          // 过滤掉 elements 中 type 为 image 的内容
-          const jsonObj2 = JSON.parse(JSON.stringify(jsonObj))
-          if (jsonObj2.slides) {
-            jsonObj2.slides.forEach((slide: any) => {
+          // 生成每页幻灯片的内容描述
+          const pptContent = []
+          if (jsonObj.slides) {
+            jsonObj.slides.forEach((slide: any, index: number) => {
+              let slideContent = ''
               if (slide.elements) {
-                slide.elements = slide.elements.filter((element: any) => element.type === 'text').map((element: any) => ({
-                  type: element.type,
-                  content: element.content
-                }))
+                const textElements = slide.elements.filter((element: any) => element.type === 'text')
+                if (textElements.length > 0) {
+                  slideContent = textElements.map((element: any) => element.content).join(' ')
+                }
               }
+              pptContent.push(`第${index + 1}页: ${slideContent || '内容为空'}`)
             })
           }
-          checkPPTFile(JSON.stringify(jsonObj2, null, 2))
+          const contentDescription = pptContent.join('\n')
+          checkPPTFile(contentDescription)
           importJSON(jsonObj)
         }
         catch (e) {
@@ -5324,4 +5459,34 @@ const clearTimerState = () => {
 
   .homework-check-box-item-title{}
 }
+
+.aiBtn {
+  position: absolute;
+  display: flex;
+  align-items: center;
+  background: #fff;
+  z-index: 9999;
+  border-radius: 50px;
+  border: 3px solid #f6c82b;
+  padding: 10px 15px;
+  font-weight: 600;
+  gap: 5px;
+  cursor: move;
+  user-select: none;
+  touch-action: none; /* 防止触摸设备上的默认行为 */
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* 添加阴影效果 */
+
+  .aiBtn-icon{
+      font-size: 16px;
+      color: #f6c82b;
+  }
+
+  &:hover {
+      background: #f9f9f9;
+  }
+
+  &:active {
+      transform: scale(0.98);
+  }
+}
 </style>

+ 2 - 1
src/views/lang/cn.json

@@ -736,5 +736,6 @@
   "ssAiChatParsing": "解析中...",
   "ssAiChatWaitUpload": "请等待文件上传完成后再发送消息",
   "ssAiChatFileSizeLimit": "文件大小不能超过10MB",
-  "ssAiChatUploadFailed": "文件上传失败:"
+  "ssAiChatUploadFailed": "文件上传失败:",
+  "ssClassroomAiAssistant": "课堂AI助手"
 }

+ 2 - 1
src/views/lang/en.json

@@ -736,5 +736,6 @@
   "ssAiChatParsing": "Parsing...",
   "ssAiChatWaitUpload": "Please wait for the file upload to complete before sending a message",
   "ssAiChatFileSizeLimit": "File size cannot exceed 10MB",
-  "ssAiChatUploadFailed": "File upload failed:"
+  "ssAiChatUploadFailed": "File upload failed:",
+  "ssClassroomAiAssistant": "Classroom AI Assistant"
 }

+ 2 - 1
src/views/lang/hk.json

@@ -736,5 +736,6 @@
   "ssAiChatParsing": "解析中...",
   "ssAiChatWaitUpload": "請等待文件上傳完成後再發送消息",
   "ssAiChatFileSizeLimit": "文件大小不能超過10MB",
-  "ssAiChatUploadFailed": "文件上傳失敗:"
+  "ssAiChatUploadFailed": "文件上傳失敗:",
+  "ssClassroomAiAssistant": "課堂AI助手"
 }