1 Commits
8 changed files with 265 additions and 160 deletions
+8 -1
View File
@@ -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
+5 -5
View File
@@ -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)})
+34 -4
View File
@@ -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:
+1
View File
@@ -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():
+2
View File
@@ -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)
# 重新生成次数
+32 -14
View File
@@ -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()
+11 -53
View File
@@ -2,7 +2,7 @@
<div>
<van-nav-bar title="对话" fixed placeholder>
<template #right>
<van-icon name="plus" size="22" @click="showNew = true" />
<van-icon name="plus" size="22" @click="createSession" />
</template>
</van-nav-bar>
@@ -23,26 +23,6 @@
</van-cell>
</van-cell-group>
</van-pull-refresh>
<van-popup v-model:show="showNew" position="bottom" round safe-area-inset-bottom>
<div style="padding: 20px 16px 28px">
<h3 style="margin-bottom: 12px">新建会话</h3>
<van-field v-model="newTitle" label="标题" placeholder="新对话" />
<van-field label="智能体" readonly :model-value="selectedAgent?.name || '默认通用助手'" is-link @click="showAgentPicker = true" />
<div style="margin-top: 20px; display: flex; gap: 12px">
<van-button block plain type="primary" @click="showNew = false">取消</van-button>
<van-button block type="primary" :loading="creating" @click="createSession">创建</van-button>
</div>
</div>
</van-popup>
<van-popup v-model:show="showAgentPicker" position="bottom" round safe-area-inset-bottom>
<van-picker
:columns="agentColumns"
@confirm="onPickAgent"
@cancel="showAgentPicker = false"
/>
</van-popup>
</div>
</template>
@@ -54,15 +34,10 @@ import client from '../api/client'
const router = useRouter()
const sessions = ref<any[]>([])
const agents = ref<any[]>([])
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<any>(null)
const agentColumns = ref<any[]>([])
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)
</script>
+172 -83
View File
@@ -16,37 +16,47 @@
<template v-if="m.role === 'user'">
<div class="bubble">
<div class="plain-text">{{ m.content }}</div>
<!-- 附件图片 -->
<div v-if="m.file_ids?.length" class="attach-preview">
<img v-for="id in m.file_ids" :key="id" v-if="imgUrlFor(id)" :src="imgUrlFor(id)" class="attach-img" @click="previewImg(imgUrlFor(id))" />
</div>
<div v-if="m.edited" class="edited-tag">已编辑</div>
</div>
<div v-if="ui.chat_ui?.user_edit || ui.chat_ui?.user_copy || ui.chat_ui?.user_time" class="msg-tools user-tools">
<van-icon v-if="ui.chat_ui?.user_edit" name="edit" @click="startEdit(m)" />
<van-icon v-if="ui.chat_ui?.user_copy" name="copy" @click="copyText(m.content)" />
<span v-if="ui.chat_ui?.user_time" class="msg-time">{{ fmtTime(m.created_at) }}</span>
<div v-if="uiOn('user_edit') || uiOn('user_copy') || uiOn('user_time')" class="msg-tools user-tools">
<van-icon v-if="uiOn('user_edit')" name="edit" @click="startEdit(m)" />
<van-icon v-if="uiOn('user_copy')" name="copy" @click="copyText(m.content)" />
<span v-if="uiOn('user_time')" class="msg-time">{{ fmtTime(m.created_at) }}</span>
</div>
</template>
<!-- AI 回答 -->
<template v-else>
<div class="bubble">
<div class="markdown-body" v-html="renderMd(m.content)"></div>
<div v-if="m.id === streamingId" class="typing">
<van-loading size="14" /> 正在思考
<!-- 思考过程流式展开输出完毕后折叠 -->
<div v-if="m.reasoning" class="reasoning-box" :class="{ collapsed: m.reasoningCollapsed }">
<div class="reasoning-header" @click="toggleReasoning(m)">
<van-icon :name="m.reasoningCollapsed ? 'arrow-down' : 'arrow-up'" />
<span>💭 思考过程</span>
<span class="reasoning-hint">{{ m.reasoningCollapsed ? '点击展开' : '点击收起' }}</span>
</div>
<div v-if="!m.reasoningCollapsed" class="reasoning-content">{{ m.reasoning }}</div>
</div>
<div class="markdown-body" v-html="renderMd(m.content)"></div>
<div v-if="m.id === streamingId && !m.content" class="typing">
<van-loading size="14" /> {{ m.reasoning ? '思考中…' : '正在思考…' }}
</div>
<!-- 推荐短语 -->
<div v-if="ui.chat_ui?.ai_suggestions && m.suggestions?.length && m.id !== streamingId" class="sugg-chips">
<div v-for="s in m.suggestions" :key="s" class="sugg-chip" @click="quickSend(s)">{{ s }}</div>
<div v-if="uiOn('ai_suggestions') && m.suggestions?.length && m.id !== streamingId" class="sugg-chips">
<div v-for="s in m.suggestions" :key="s" class="sugg-chip" @click="useSuggestion(s)">{{ s }}</div>
</div>
</div>
<div v-if="hasAiTools" class="msg-tools ai-tools">
<van-icon v-if="ui.chat_ui?.ai_copy" name="copy" @click="copyText(m.content)" />
<van-icon v-if="ui.chat_ui?.ai_regenerate" name="replay" @click="regenerate()" />
<van-icon v-if="ui.chat_ui?.ai_feedback" name="good-job" :class="{ active: m.feedback === 'like' }" @click="feedback(m, 'like')" />
<van-icon v-if="ui.chat_ui?.ai_feedback" name="bad-job" :class="{ active: m.feedback === 'dislike' }" @click="feedback(m, 'dislike')" />
<van-icon v-if="ui.chat_ui?.ai_voice" :name="speakingId === m.id ? 'volume-o' : 'volume'" @click="speak(m)" />
<van-icon v-if="uiOn('ai_copy')" name="copy" @click="copyText(m.content)" />
<van-icon v-if="uiOn('ai_regenerate')" name="replay" @click="regenerate()" />
<van-icon v-if="uiOn('ai_feedback')" name="good-job" :class="{ active: m.feedback === 'like' }" @click="feedback(m, 'like')" />
<van-icon v-if="uiOn('ai_feedback')" name="bad-job" :class="{ active: m.feedback === 'dislike' }" @click="feedback(m, 'dislike')" />
<van-icon v-if="uiOn('ai_voice')" :name="speakingId === m.id ? 'volume-o' : 'volume'" @click="speak(m)" />
</div>
</template>
</div>
@@ -56,9 +66,9 @@
<!-- 输入区 -->
<div class="input-bar">
<div class="input-row">
<van-icon v-if="ui.chat_ui?.voice_input" name="audio" size="22" class="input-icon" @click="startVoice" />
<van-icon v-if="ui.chat_ui?.attachment" name="plus" size="22" class="input-icon" @click="showAttachSheet = true" />
<van-icon v-if="uiOn('attachment')" name="plus" size="22" class="input-icon" @click="showAttachSheet = true" />
<van-field
ref="inputRef"
v-model="input"
type="textarea"
autosize
@@ -67,10 +77,26 @@
:placeholder="editingMsg ? '编辑消息…(保存后自动重新生成回答)' : '输入消息…'"
@keydown.enter.exact.prevent="send"
/>
<!-- 语音长按说话松开结束并自动提交 -->
<div
v-if="uiOn('voice_input')"
class="voice-btn"
:class="{ holding: voiceHolding }"
@touchstart.prevent="startPress"
@touchend="endPress"
@touchcancel="endPress"
@mousedown.prevent="startPress"
@mouseup="endPress"
@mouseleave="endPress"
>
<van-icon name="audio" size="22" />
</div>
<van-button type="primary" :disabled="sending || (!input.trim() && attachments.length === 0)" @click="send">
{{ editingMsg ? '更新' : '发送' }}
</van-button>
</div>
<!-- 录音提示 -->
<div v-if="voiceHolding" class="voice-tip">🎙 正在录音松开结束并发送</div>
<!-- 已选附件 -->
<div v-if="attachments.length" class="attach-list">
<div v-for="(a, i) in attachments" :key="i" class="attach-item">
@@ -84,9 +110,6 @@
<!-- 附件类型选择 -->
<van-action-sheet v-model:show="showAttachSheet" :actions="attachActions" cancel-text="取消" @select="onAttachSelect" />
<!-- 图片预览 -->
<van-image-preview v-model:show="showPreview" :images="previewList" :start-position="previewIndex" />
</div>
</template>
@@ -116,14 +139,33 @@ const input = ref('')
const sending = ref(false)
const streamingId = ref<number | null>(null)
const scrollRef = ref<HTMLElement>()
const inputRef = ref<any>()
const ui = ref<any>({ chat_ui: {} })
const attachments = ref<any[]>([])
const showAttachSheet = ref(false)
const editingMsg = ref<any>(null)
const speakingId = ref<number | null>(null)
const showPreview = ref(false)
const previewList = ref<string[]>([])
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<number, string>()
@@ -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; }