Merge pull request 'fix: 修复 deploy 工作流缺少 Node.js 导致 checkout 失败的问题' #72
@@ -55,6 +55,7 @@ type WsQuery struct {
|
|||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
Image string `json:"image"` // base64
|
Image string `json:"image"` // base64
|
||||||
Audio string `json:"audio"` // base64
|
Audio string `json:"audio"` // base64
|
||||||
|
Text string `json:"text"` // 用户手动输入的文本(有值时跳过 STT)
|
||||||
MimeType string `json:"mime_type"` // 默认 "audio/pcm"
|
MimeType string `json:"mime_type"` // 默认 "audio/pcm"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,22 +62,27 @@ func (p *Pipeline) ProcessQuery(
|
|||||||
log := logger.Log
|
log := logger.Log
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// 解码音频数据
|
// 解码音频数据(文本输入模式可跳过)
|
||||||
audio, err := base64.StdEncoding.DecodeString(req.Audio)
|
var audio []byte
|
||||||
if err != nil {
|
if req.Text == "" && req.Audio != "" {
|
||||||
log.Errorw("音频解码失败", "error", err)
|
var err error
|
||||||
sender.SendError(models.WsError{
|
audio, err = base64.StdEncoding.DecodeString(req.Audio)
|
||||||
Type: "error",
|
if err != nil {
|
||||||
RequestID: req.RequestID,
|
log.Errorw("音频解码失败", "error", err)
|
||||||
Code: "INVALID_MESSAGE",
|
sender.SendError(models.WsError{
|
||||||
Message: "音频数据解码失败",
|
Type: "error",
|
||||||
})
|
RequestID: req.RequestID,
|
||||||
return err
|
Code: "INVALID_MESSAGE",
|
||||||
|
Message: "音频数据解码失败",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码图片数据(可选)
|
// 解码图片数据(可选)
|
||||||
var image []byte
|
var image []byte
|
||||||
if req.Image != "" {
|
if req.Image != "" {
|
||||||
|
var err error
|
||||||
image, err = base64.StdEncoding.DecodeString(req.Image)
|
image, err = base64.StdEncoding.DecodeString(req.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("图片解码失败", "error", err)
|
log.Errorw("图片解码失败", "error", err)
|
||||||
@@ -110,45 +115,64 @@ func (p *Pipeline) ProcessQuery(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: STT 语音识别
|
// Step 1: 获取用户文本(语音识别或直接使用输入文本)
|
||||||
log.Infow("开始语音识别", "request_id", req.RequestID)
|
var userText string
|
||||||
sttResult, err := p.sttService.Recognize(ctx, audio, stt.Options{
|
if req.Text != "" {
|
||||||
Encoding: "pcm_s16le",
|
// 文本输入模式:跳过 STT,直接使用用户输入的文本
|
||||||
SampleRate: 16000,
|
log.Infow("使用文本输入", "request_id", req.RequestID, "text", req.Text)
|
||||||
Language: sess.Config.Language,
|
userText = req.Text
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Errorw("语音识别失败", "error", err)
|
|
||||||
sender.SendError(models.WsError{
|
|
||||||
Type: "error",
|
|
||||||
RequestID: req.RequestID,
|
|
||||||
Code: "STT_ERROR",
|
|
||||||
Message: "语音识别失败",
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送 STT 结果
|
// 发送 stt_result 以保持前端消息流一致性
|
||||||
if err := sender.SendSTTResult(models.WsSTTResult{
|
if err := sender.SendSTTResult(models.WsSTTResult{
|
||||||
Type: "stt_result",
|
Type: "stt_result",
|
||||||
RequestID: req.RequestID,
|
RequestID: req.RequestID,
|
||||||
Text: sttResult,
|
Text: userText,
|
||||||
IsFinal: true,
|
IsFinal: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorw("发送 STT 结果失败", "error", err)
|
log.Errorw("发送 STT 结果失败", "error", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 语音模式:执行 STT 语音识别
|
||||||
|
log.Infow("开始语音识别", "request_id", req.RequestID)
|
||||||
|
sttResult, err := p.sttService.Recognize(ctx, audio, stt.Options{
|
||||||
|
Encoding: "pcm_s16le",
|
||||||
|
SampleRate: 16000,
|
||||||
|
Language: sess.Config.Language,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorw("语音识别失败", "error", err)
|
||||||
|
sender.SendError(models.WsError{
|
||||||
|
Type: "error",
|
||||||
|
RequestID: req.RequestID,
|
||||||
|
Code: "STT_ERROR",
|
||||||
|
Message: "语音识别失败",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userText = sttResult
|
||||||
|
|
||||||
|
// 发送 STT 结果
|
||||||
|
if err := sender.SendSTTResult(models.WsSTTResult{
|
||||||
|
Type: "stt_result",
|
||||||
|
RequestID: req.RequestID,
|
||||||
|
Text: userText,
|
||||||
|
IsFinal: true,
|
||||||
|
}); err != nil {
|
||||||
|
log.Errorw("发送 STT 结果失败", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 追加用户消息到历史
|
// 追加用户消息到历史
|
||||||
p.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
p.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: sttResult,
|
Content: userText,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2+3: LLM 流式推理 + TTS 并行合成
|
// Step 2+3: LLM 流式推理 + TTS 并行合成
|
||||||
log.Infow("开始 LLM 推理", "request_id", req.RequestID)
|
log.Infow("开始 LLM 推理", "request_id", req.RequestID)
|
||||||
llmReq := llm.Request{
|
llmReq := llm.Request{
|
||||||
Image: image,
|
Image: image,
|
||||||
Text: sttResult,
|
Text: userText,
|
||||||
History: history,
|
History: history,
|
||||||
Language: sess.Config.Language,
|
Language: sess.Config.Language,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,19 +41,22 @@ interface WsMessage {
|
|||||||
|
|
||||||
#### `query` — 发起一次视觉对话
|
#### `query` — 发起一次视觉对话
|
||||||
|
|
||||||
用户说完话后,客户端同时发送当前图像帧和语音片段:
|
用户说完话后,客户端同时发送当前图像帧和语音片段。也支持文本输入模式(手动输入文字时跳过语音识别):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface QueryMessage {
|
interface QueryMessage {
|
||||||
type: "query";
|
type: "query";
|
||||||
request_id: string; // 客户端生成的 UUID
|
request_id: string; // 客户端生成的 UUID
|
||||||
image: string; // Base64 编码的 JPEG 图像(不含 data: 前缀)
|
image: string; // Base64 编码的 JPEG 图像(不含 data: 前缀)
|
||||||
audio: string; // Base64 编码的音频片段(PCM 16kHz)
|
audio: string; // Base64 编码的音频片段(PCM 16kHz),文本输入时为空字符串
|
||||||
|
text?: string; // 用户手动输入的文本(有值时跳过 STT,直接使用此文本)
|
||||||
mime_type?: string; // 音频格式,默认 "audio/pcm"
|
mime_type?: string; // 音频格式,默认 "audio/pcm"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
|
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
|
||||||
|
>
|
||||||
|
> **文本输入模式**:当用户关闭麦克风后,可通过对话框手动输入文字。此时 `text` 字段携带用户输入,`audio` 为空字符串,服务端跳过 STT 直接使用 `text` 进行 LLM 推理。
|
||||||
|
|
||||||
#### `config` — 更新会话配置
|
#### `config` — 更新会话配置
|
||||||
|
|
||||||
@@ -211,13 +214,13 @@ interface PongMessage {
|
|||||||
|
|
||||||
### 消息流时序
|
### 消息流时序
|
||||||
|
|
||||||
一次完整交互:
|
**语音模式**(麦克风开启):
|
||||||
|
|
||||||
```
|
```
|
||||||
Client Server
|
Client Server
|
||||||
| |
|
| |
|
||||||
|-- query {image, audio} ------>|
|
|-- query {image, audio} ------>|
|
||||||
|<-- stt_result {text} ---------|
|
|<-- stt_result {text} ---------| (语音识别)
|
||||||
| |
|
| |
|
||||||
|<-- llm_chunk {delta: "这"} ---| (LLM 流式输出)
|
|<-- llm_chunk {delta: "这"} ---| (LLM 流式输出)
|
||||||
|<-- llm_chunk {delta: "是一"} -|
|
|<-- llm_chunk {delta: "是一"} -|
|
||||||
@@ -228,6 +231,22 @@ Client Server
|
|||||||
|<-- tts_audio {is_last: true} -|
|
|<-- tts_audio {is_last: true} -|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**文本输入模式**(麦克风关闭,手动输入文字):
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
| |
|
||||||
|
|-- query {image, text} ------->| (跳过 STT)
|
||||||
|
|<-- stt_result {text} ---------| (回显用户文本)
|
||||||
|
| |
|
||||||
|
|<-- llm_chunk {delta: "好的"} -| (LLM 流式输出)
|
||||||
|
|<-- llm_chunk {delta: ",我"} -|
|
||||||
|
|<-- llm_done {full_text} ------|
|
||||||
|
| |
|
||||||
|
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
||||||
|
|<-- tts_audio {is_last: true} -|
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、REST API
|
## 二、REST API
|
||||||
@@ -891,6 +910,7 @@ type QueryRequest struct {
|
|||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
Image []byte `json:"-"` // Base64 解码后
|
Image []byte `json:"-"` // Base64 解码后
|
||||||
Audio []byte `json:"-"` // Base64 解码后
|
Audio []byte `json:"-"` // Base64 解码后
|
||||||
|
Text string `json:"text"` // 用户手动输入的文本(有值时跳过 STT)
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,13 @@ body {
|
|||||||
/* ---- Chat Panel (覆盖子组件样式) ---- */
|
/* ---- Chat Panel (覆盖子组件样式) ---- */
|
||||||
|
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel__messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
@@ -560,6 +567,56 @@ body {
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Chat Input ---- */
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 16px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__field {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__field::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__field:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__send {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__send:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__send:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Streaming Cursor ---- */
|
/* ---- Streaming Cursor ---- */
|
||||||
|
|
||||||
.cursor {
|
.cursor {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function App() {
|
|||||||
isMicOn,
|
isMicOn,
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleMic,
|
toggleMic,
|
||||||
|
sendTextMessage,
|
||||||
} = useVisionSession();
|
} = useVisionSession();
|
||||||
|
|
||||||
const isConnected = connectionStatus === "connected";
|
const isConnected = connectionStatus === "connected";
|
||||||
@@ -233,6 +234,7 @@ function App() {
|
|||||||
messages={messages}
|
messages={messages}
|
||||||
currentReply={currentReply}
|
currentReply={currentReply}
|
||||||
connectionStatus={connectionStatus}
|
connectionStatus={connectionStatus}
|
||||||
|
onSendText={sendTextMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ChatPanel — 消息展示面板
|
// ChatPanel — 消息展示面板
|
||||||
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动
|
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ChatMessage } from "../../types";
|
import type { ChatMessage } from "../../types";
|
||||||
import type { ConnectionStatus } from "../../lib/websocket";
|
import type { ConnectionStatus } from "../../lib/websocket";
|
||||||
|
|
||||||
@@ -11,12 +11,16 @@ interface ChatPanelProps {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
currentReply?: string;
|
currentReply?: string;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
|
onSendText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPanelProps) {
|
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const isAutoScroll = useRef(true);
|
const isAutoScroll = useRef(true);
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
|
const isConnected = connectionStatus === "connected";
|
||||||
|
|
||||||
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,9 +43,17 @@ export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPane
|
|||||||
}
|
}
|
||||||
}, [messages, currentReply]);
|
}, [messages, currentReply]);
|
||||||
|
|
||||||
|
// 提交文本消息
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!inputText.trim() || !onSendText) return;
|
||||||
|
onSendText(inputText);
|
||||||
|
setInputText("");
|
||||||
|
};
|
||||||
|
|
||||||
// 空状态
|
// 空状态
|
||||||
if (messages.length === 0 && !currentReply) {
|
if (messages.length === 0 && !currentReply) {
|
||||||
if (connectionStatus !== "connected") {
|
if (!isConnected) {
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel chat-panel--empty">
|
<div className="chat-panel chat-panel--empty">
|
||||||
<span className="chat-panel--empty-icon">💬</span>
|
<span className="chat-panel--empty-icon">💬</span>
|
||||||
@@ -60,35 +72,58 @@ export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPane
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel" ref={containerRef}>
|
<div className="chat-panel">
|
||||||
{messages.map((msg, index) => (
|
<div className="chat-panel__messages" ref={containerRef}>
|
||||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
{messages.map((msg, index) => (
|
||||||
<div className="chat-message__role">
|
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||||
{msg.role === "user" ? "你" : "AI"}
|
<div className="chat-message__role">
|
||||||
</div>
|
{msg.role === "user" ? "你" : "AI"}
|
||||||
<div className="chat-message__content">{msg.content}</div>
|
|
||||||
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
|
||||||
<div className="chat-message__meta">
|
|
||||||
{msg.tokensUsed} tokens
|
|
||||||
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
|
|
||||||
{msg.model && ` · ${msg.model}`}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="chat-message__content">{msg.content}</div>
|
||||||
</div>
|
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
||||||
))}
|
<div className="chat-message__meta">
|
||||||
|
{msg.tokensUsed} tokens
|
||||||
{/* 流式回复(尚未完成) */}
|
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
|
||||||
{currentReply && (
|
{msg.model && ` · ${msg.model}`}
|
||||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
</div>
|
||||||
<div className="chat-message__role">AI</div>
|
)}
|
||||||
<div className="chat-message__content">
|
|
||||||
{currentReply}
|
|
||||||
<span className="cursor">▌</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={bottomRef} />
|
{/* 流式回复(尚未完成) */}
|
||||||
|
{currentReply && (
|
||||||
|
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||||
|
<div className="chat-message__role">AI</div>
|
||||||
|
<div className="chat-message__content">
|
||||||
|
{currentReply}
|
||||||
|
<span className="cursor">▌</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文本输入框 */}
|
||||||
|
{isConnected && onSendText && (
|
||||||
|
<form className="chat-input" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="chat-input__field"
|
||||||
|
placeholder="输入文字对话..."
|
||||||
|
value={inputText}
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="chat-input__send"
|
||||||
|
disabled={!inputText.trim()}
|
||||||
|
title="发送"
|
||||||
|
>
|
||||||
|
➤
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,6 +363,44 @@ export function useVisionSession() {
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}, [send, currentReply]);
|
}, [send, currentReply]);
|
||||||
|
|
||||||
|
/** 发送文本消息(手动输入) */
|
||||||
|
const sendTextMessage = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!text.trim() || isProcessingRef.current) return;
|
||||||
|
|
||||||
|
// 停止上一轮的 TTS 播放
|
||||||
|
ttsPlayerRef.current?.stop();
|
||||||
|
setIsAudioPlaying(false);
|
||||||
|
|
||||||
|
// 捕获当前摄像头画面
|
||||||
|
const frame = captureFrame();
|
||||||
|
|
||||||
|
const requestId = uuidv4();
|
||||||
|
send({
|
||||||
|
type: "query",
|
||||||
|
request_id: requestId,
|
||||||
|
image: frame ? dataUrlToBase64(frame) : "",
|
||||||
|
audio: "", // 文本输入无音频
|
||||||
|
text: text.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新请求统计
|
||||||
|
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
|
||||||
|
|
||||||
|
// 添加用户消息
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", content: text.trim(), timestamp: Date.now() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 记录到对话历史
|
||||||
|
historyRef.current.push({ role: "user", content: text.trim() });
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
},
|
||||||
|
[captureFrame, send],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
currentReply,
|
currentReply,
|
||||||
@@ -387,5 +425,6 @@ export function useVisionSession() {
|
|||||||
isMicOn,
|
isMicOn,
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleMic,
|
toggleMic,
|
||||||
|
sendTextMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export interface QueryMessage {
|
|||||||
type: "query";
|
type: "query";
|
||||||
request_id: string;
|
request_id: string;
|
||||||
image: string; // Base64 JPEG(不含 data: 前缀)
|
image: string; // Base64 JPEG(不含 data: 前缀)
|
||||||
audio: string; // Base64 PCM 16kHz
|
audio: string; // Base64 PCM 16kHz(文本输入时为空字符串)
|
||||||
|
text?: string; // 用户手动输入的文本(有值时跳过 STT)
|
||||||
mime_type?: string; // 默认 "audio/pcm"
|
mime_type?: string; // 默认 "audio/pcm"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user