diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 249fd42..80ee042 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -34,6 +34,7 @@ def _message_out(m: ChatMessage) -> dict: "id": m.id, "session_id": m.session_id, "role": m.role, "content": m.content, "model": m.model, "file_ids": file_ids, "feedback": m.feedback or "", "suggestions": suggestions, "edited": bool(m.edited), "regenerated": m.regenerated or 0, + "reasoning": m.reasoning_content or "", "created_at": m.created_at.isoformat(), "updated_at": m.updated_at.isoformat(), } @@ -142,7 +143,7 @@ async def regenerate(session_id: int, """REST 非流式重新生成最后一条 AI 回答(备用,前端主要走 WS 流式)。""" s = _get_owned_session(db, user, session_id) try: - async for delta, message_id in chat_service.regenerate_stream(db, s): + async for _k, _d, message_id in _regenerate_rest(chat_service.regenerate_stream(db, s)): pass # 流式丢弃,最终内容已入库 except LookupError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -150,3 +151,9 @@ async def regenerate(session_id: int, raise HTTPException(status_code=502, detail=f"模型调用失败:{e}") m = db.get(ChatMessage, message_id) return ok(_message_out(m)) + + +async def _regenerate_rest(agen): + """适配 regenerate_stream 的 (kind, delta), id 产出结构。""" + async for (kind, delta), message_id in agen: + yield kind, delta, message_id diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py index 964525a..470afb1 100644 --- a/backend/app/api/ws.py +++ b/backend/app/api/ws.py @@ -1,7 +1,7 @@ """WebSocket 路由:流式对话 + 重新生成。协议: 客户端 → {"type":"chat","content":"...","agent_id":null,"model":"","file_ids":[]} {"type":"regenerate"} -服务端 → {"type":"delta","message_id":1,"content":"增量文本"} +服务端 → {"type":"delta","message_id":1,"kind":"reasoning"|"content","content":"增量文本"} {"type":"done","message_id":1} {"type":"suggestions","message_id":1,"items":["..",".."]} {"type":"title","title":"..."} @@ -70,20 +70,20 @@ async def chat_ws(websocket: WebSocket): await websocket.send_json({"type": "error", "message": "消息不能为空"}) continue try: - async for delta, message_id in chat_service.chat_stream( + async for (kind, delta), message_id in chat_service.chat_stream( db, user, session, content, agent_id=data.get("agent_id"), model=data.get("model", ""), file_ids=file_ids, ): - await websocket.send_json({"type": "delta", "message_id": message_id, "content": delta}) + await websocket.send_json({"type": "delta", "message_id": message_id, "kind": kind, "content": delta}) await websocket.send_json({"type": "done", "message_id": message_id}) except Exception as e: await websocket.send_json({"type": "error", "message": f"模型调用失败:{e}"}) elif msg_type == "regenerate": try: - async for delta, message_id in chat_service.regenerate_stream(db, session): - await websocket.send_json({"type": "delta", "message_id": message_id, "content": delta}) + async for (kind, delta), message_id in chat_service.regenerate_stream(db, session): + await websocket.send_json({"type": "delta", "message_id": message_id, "kind": kind, "content": delta}) await websocket.send_json({"type": "done", "message_id": message_id}) except LookupError as e: await websocket.send_json({"type": "error", "message": str(e)}) diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index 9566796..6ca5d7e 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -41,8 +41,10 @@ async def chat_completion_stream( model: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 4096, -) -> AsyncIterator[str]: - """流式对话补全:逐段产出增量文本。""" +): + """流式对话补全:逐段产出增量。 + 产出 (kind, text):kind="reasoning" 思考内容 / kind="content" 回答内容。 + """ client = _client_for(resolve_model(model)) stream = await client.chat.completions.create( model=resolve_model(model), @@ -52,8 +54,36 @@ async def chat_completion_stream( stream=True, ) async for chunk in stream: - if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content: - yield chunk.choices[0].delta.content + if not chunk.choices or not chunk.choices[0].delta: + continue + delta = chunk.choices[0].delta + # 思考内容:deepseek-reasoner 用 reasoning_content,部分模型用 reasoning/thinking + reasoning = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) + if reasoning: + yield ("reasoning", reasoning) + continue + if delta.content: + yield ("content", delta.content) + + +async def chat_completion_full( + messages: list[dict], + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 4096, +) -> tuple[str, str]: + """非流式对话补全:返回 (回答内容, 思考内容)。""" + client = _client_for(resolve_model(model)) + resp = await client.chat.completions.create( + model=resolve_model(model), + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + stream=False, + ) + msg = resp.choices[0].message + reasoning = getattr(msg, "reasoning_content", None) or getattr(msg, "reasoning", None) or "" + return (msg.content or ""), reasoning async def vision_analysis(prompt: str, image_urls: list[str], model: Optional[str] = None) -> str: diff --git a/backend/app/core/migrate.py b/backend/app/core/migrate.py index 03d031c..bf188a6 100644 --- a/backend/app/core/migrate.py +++ b/backend/app/core/migrate.py @@ -12,6 +12,7 @@ def migrate(db): "suggestions": "TEXT DEFAULT '[]'", "edited": "BOOLEAN DEFAULT 0", "regenerated": "INTEGER DEFAULT 0", + "reasoning_content": "TEXT DEFAULT ''", "updated_at": "DATETIME", } for name, ddl in additions.items(): diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py index 48f060b..d62434a 100644 --- a/backend/app/models/chat.py +++ b/backend/app/models/chat.py @@ -39,6 +39,8 @@ class ChatMessage(Base): feedback: Mapped[str] = mapped_column(String(8), default="") # 推荐短语(JSON 数组,AI 回答后生成 1-3 条) suggestions: Mapped[str] = mapped_column(Text, default="[]") + # 思考内容(思考模型的 reasoning 流式输出) + reasoning_content: Mapped[str] = mapped_column(Text, default="") # 用户消息是否被编辑过 edited: Mapped[bool] = mapped_column(Boolean, default=False) # 重新生成次数 diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index 9494cfd..4627ca8 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -220,9 +220,11 @@ async def chat_once(db: Session, user: User, session: ChatSession, content: str, db.commit() messages = build_messages(db, session, content, file_ids) - reply_text = await llm.chat_completion(messages, model=session.model, temperature=agent.temperature if agent else 0.7) + reply_text, reply_reasoning = await llm.chat_completion_full( + messages, model=session.model, temperature=agent.temperature if agent else 0.7) - reply = ChatMessage(session_id=session.id, role="assistant", content=reply_text, model=session.model) + reply = ChatMessage(session_id=session.id, role="assistant", content=reply_text, + reasoning_content=reply_reasoning, model=session.model) db.add(reply) session.updated_at = datetime.utcnow() db.commit() @@ -245,25 +247,32 @@ async def chat_stream(db: Session, user: User, session: ChatSession, content: st db.commit() messages = build_messages(db, session, content, file_ids) - reply = ChatMessage(session_id=session.id, role="assistant", content="", model=session.model) + reply = ChatMessage(session_id=session.id, role="assistant", content="", reasoning_content="", model=session.model) db.add(reply) db.commit() db.refresh(reply) parts: list[str] = [] + reasoning_parts: list[str] = [] try: - async for delta in llm.chat_completion_stream( + async for kind, delta in llm.chat_completion_stream( messages, model=session.model, temperature=agent.temperature if agent else 0.7 ): - parts.append(delta) - yield delta, reply.id + if kind == "reasoning": + reasoning_parts.append(delta) + yield ("reasoning", delta), reply.id + else: + parts.append(delta) + yield ("content", delta), reply.id except Exception as e: reply.content = "".join(parts) or f"(调用失败:{e})" + reply.reasoning_content = "".join(reasoning_parts) session.updated_at = datetime.utcnow() db.commit() raise else: reply.content = "".join(parts) + reply.reasoning_content = "".join(reasoning_parts) session.updated_at = datetime.utcnow() db.commit() _fire_background_jobs(db, session, content, reply.id, reply.content) @@ -276,7 +285,7 @@ def _fire_background_jobs(db: Session, session: ChatSession, first_content: str, async def regenerate_stream(db: Session, session: ChatSession): - """重新生成最后一条 AI 回答(流式)。返回 (message_id, async_iter)。""" + """重新生成最后一条 AI 回答(流式):先删除旧回答内容,再流式输出。""" last = ( db.query(ChatMessage) .filter(ChatMessage.session_id == session.id, ChatMessage.role == "assistant") @@ -286,27 +295,36 @@ async def regenerate_stream(db: Session, session: ChatSession): if not last: raise LookupError("没有可重新生成的消息") - agent = db.get(Agent, session.agent_id) if session.agent_id else None - messages = build_messages(db, session, "", exclude_last=True) - - # 重新生成后旧建议清空,等新建议 + # 先删除旧回答(内容清空,前端同步清空显示) + last.content = "" + last.reasoning_content = "" last.suggestions = "[]" db.commit() + agent = db.get(Agent, session.agent_id) if session.agent_id else None + messages = build_messages(db, session, "", exclude_last=True) + parts: list[str] = [] + reasoning_parts: list[str] = [] try: - async for delta in llm.chat_completion_stream( + async for kind, delta in llm.chat_completion_stream( messages, model=session.model, temperature=agent.temperature if agent else 0.7 ): - parts.append(delta) - yield delta, last.id + if kind == "reasoning": + reasoning_parts.append(delta) + yield ("reasoning", delta), last.id + else: + parts.append(delta) + yield ("content", delta), last.id except Exception as e: last.content = "".join(parts) or f"(调用失败:{e})" + last.reasoning_content = "".join(reasoning_parts) last.regenerated += 1 db.commit() raise else: last.content = "".join(parts) + last.reasoning_content = "".join(reasoning_parts) last.regenerated += 1 session.updated_at = datetime.utcnow() db.commit() diff --git a/frontend/src/views/ChatListView.vue b/frontend/src/views/ChatListView.vue index a5187f6..308cd0f 100644 --- a/frontend/src/views/ChatListView.vue +++ b/frontend/src/views/ChatListView.vue @@ -2,7 +2,7 @@
@@ -23,26 +23,6 @@ - - -
-

新建会话

- - -
- 取消 - 创建 -
-
-
- - - -
@@ -54,15 +34,10 @@ import client from '../api/client' const router = useRouter() const sessions = ref([]) -const agents = ref([]) const loading = ref(false) const refreshing = ref(false) -const showNew = ref(false) -const showAgentPicker = ref(false) const creating = ref(false) -const newTitle = ref('') -const selectedAgent = ref(null) -const agentColumns = ref([]) +let generalAgentId: number | null = null function timeText(iso: string) { return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) @@ -72,6 +47,11 @@ async function load() { loading.value = true try { sessions.value = await client.get('/chat/sessions') + // 预取通用助手 id(新建会话直接用,不弹窗) + try { + const agents = await client.get('/agents') + generalAgentId = agents.find((a: any) => a.is_builtin && a.name === '通用助手')?.id ?? null + } catch {} } catch (e: any) { showToast(e.message) } finally { @@ -80,31 +60,12 @@ async function load() { } } -async function loadAgents() { - try { - agents.value = await client.get('/agents') - agentColumns.value = [ - { text: '默认(通用助手)', value: null }, - ...agents.value.map((a: any) => ({ text: `${a.avatar || '🤖'} ${a.name}`, value: a.id })), - ] - } catch {} -} - -function onPickAgent({ selectedOptions }: any) { - const opt = selectedOptions[0] - selectedAgent.value = agents.value.find((a: any) => a.id === opt.value) || null - showAgentPicker.value = false -} - async function createSession() { + if (creating.value) return creating.value = true try { - const s = await client.post('/chat/sessions', { - title: newTitle.value || '新对话', - agent_id: selectedAgent.value?.id ?? null, - }) - showNew.value = false - newTitle.value = '' + // 直接用通用助手,不弹窗 + const s = await client.post('/chat/sessions', { title: '新对话', agent_id: generalAgentId }) router.push(`/chat/${s.id}`) } catch (e: any) { showToast(e.message) @@ -113,8 +74,5 @@ async function createSession() { } } -onMounted(() => { - load() - loadAgents() -}) +onMounted(load) diff --git a/frontend/src/views/ChatRoomView.vue b/frontend/src/views/ChatRoomView.vue index b4ceabe..069ad40 100644 --- a/frontend/src/views/ChatRoomView.vue +++ b/frontend/src/views/ChatRoomView.vue @@ -16,37 +16,47 @@ @@ -56,9 +66,9 @@
- - + + +
+ +
{{ editingMsg ? '更新' : '发送' }}
+ +
🎙️ 正在录音,松开结束并发送
@@ -84,9 +110,6 @@ - - -
@@ -116,14 +139,33 @@ const input = ref('') const sending = ref(false) const streamingId = ref(null) const scrollRef = ref() +const inputRef = ref() const ui = ref({ chat_ui: {} }) const attachments = ref([]) const showAttachSheet = ref(false) const editingMsg = ref(null) const speakingId = ref(null) -const showPreview = ref(false) -const previewList = ref([]) -const previewIndex = ref(0) +const voiceHolding = ref(false) + +let ws: WebSocket | null = null +let titleTimer: number | null = null +let suggTimer: number | null = null +let recorder: any = null + +// UI 开关:配置缺失时默认开启(兜底,保证按钮始终可用) +function uiOn(key: string): boolean { + return (ui.value.chat_ui?.[key] ?? true) !== false +} + +const hasAiTools = computed(() => uiOn('ai_copy') || uiOn('ai_regenerate') || uiOn('ai_feedback') || uiOn('ai_voice')) + +const attachActions = computed(() => { + const types = ui.value.chat_ui?.attachment_types || ['image', 'text'] + const actions: any[] = [] + if (types.includes('image')) actions.push({ name: '上传图片', subname: 'JPG/PNG/GIF/WebP', value: 'image' }) + if (types.includes('text')) actions.push({ name: '上传文本文件', subname: 'TXT/MD/CSV/JSON/代码文件', value: 'text' }) + return actions +}) // 附件图片缓存:file_id -> objectURL const imgCache = new Map() @@ -150,23 +192,6 @@ async function scanImages() { } } -let ws: WebSocket | null = null -let titleTimer: number | null = null -let suggTimer: number | null = null - -const hasAiTools = computed(() => { - const c = ui.value.chat_ui || {} - return c.ai_copy || c.ai_regenerate || c.ai_feedback || c.ai_voice -}) - -const attachActions = computed(() => { - const types = ui.value.chat_ui?.attachment_types || ['image', 'text'] - const actions: any[] = [] - if (types.includes('image')) actions.push({ name: '上传图片', subname: 'JPG/PNG/GIF/WebP', value: 'image' }) - if (types.includes('text')) actions.push({ name: '上传文本文件', subname: 'TXT/MD/CSV/JSON/代码文件', value: 'text' }) - return actions -}) - function renderMd(text: string) { return marked.parse(text || '') } @@ -195,6 +220,7 @@ async function load() { const sessions = await client.get('/chat/sessions') session.value = sessions.find((s: any) => s.id === sessionId.value) || null messages.value = await client.get(`/chat/sessions/${sessionId.value}/messages`) + scanImages() scrollBottom() } catch (e: any) { showToast(e.message) @@ -208,7 +234,6 @@ function send() { if (!content && attachments.value.length === 0) return if (editingMsg.value) { - // 编辑:更新消息内容,然后重新生成 saveEdit(content) return } @@ -221,24 +246,13 @@ function send() { const tempId = -Date.now() messages.value.push({ id: tempId, role: 'user', content, file_ids: fileIds }) - const aiMsg = { id: -Date.now() - 1, role: 'assistant', content: '', suggestions: [] } + const aiMsg = { id: -Date.now() - 1, role: 'assistant', content: '', reasoning: '', suggestions: [], reasoningCollapsed: false } messages.value.push(aiMsg) streamingId.value = aiMsg.id scrollBottom() - if (!ws || ws.readyState !== WebSocket.OPEN) { - ws = new WebSocket(wsUrl(sessionId.value)) - ws.onmessage = (ev) => handleWs(ev, aiMsg) - ws.onopen = () => ws!.send(JSON.stringify({ type: 'chat', content, file_ids: fileIds })) - ws.onerror = () => { - showToast('连接失败,请重试') - sending.value = false - streamingId.value = null - } - ws.onclose = () => { ws = null } - } else { - ws.send(JSON.stringify({ type: 'chat', content, file_ids: fileIds })) - } + const doSend = () => ws!.send(JSON.stringify({ type: 'chat', content, file_ids: fileIds })) + ensureWs(doSend, aiMsg) } async function saveEdit(content: string) { @@ -246,10 +260,9 @@ async function saveEdit(content: string) { sending.value = true try { await client.put(`/chat/messages/${target.id}`, { content }) - // 本地更新 const msg = messages.value.find((m) => m.id === target.id) if (msg) { msg.content = content; msg.edited = true } - // 触发重新生成 + // 保存后自动重新生成(先删旧答再流式) regenerate() } catch (e: any) { showToast(e.message) @@ -257,30 +270,54 @@ async function saveEdit(content: string) { } } +// ---------- 重新生成:先删除原回答,再流式输出 ---------- function regenerate() { - const aiMsg = { id: -Date.now() - 2, role: 'assistant', content: '', suggestions: [] } - messages.value.push(aiMsg) - streamingId.value = aiMsg.id + if (sending.value) return + // 找到最后一条真实 AI 回答 + const last = [...messages.value].reverse().find((m) => m.role === 'assistant' && m.id > 0) + if (!last) return showToast('没有可重新生成的消息') + // 删除原回答:清空内容,从空白开始流式 + last.content = '' + last.reasoning = '' + last.reasoningCollapsed = false + last.suggestions = [] + streamingId.value = last.id sending.value = true editingMsg.value = null scrollBottom() const doSend = () => ws!.send(JSON.stringify({ type: 'regenerate' })) + ensureWs(doSend, last) +} + +function ensureWs(onOpen: () => void, streamMsg: any) { if (!ws || ws.readyState !== WebSocket.OPEN) { ws = new WebSocket(wsUrl(sessionId.value)) - ws.onmessage = (ev) => handleWs(ev, aiMsg) - ws.onopen = doSend - ws.onerror = () => { showToast('连接失败'); sending.value = false; streamingId.value = null } + ws.onmessage = (ev) => handleWs(ev) + ws.onopen = onOpen + ws.onerror = () => { + showToast('连接失败,请重试') + sending.value = false + streamingId.value = null + } ws.onclose = () => { ws = null } } else { - doSend() + onOpen() } } -function handleWs(ev: MessageEvent, aiMsg: any) { +function handleWs(ev: MessageEvent) { const data = JSON.parse(ev.data) if (data.type === 'delta') { - aiMsg.content += data.content + const msg = messages.value.find((m) => m.id === data.message_id) + if (!msg) return + if (data.kind === 'reasoning') { + msg.reasoning = (msg.reasoning || '') + data.content + } else { + // 思考内容输出完毕后自动折叠,再展示正式回答 + if (!msg.content && msg.reasoning && !msg.reasoningCollapsed) msg.reasoningCollapsed = true + msg.content += data.content + } scrollBottom() } else if (data.type === 'done') { streamingId.value = null @@ -293,7 +330,8 @@ function handleWs(ev: MessageEvent, aiMsg: any) { } else if (data.type === 'title') { if (session.value) session.value.title = data.title } else if (data.type === 'error') { - aiMsg.content = `⚠️ ${data.message}` + const msg = messages.value.find((m) => m.id === data.message_id) || messages.value[messages.value.length - 1] + if (msg && msg.role === 'assistant') msg.content = `⚠️ ${data.message}` streamingId.value = null sending.value = false scrollBottom() @@ -303,6 +341,9 @@ function handleWs(ev: MessageEvent, aiMsg: any) { async function refreshMessages() { try { messages.value = await client.get(`/chat/sessions/${sessionId.value}/messages`) + messages.value.forEach((m: any) => { + if (m.reasoning && !m.content && !m.reasoningCollapsed) m.reasoningCollapsed = false + }) const sessions = await client.get('/chat/sessions') session.value = sessions.find((s: any) => s.id === sessionId.value) || session.value scanImages() @@ -311,7 +352,6 @@ async function refreshMessages() { } function scheduleSuggestRefresh() { - // 建议异步生成,稍后刷新一次 if (suggTimer) window.clearTimeout(suggTimer) suggTimer = window.setTimeout(refreshMessages, 4000) titleTimer = window.setTimeout(async () => { @@ -326,6 +366,7 @@ function scheduleSuggestRefresh() { function startEdit(m: any) { editingMsg.value = m input.value = m.content + inputRef.value?.focus?.() scrollBottom() } @@ -346,6 +387,17 @@ async function feedback(m: any, kind: string) { } } +function toggleReasoning(m: any) { + m.reasoningCollapsed = !m.reasoningCollapsed +} + +// 推荐短语:点击填入输入框(不直接发送) +function useSuggestion(s: string) { + input.value = s + inputRef.value?.focus?.() + scrollBottom() +} + // 语音播放(浏览器 TTS) function speak(m: any) { if (!('speechSynthesis' in window)) return showToast('当前浏览器不支持语音播放') @@ -365,23 +417,54 @@ function speak(m: any) { window.speechSynthesis.speak(u) } -// 语音输入 -function startVoice() { +// ---------- 语音输入:长按说话,松开结束并自动提交 ---------- +function startPress() { + if (voiceHolding.value) return const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition if (!SR) return showToast('当前浏览器不支持语音输入(建议用 Chrome/Edge)') + voiceHolding.value = true const rec = new SR() rec.lang = 'zh-CN' rec.interimResults = true - rec.continuous = true + rec.continuous = false + let finalText = '' rec.onresult = (e: any) => { - let text = '' - for (let i = 0; i < e.results.length; i++) text += e.results[i][0].transcript - input.value = text + let interim = '' + for (let i = 0; i < e.results.length; i++) { + if (e.results[i].isFinal) finalText += e.results[i][0].transcript + else interim += e.results[i][0].transcript + } + input.value = (finalText + interim).trim() + } + rec.onend = () => { + voiceHolding.value = false + const text = input.value.trim() + if (text) { + showToast('识别完成,正在发送') + send() + } else { + showToast('未识别到语音') + } + } + rec.onerror = (e: any) => { + voiceHolding.value = false + if (e.error !== 'aborted') showToast(`语音识别失败:${e.error || ''}`) + } + recorder = rec + try { + rec.start() + } catch { + voiceHolding.value = false + } +} + +function endPress() { + if (recorder) { + try { recorder.stop() } catch {} + recorder = null + } else { + voiceHolding.value = false } - rec.onend = () => showToast('语音输入结束') - rec.onerror = (e: any) => showToast(`语音识别失败:${e.error || ''}`) - showToast('请说话…') - rec.start() } // ---------- 附件 ---------- @@ -396,7 +479,6 @@ function onAttachSelect(action: any) { if (!file) return if (file.size > 20 * 1024 * 1024) return showToast('文件不能超过 20MB') const preview = isImage ? URL.createObjectURL(file) : '' - // 先展示,再上传 const item = { name: file.name, type: isImage ? 'image' : 'text', preview, fileId: 0, uploading: true } attachments.value.push(item) try { @@ -413,10 +495,6 @@ function onAttachSelect(action: any) { fileInput.click() } -function imageUrls(m: any) { - return (m.file_ids || []).map((id: number) => ({ id, url: '' })).filter(() => false) -} - async function previewImg(url: string) { showImagePreview([url]) } @@ -458,6 +536,13 @@ async function delSession() { .msg-tools .van-icon.active { color: #1989fa; } .msg-time { font-size: 11px; color: #c8c9cc; } .typing { display: flex; align-items: center; gap: 6px; color: #969799; font-size: 12px; margin-top: 4px; } +/* 思考过程 */ +.reasoning-box { background: #f7f8fa; border-radius: 8px; margin-bottom: 8px; font-size: 12.5px; } +.reasoning-box.collapsed .reasoning-content { display: none; } +.reasoning-header { display: flex; align-items: center; gap: 6px; padding: 6px 10px; color: #969799; cursor: pointer; user-select: none; } +.reasoning-hint { margin-left: auto; font-size: 11px; color: #c8c9cc; } +.reasoning-content { padding: 0 10px 8px; color: #646566; white-space: pre-wrap; max-height: 200px; overflow-y: auto; } +/* 推荐短语 */ .sugg-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .sugg-chip { background: #e8f3ff; color: #1989fa; font-size: 12px; padding: 4px 10px; border-radius: 14px; cursor: pointer; } .attach-preview { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } @@ -466,6 +551,10 @@ async function delSession() { .input-row { display: flex; align-items: flex-end; gap: 8px; max-width: 860px; margin: 0 auto; } .input-icon { color: #646566; flex-shrink: 0; margin-bottom: 8px; } .input-row .van-field { flex: 1; background: #f7f8fa; border-radius: 8px; padding: 4px 10px; } +/* 语音按钮:长按说话 */ +.voice-btn { flex-shrink: 0; margin-bottom: 8px; color: #646566; border-radius: 50%; padding: 4px; display: flex; align-items: center; justify-content: center; transition: all .15s; } +.voice-btn.holding { color: #fff; background: #ee0a24; transform: scale(1.15); box-shadow: 0 2px 12px rgba(238, 10, 36, .4); } +.voice-tip { text-align: center; font-size: 12px; color: #ee0a24; padding-top: 4px; } .attach-list { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 4px 0; max-width: 860px; margin: 0 auto; } .attach-item { display: flex; align-items: center; gap: 6px; background: #f7f8fa; border-radius: 8px; padding: 4px 8px; font-size: 12px; position: relative; } .attach-thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; }