v0.2.1 修复对话体验:1)按钮兜底显示(配置加载失败也默认全开) 2)重新生成先删除旧回答再流式输出 3)推荐短语点击填入输入框 4)思考模型reasoning流式输出+自动折叠 5)语音输入改长按录音松开提交 6)新建对话直接用通用助手不弹窗
This commit is contained in:
@@ -34,6 +34,7 @@ def _message_out(m: ChatMessage) -> dict:
|
|||||||
"id": m.id, "session_id": m.session_id, "role": m.role, "content": m.content,
|
"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 "",
|
"model": m.model, "file_ids": file_ids, "feedback": m.feedback or "",
|
||||||
"suggestions": suggestions, "edited": bool(m.edited), "regenerated": m.regenerated or 0,
|
"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(),
|
"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 流式)。"""
|
"""REST 非流式重新生成最后一条 AI 回答(备用,前端主要走 WS 流式)。"""
|
||||||
s = _get_owned_session(db, user, session_id)
|
s = _get_owned_session(db, user, session_id)
|
||||||
try:
|
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 # 流式丢弃,最终内容已入库
|
pass # 流式丢弃,最终内容已入库
|
||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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}")
|
raise HTTPException(status_code=502, detail=f"模型调用失败:{e}")
|
||||||
m = db.get(ChatMessage, message_id)
|
m = db.get(ChatMessage, message_id)
|
||||||
return ok(_message_out(m))
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""WebSocket 路由:流式对话 + 重新生成。协议:
|
"""WebSocket 路由:流式对话 + 重新生成。协议:
|
||||||
客户端 → {"type":"chat","content":"...","agent_id":null,"model":"","file_ids":[]}
|
客户端 → {"type":"chat","content":"...","agent_id":null,"model":"","file_ids":[]}
|
||||||
{"type":"regenerate"}
|
{"type":"regenerate"}
|
||||||
服务端 → {"type":"delta","message_id":1,"content":"增量文本"}
|
服务端 → {"type":"delta","message_id":1,"kind":"reasoning"|"content","content":"增量文本"}
|
||||||
{"type":"done","message_id":1}
|
{"type":"done","message_id":1}
|
||||||
{"type":"suggestions","message_id":1,"items":["..",".."]}
|
{"type":"suggestions","message_id":1,"items":["..",".."]}
|
||||||
{"type":"title","title":"..."}
|
{"type":"title","title":"..."}
|
||||||
@@ -70,20 +70,20 @@ async def chat_ws(websocket: WebSocket):
|
|||||||
await websocket.send_json({"type": "error", "message": "消息不能为空"})
|
await websocket.send_json({"type": "error", "message": "消息不能为空"})
|
||||||
continue
|
continue
|
||||||
try:
|
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,
|
db, user, session, content,
|
||||||
agent_id=data.get("agent_id"), model=data.get("model", ""),
|
agent_id=data.get("agent_id"), model=data.get("model", ""),
|
||||||
file_ids=file_ids,
|
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})
|
await websocket.send_json({"type": "done", "message_id": message_id})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await websocket.send_json({"type": "error", "message": f"模型调用失败:{e}"})
|
await websocket.send_json({"type": "error", "message": f"模型调用失败:{e}"})
|
||||||
|
|
||||||
elif msg_type == "regenerate":
|
elif msg_type == "regenerate":
|
||||||
try:
|
try:
|
||||||
async for delta, message_id in chat_service.regenerate_stream(db, session):
|
async for (kind, delta), message_id in chat_service.regenerate_stream(db, session):
|
||||||
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})
|
await websocket.send_json({"type": "done", "message_id": message_id})
|
||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
await websocket.send_json({"type": "error", "message": str(e)})
|
await websocket.send_json({"type": "error", "message": str(e)})
|
||||||
|
|||||||
+34
-4
@@ -41,8 +41,10 @@ async def chat_completion_stream(
|
|||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
) -> AsyncIterator[str]:
|
):
|
||||||
"""流式对话补全:逐段产出增量文本。"""
|
"""流式对话补全:逐段产出增量。
|
||||||
|
产出 (kind, text):kind="reasoning" 思考内容 / kind="content" 回答内容。
|
||||||
|
"""
|
||||||
client = _client_for(resolve_model(model))
|
client = _client_for(resolve_model(model))
|
||||||
stream = await client.chat.completions.create(
|
stream = await client.chat.completions.create(
|
||||||
model=resolve_model(model),
|
model=resolve_model(model),
|
||||||
@@ -52,8 +54,36 @@ async def chat_completion_stream(
|
|||||||
stream=True,
|
stream=True,
|
||||||
)
|
)
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
|
if not chunk.choices or not chunk.choices[0].delta:
|
||||||
yield chunk.choices[0].delta.content
|
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:
|
async def vision_analysis(prompt: str, image_urls: list[str], model: Optional[str] = None) -> str:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ def migrate(db):
|
|||||||
"suggestions": "TEXT DEFAULT '[]'",
|
"suggestions": "TEXT DEFAULT '[]'",
|
||||||
"edited": "BOOLEAN DEFAULT 0",
|
"edited": "BOOLEAN DEFAULT 0",
|
||||||
"regenerated": "INTEGER DEFAULT 0",
|
"regenerated": "INTEGER DEFAULT 0",
|
||||||
|
"reasoning_content": "TEXT DEFAULT ''",
|
||||||
"updated_at": "DATETIME",
|
"updated_at": "DATETIME",
|
||||||
}
|
}
|
||||||
for name, ddl in additions.items():
|
for name, ddl in additions.items():
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ class ChatMessage(Base):
|
|||||||
feedback: Mapped[str] = mapped_column(String(8), default="")
|
feedback: Mapped[str] = mapped_column(String(8), default="")
|
||||||
# 推荐短语(JSON 数组,AI 回答后生成 1-3 条)
|
# 推荐短语(JSON 数组,AI 回答后生成 1-3 条)
|
||||||
suggestions: Mapped[str] = mapped_column(Text, default="[]")
|
suggestions: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
# 思考内容(思考模型的 reasoning 流式输出)
|
||||||
|
reasoning_content: Mapped[str] = mapped_column(Text, default="")
|
||||||
# 用户消息是否被编辑过
|
# 用户消息是否被编辑过
|
||||||
edited: Mapped[bool] = mapped_column(Boolean, default=False)
|
edited: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
# 重新生成次数
|
# 重新生成次数
|
||||||
|
|||||||
@@ -220,9 +220,11 @@ async def chat_once(db: Session, user: User, session: ChatSession, content: str,
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
messages = build_messages(db, session, content, file_ids)
|
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)
|
db.add(reply)
|
||||||
session.updated_at = datetime.utcnow()
|
session.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -245,25 +247,32 @@ async def chat_stream(db: Session, user: User, session: ChatSession, content: st
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
messages = build_messages(db, session, content, file_ids)
|
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.add(reply)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(reply)
|
db.refresh(reply)
|
||||||
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
try:
|
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
|
messages, model=session.model, temperature=agent.temperature if agent else 0.7
|
||||||
):
|
):
|
||||||
parts.append(delta)
|
if kind == "reasoning":
|
||||||
yield delta, reply.id
|
reasoning_parts.append(delta)
|
||||||
|
yield ("reasoning", delta), reply.id
|
||||||
|
else:
|
||||||
|
parts.append(delta)
|
||||||
|
yield ("content", delta), reply.id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
reply.content = "".join(parts) or f"(调用失败:{e})"
|
reply.content = "".join(parts) or f"(调用失败:{e})"
|
||||||
|
reply.reasoning_content = "".join(reasoning_parts)
|
||||||
session.updated_at = datetime.utcnow()
|
session.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
reply.content = "".join(parts)
|
reply.content = "".join(parts)
|
||||||
|
reply.reasoning_content = "".join(reasoning_parts)
|
||||||
session.updated_at = datetime.utcnow()
|
session.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
_fire_background_jobs(db, session, content, reply.id, reply.content)
|
_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):
|
async def regenerate_stream(db: Session, session: ChatSession):
|
||||||
"""重新生成最后一条 AI 回答(流式)。返回 (message_id, async_iter)。"""
|
"""重新生成最后一条 AI 回答(流式):先删除旧回答内容,再流式输出。"""
|
||||||
last = (
|
last = (
|
||||||
db.query(ChatMessage)
|
db.query(ChatMessage)
|
||||||
.filter(ChatMessage.session_id == session.id, ChatMessage.role == "assistant")
|
.filter(ChatMessage.session_id == session.id, ChatMessage.role == "assistant")
|
||||||
@@ -286,27 +295,36 @@ async def regenerate_stream(db: Session, session: ChatSession):
|
|||||||
if not last:
|
if not last:
|
||||||
raise LookupError("没有可重新生成的消息")
|
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 = "[]"
|
last.suggestions = "[]"
|
||||||
db.commit()
|
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] = []
|
parts: list[str] = []
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
try:
|
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
|
messages, model=session.model, temperature=agent.temperature if agent else 0.7
|
||||||
):
|
):
|
||||||
parts.append(delta)
|
if kind == "reasoning":
|
||||||
yield delta, last.id
|
reasoning_parts.append(delta)
|
||||||
|
yield ("reasoning", delta), last.id
|
||||||
|
else:
|
||||||
|
parts.append(delta)
|
||||||
|
yield ("content", delta), last.id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last.content = "".join(parts) or f"(调用失败:{e})"
|
last.content = "".join(parts) or f"(调用失败:{e})"
|
||||||
|
last.reasoning_content = "".join(reasoning_parts)
|
||||||
last.regenerated += 1
|
last.regenerated += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
last.content = "".join(parts)
|
last.content = "".join(parts)
|
||||||
|
last.reasoning_content = "".join(reasoning_parts)
|
||||||
last.regenerated += 1
|
last.regenerated += 1
|
||||||
session.updated_at = datetime.utcnow()
|
session.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<van-nav-bar title="对话" fixed placeholder>
|
<van-nav-bar title="对话" fixed placeholder>
|
||||||
<template #right>
|
<template #right>
|
||||||
<van-icon name="plus" size="22" @click="showNew = true" />
|
<van-icon name="plus" size="22" @click="createSession" />
|
||||||
</template>
|
</template>
|
||||||
</van-nav-bar>
|
</van-nav-bar>
|
||||||
|
|
||||||
@@ -23,26 +23,6 @@
|
|||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
</van-pull-refresh>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -54,15 +34,10 @@ import client from '../api/client'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const sessions = ref<any[]>([])
|
const sessions = ref<any[]>([])
|
||||||
const agents = ref<any[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const showNew = ref(false)
|
|
||||||
const showAgentPicker = ref(false)
|
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const newTitle = ref('')
|
let generalAgentId: number | null = null
|
||||||
const selectedAgent = ref<any>(null)
|
|
||||||
const agentColumns = ref<any[]>([])
|
|
||||||
|
|
||||||
function timeText(iso: string) {
|
function timeText(iso: string) {
|
||||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
sessions.value = await client.get('/chat/sessions')
|
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) {
|
} catch (e: any) {
|
||||||
showToast(e.message)
|
showToast(e.message)
|
||||||
} finally {
|
} 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() {
|
async function createSession() {
|
||||||
|
if (creating.value) return
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
const s = await client.post('/chat/sessions', {
|
// 直接用通用助手,不弹窗
|
||||||
title: newTitle.value || '新对话',
|
const s = await client.post('/chat/sessions', { title: '新对话', agent_id: generalAgentId })
|
||||||
agent_id: selectedAgent.value?.id ?? null,
|
|
||||||
})
|
|
||||||
showNew.value = false
|
|
||||||
newTitle.value = ''
|
|
||||||
router.push(`/chat/${s.id}`)
|
router.push(`/chat/${s.id}`)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showToast(e.message)
|
showToast(e.message)
|
||||||
@@ -113,8 +74,5 @@ async function createSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(load)
|
||||||
load()
|
|
||||||
loadAgents()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
@@ -16,37 +16,47 @@
|
|||||||
<template v-if="m.role === 'user'">
|
<template v-if="m.role === 'user'">
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
<div class="plain-text">{{ m.content }}</div>
|
<div class="plain-text">{{ m.content }}</div>
|
||||||
<!-- 附件图片 -->
|
|
||||||
<div v-if="m.file_ids?.length" class="attach-preview">
|
<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))" />
|
<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>
|
||||||
<div v-if="m.edited" class="edited-tag">已编辑</div>
|
<div v-if="m.edited" class="edited-tag">已编辑</div>
|
||||||
</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">
|
<div v-if="uiOn('user_edit') || uiOn('user_copy') || uiOn('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="uiOn('user_edit')" name="edit" @click="startEdit(m)" />
|
||||||
<van-icon v-if="ui.chat_ui?.user_copy" name="copy" @click="copyText(m.content)" />
|
<van-icon v-if="uiOn('user_copy')" name="copy" @click="copyText(m.content)" />
|
||||||
<span v-if="ui.chat_ui?.user_time" class="msg-time">{{ fmtTime(m.created_at) }}</span>
|
<span v-if="uiOn('user_time')" class="msg-time">{{ fmtTime(m.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- AI 回答 -->
|
<!-- AI 回答 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
<div class="markdown-body" v-html="renderMd(m.content)"></div>
|
<!-- 思考过程:流式展开,输出完毕后折叠 -->
|
||||||
<div v-if="m.id === streamingId" class="typing">
|
<div v-if="m.reasoning" class="reasoning-box" :class="{ collapsed: m.reasoningCollapsed }">
|
||||||
<van-loading size="14" /> 正在思考…
|
<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>
|
||||||
|
|
||||||
|
<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-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="quickSend(s)">{{ s }}</div>
|
<div v-for="s in m.suggestions" :key="s" class="sugg-chip" @click="useSuggestion(s)">{{ s }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasAiTools" class="msg-tools ai-tools">
|
<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="uiOn('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="uiOn('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="uiOn('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="uiOn('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_voice')" :name="speakingId === m.id ? 'volume-o' : 'volume'" @click="speak(m)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,9 +66,9 @@
|
|||||||
<!-- 输入区 -->
|
<!-- 输入区 -->
|
||||||
<div class="input-bar">
|
<div class="input-bar">
|
||||||
<div class="input-row">
|
<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="uiOn('attachment')" name="plus" size="22" class="input-icon" @click="showAttachSheet = true" />
|
||||||
<van-icon v-if="ui.chat_ui?.attachment" name="plus" size="22" class="input-icon" @click="showAttachSheet = true" />
|
|
||||||
<van-field
|
<van-field
|
||||||
|
ref="inputRef"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
autosize
|
autosize
|
||||||
@@ -67,10 +77,26 @@
|
|||||||
:placeholder="editingMsg ? '编辑消息…(保存后自动重新生成回答)' : '输入消息…'"
|
:placeholder="editingMsg ? '编辑消息…(保存后自动重新生成回答)' : '输入消息…'"
|
||||||
@keydown.enter.exact.prevent="send"
|
@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">
|
<van-button type="primary" :disabled="sending || (!input.trim() && attachments.length === 0)" @click="send">
|
||||||
{{ editingMsg ? '更新' : '发送' }}
|
{{ editingMsg ? '更新' : '发送' }}
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 录音提示 -->
|
||||||
|
<div v-if="voiceHolding" class="voice-tip">🎙️ 正在录音,松开结束并发送</div>
|
||||||
<!-- 已选附件 -->
|
<!-- 已选附件 -->
|
||||||
<div v-if="attachments.length" class="attach-list">
|
<div v-if="attachments.length" class="attach-list">
|
||||||
<div v-for="(a, i) in attachments" :key="i" class="attach-item">
|
<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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -116,14 +139,33 @@ const input = ref('')
|
|||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const streamingId = ref<number | null>(null)
|
const streamingId = ref<number | null>(null)
|
||||||
const scrollRef = ref<HTMLElement>()
|
const scrollRef = ref<HTMLElement>()
|
||||||
|
const inputRef = ref<any>()
|
||||||
const ui = ref<any>({ chat_ui: {} })
|
const ui = ref<any>({ chat_ui: {} })
|
||||||
const attachments = ref<any[]>([])
|
const attachments = ref<any[]>([])
|
||||||
const showAttachSheet = ref(false)
|
const showAttachSheet = ref(false)
|
||||||
const editingMsg = ref<any>(null)
|
const editingMsg = ref<any>(null)
|
||||||
const speakingId = ref<number | null>(null)
|
const speakingId = ref<number | null>(null)
|
||||||
const showPreview = ref(false)
|
const voiceHolding = ref(false)
|
||||||
const previewList = ref<string[]>([])
|
|
||||||
const previewIndex = ref(0)
|
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
|
// 附件图片缓存:file_id -> objectURL
|
||||||
const imgCache = new Map<number, string>()
|
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) {
|
function renderMd(text: string) {
|
||||||
return marked.parse(text || '')
|
return marked.parse(text || '')
|
||||||
}
|
}
|
||||||
@@ -195,6 +220,7 @@ async function load() {
|
|||||||
const sessions = await client.get('/chat/sessions')
|
const sessions = await client.get('/chat/sessions')
|
||||||
session.value = sessions.find((s: any) => s.id === sessionId.value) || null
|
session.value = sessions.find((s: any) => s.id === sessionId.value) || null
|
||||||
messages.value = await client.get(`/chat/sessions/${sessionId.value}/messages`)
|
messages.value = await client.get(`/chat/sessions/${sessionId.value}/messages`)
|
||||||
|
scanImages()
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showToast(e.message)
|
showToast(e.message)
|
||||||
@@ -208,7 +234,6 @@ function send() {
|
|||||||
if (!content && attachments.value.length === 0) return
|
if (!content && attachments.value.length === 0) return
|
||||||
|
|
||||||
if (editingMsg.value) {
|
if (editingMsg.value) {
|
||||||
// 编辑:更新消息内容,然后重新生成
|
|
||||||
saveEdit(content)
|
saveEdit(content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -221,24 +246,13 @@ function send() {
|
|||||||
|
|
||||||
const tempId = -Date.now()
|
const tempId = -Date.now()
|
||||||
messages.value.push({ id: tempId, role: 'user', content, file_ids: fileIds })
|
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)
|
messages.value.push(aiMsg)
|
||||||
streamingId.value = aiMsg.id
|
streamingId.value = aiMsg.id
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
const doSend = () => ws!.send(JSON.stringify({ type: 'chat', content, file_ids: fileIds }))
|
||||||
ws = new WebSocket(wsUrl(sessionId.value))
|
ensureWs(doSend, aiMsg)
|
||||||
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 }))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit(content: string) {
|
async function saveEdit(content: string) {
|
||||||
@@ -246,10 +260,9 @@ async function saveEdit(content: string) {
|
|||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
await client.put(`/chat/messages/${target.id}`, { content })
|
await client.put(`/chat/messages/${target.id}`, { content })
|
||||||
// 本地更新
|
|
||||||
const msg = messages.value.find((m) => m.id === target.id)
|
const msg = messages.value.find((m) => m.id === target.id)
|
||||||
if (msg) { msg.content = content; msg.edited = true }
|
if (msg) { msg.content = content; msg.edited = true }
|
||||||
// 触发重新生成
|
// 保存后自动重新生成(先删旧答再流式)
|
||||||
regenerate()
|
regenerate()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showToast(e.message)
|
showToast(e.message)
|
||||||
@@ -257,30 +270,54 @@ async function saveEdit(content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 重新生成:先删除原回答,再流式输出 ----------
|
||||||
function regenerate() {
|
function regenerate() {
|
||||||
const aiMsg = { id: -Date.now() - 2, role: 'assistant', content: '', suggestions: [] }
|
if (sending.value) return
|
||||||
messages.value.push(aiMsg)
|
// 找到最后一条真实 AI 回答
|
||||||
streamingId.value = aiMsg.id
|
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
|
sending.value = true
|
||||||
editingMsg.value = null
|
editingMsg.value = null
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
|
|
||||||
const doSend = () => ws!.send(JSON.stringify({ type: 'regenerate' }))
|
const doSend = () => ws!.send(JSON.stringify({ type: 'regenerate' }))
|
||||||
|
ensureWs(doSend, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWs(onOpen: () => void, streamMsg: any) {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
ws = new WebSocket(wsUrl(sessionId.value))
|
ws = new WebSocket(wsUrl(sessionId.value))
|
||||||
ws.onmessage = (ev) => handleWs(ev, aiMsg)
|
ws.onmessage = (ev) => handleWs(ev)
|
||||||
ws.onopen = doSend
|
ws.onopen = onOpen
|
||||||
ws.onerror = () => { showToast('连接失败'); sending.value = false; streamingId.value = null }
|
ws.onerror = () => {
|
||||||
|
showToast('连接失败,请重试')
|
||||||
|
sending.value = false
|
||||||
|
streamingId.value = null
|
||||||
|
}
|
||||||
ws.onclose = () => { ws = null }
|
ws.onclose = () => { ws = null }
|
||||||
} else {
|
} else {
|
||||||
doSend()
|
onOpen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWs(ev: MessageEvent, aiMsg: any) {
|
function handleWs(ev: MessageEvent) {
|
||||||
const data = JSON.parse(ev.data)
|
const data = JSON.parse(ev.data)
|
||||||
if (data.type === 'delta') {
|
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()
|
scrollBottom()
|
||||||
} else if (data.type === 'done') {
|
} else if (data.type === 'done') {
|
||||||
streamingId.value = null
|
streamingId.value = null
|
||||||
@@ -293,7 +330,8 @@ function handleWs(ev: MessageEvent, aiMsg: any) {
|
|||||||
} else if (data.type === 'title') {
|
} else if (data.type === 'title') {
|
||||||
if (session.value) session.value.title = data.title
|
if (session.value) session.value.title = data.title
|
||||||
} else if (data.type === 'error') {
|
} 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
|
streamingId.value = null
|
||||||
sending.value = false
|
sending.value = false
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
@@ -303,6 +341,9 @@ function handleWs(ev: MessageEvent, aiMsg: any) {
|
|||||||
async function refreshMessages() {
|
async function refreshMessages() {
|
||||||
try {
|
try {
|
||||||
messages.value = await client.get(`/chat/sessions/${sessionId.value}/messages`)
|
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')
|
const sessions = await client.get('/chat/sessions')
|
||||||
session.value = sessions.find((s: any) => s.id === sessionId.value) || session.value
|
session.value = sessions.find((s: any) => s.id === sessionId.value) || session.value
|
||||||
scanImages()
|
scanImages()
|
||||||
@@ -311,7 +352,6 @@ async function refreshMessages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSuggestRefresh() {
|
function scheduleSuggestRefresh() {
|
||||||
// 建议异步生成,稍后刷新一次
|
|
||||||
if (suggTimer) window.clearTimeout(suggTimer)
|
if (suggTimer) window.clearTimeout(suggTimer)
|
||||||
suggTimer = window.setTimeout(refreshMessages, 4000)
|
suggTimer = window.setTimeout(refreshMessages, 4000)
|
||||||
titleTimer = window.setTimeout(async () => {
|
titleTimer = window.setTimeout(async () => {
|
||||||
@@ -326,6 +366,7 @@ function scheduleSuggestRefresh() {
|
|||||||
function startEdit(m: any) {
|
function startEdit(m: any) {
|
||||||
editingMsg.value = m
|
editingMsg.value = m
|
||||||
input.value = m.content
|
input.value = m.content
|
||||||
|
inputRef.value?.focus?.()
|
||||||
scrollBottom()
|
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)
|
// 语音播放(浏览器 TTS)
|
||||||
function speak(m: any) {
|
function speak(m: any) {
|
||||||
if (!('speechSynthesis' in window)) return showToast('当前浏览器不支持语音播放')
|
if (!('speechSynthesis' in window)) return showToast('当前浏览器不支持语音播放')
|
||||||
@@ -365,23 +417,54 @@ function speak(m: any) {
|
|||||||
window.speechSynthesis.speak(u)
|
window.speechSynthesis.speak(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语音输入
|
// ---------- 语音输入:长按说话,松开结束并自动提交 ----------
|
||||||
function startVoice() {
|
function startPress() {
|
||||||
|
if (voiceHolding.value) return
|
||||||
const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||||
if (!SR) return showToast('当前浏览器不支持语音输入(建议用 Chrome/Edge)')
|
if (!SR) return showToast('当前浏览器不支持语音输入(建议用 Chrome/Edge)')
|
||||||
|
voiceHolding.value = true
|
||||||
const rec = new SR()
|
const rec = new SR()
|
||||||
rec.lang = 'zh-CN'
|
rec.lang = 'zh-CN'
|
||||||
rec.interimResults = true
|
rec.interimResults = true
|
||||||
rec.continuous = true
|
rec.continuous = false
|
||||||
|
let finalText = ''
|
||||||
rec.onresult = (e: any) => {
|
rec.onresult = (e: any) => {
|
||||||
let text = ''
|
let interim = ''
|
||||||
for (let i = 0; i < e.results.length; i++) text += e.results[i][0].transcript
|
for (let i = 0; i < e.results.length; i++) {
|
||||||
input.value = text
|
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) return
|
||||||
if (file.size > 20 * 1024 * 1024) return showToast('文件不能超过 20MB')
|
if (file.size > 20 * 1024 * 1024) return showToast('文件不能超过 20MB')
|
||||||
const preview = isImage ? URL.createObjectURL(file) : ''
|
const preview = isImage ? URL.createObjectURL(file) : ''
|
||||||
// 先展示,再上传
|
|
||||||
const item = { name: file.name, type: isImage ? 'image' : 'text', preview, fileId: 0, uploading: true }
|
const item = { name: file.name, type: isImage ? 'image' : 'text', preview, fileId: 0, uploading: true }
|
||||||
attachments.value.push(item)
|
attachments.value.push(item)
|
||||||
try {
|
try {
|
||||||
@@ -413,10 +495,6 @@ function onAttachSelect(action: any) {
|
|||||||
fileInput.click()
|
fileInput.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrls(m: any) {
|
|
||||||
return (m.file_ids || []).map((id: number) => ({ id, url: '' })).filter(() => false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewImg(url: string) {
|
async function previewImg(url: string) {
|
||||||
showImagePreview([url])
|
showImagePreview([url])
|
||||||
}
|
}
|
||||||
@@ -458,6 +536,13 @@ async function delSession() {
|
|||||||
.msg-tools .van-icon.active { color: #1989fa; }
|
.msg-tools .van-icon.active { color: #1989fa; }
|
||||||
.msg-time { font-size: 11px; color: #c8c9cc; }
|
.msg-time { font-size: 11px; color: #c8c9cc; }
|
||||||
.typing { display: flex; align-items: center; gap: 6px; color: #969799; font-size: 12px; margin-top: 4px; }
|
.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-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; }
|
.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; }
|
.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-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-icon { color: #646566; flex-shrink: 0; margin-bottom: 8px; }
|
||||||
.input-row .van-field { flex: 1; background: #f7f8fa; border-radius: 8px; padding: 4px 10px; }
|
.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-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-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; }
|
.attach-thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user