Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0a0e93c0 |
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
# 重新生成次数
|
||||
|
||||
@@ -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
|
||||
):
|
||||
if kind == "reasoning":
|
||||
reasoning_parts.append(delta)
|
||||
yield ("reasoning", delta), reply.id
|
||||
else:
|
||||
parts.append(delta)
|
||||
yield delta, reply.id
|
||||
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
|
||||
):
|
||||
if kind == "reasoning":
|
||||
reasoning_parts.append(delta)
|
||||
yield ("reasoning", delta), last.id
|
||||
else:
|
||||
parts.append(delta)
|
||||
yield delta, last.id
|
||||
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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
rec.onend = () => showToast('语音输入结束')
|
||||
rec.onerror = (e: any) => showToast(`语音识别失败:${e.error || ''}`)
|
||||
showToast('请说话…')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user