fix: XML动作标签 + 意图分析上下文 + 图片file_id引用
- 动作消息改用 <action>...</action> XML 标签(注入器 + 解析器 + 测试) - 括号解析保留为降级方案,确保向后兼容 - 意图分析传入最近对话历史,防止短追问误判为 iot_query - 意图提示词强化:短追问明确归为 question,iot_query 需设备名词 - 图片附件支持 file_id 轻量引用(Gateway FileStore 解析 + 上传端点复用) - API 文档更新:附件新格式 + 图片传递链路 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ func NewIntentAnalyzer(llmAdapter *llm.Adapter) *IntentAnalyzer {
|
|||||||
|
|
||||||
// Analyze 分析用户消息意图
|
// Analyze 分析用户消息意图
|
||||||
// 优先使用 LLM,对于简单问候使用关键词快速通道(跳过 LLM 调用)
|
// 优先使用 LLM,对于简单问候使用关键词快速通道(跳过 LLM 调用)
|
||||||
func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*model.IntentResult, error) {
|
func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string, historyHint ...string) (*model.IntentResult, error) {
|
||||||
// 快速通道:简单问候/闲聊直接返回,跳过 LLM 调用
|
// 快速通道:简单问候/闲聊直接返回,跳过 LLM 调用
|
||||||
if a.isSimpleGreeting(userMessage) {
|
if a.isSimpleGreeting(userMessage) {
|
||||||
logger.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
|
logger.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
|
||||||
@@ -55,6 +55,10 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建轻量意图分析提示词
|
// 构建轻量意图分析提示词
|
||||||
|
userContent := userMessage
|
||||||
|
if len(historyHint) > 0 && historyHint[0] != "" {
|
||||||
|
userContent = fmt.Sprintf("对话上下文: %s\n\n用户消息: %s", historyHint[0], userMessage)
|
||||||
|
}
|
||||||
messages := []model.LLMMessage{
|
messages := []model.LLMMessage{
|
||||||
{
|
{
|
||||||
Role: model.RoleSystem,
|
Role: model.RoleSystem,
|
||||||
@@ -62,7 +66,7 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Role: model.RoleUser,
|
Role: model.RoleUser,
|
||||||
Content: fmt.Sprintf("用户消息: %s", userMessage),
|
Content: userContent,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,13 +227,14 @@ const intentAnalysisSystemPrompt = `分析以下用户消息的意图。只需
|
|||||||
- primary: 用户的主要意图
|
- primary: 用户的主要意图
|
||||||
- chat: 日常闲聊
|
- chat: 日常闲聊
|
||||||
- iot_control: 需要控制智能设备
|
- iot_control: 需要控制智能设备
|
||||||
- iot_query: 查询设备状态
|
- iot_query: 查询设备状态(仅当明确提到设备名时才用,如灯/空调/温度)
|
||||||
- question: 提问
|
- question: 提问(短追问如"看到了什么""什么意思""然后呢"归此类)
|
||||||
- emotional: 情绪表达/倾诉
|
- emotional: 情绪表达/倾诉
|
||||||
- needs_iot: 是否需要调用 IoT 相关功能
|
- needs_iot: 是否需要调用 IoT 相关功能(仅当明确提到设备名词时才为 true)
|
||||||
- needs_memory: 是否需要检索用户记忆(大部分情况为 true)
|
- needs_memory: 是否需要检索用户记忆(大部分情况为 true)
|
||||||
- sentiment: 用户情绪
|
- sentiment: 用户情绪
|
||||||
- urgency: low=普通闲聊, medium=需要回应, high=紧急求助`
|
- urgency: low=普通闲聊, medium=需要回应, high=紧急求助
|
||||||
|
- 重要:短追问绝不判定为 iot_control 或 iot_query,应判定为 question`
|
||||||
|
|
||||||
// parseIntentResponse 从 LLM 响应中解析意图 JSON
|
// parseIntentResponse 从 LLM 响应中解析意图 JSON
|
||||||
func parseIntentResponse(content string) (*model.IntentResult, error) {
|
func parseIntentResponse(content string) (*model.IntentResult, error) {
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
|
|
||||||
// 1. 意图分析
|
// 1. 意图分析
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
|
historyHint := o.buildHistoryHint(params.SessionID)
|
||||||
|
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message, historyHint)
|
||||||
if err != nil || intent == nil {
|
if err != nil || intent == nil {
|
||||||
logger.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
|
logger.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
|
||||||
intent = &model.IntentResult{
|
intent = &model.IntentResult{
|
||||||
@@ -650,6 +651,31 @@ func (o *Orchestrator) GetHistory(sessionID string, limit int) []model.LLMMessag
|
|||||||
return o.contextBuilder.GetHistory(sessionID, limit)
|
return o.contextBuilder.GetHistory(sessionID, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildHistoryHint returns a short context string from recent conversation history.
|
||||||
|
// Used by the intent analyzer to disambiguate follow-up questions from IoT queries.
|
||||||
|
func (o *Orchestrator) buildHistoryHint(sessionID string) string {
|
||||||
|
if o.contextBuilder == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
history := o.contextBuilder.GetHistory(sessionID, 3)
|
||||||
|
if len(history) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, m := range history {
|
||||||
|
roleLabel := "用户"
|
||||||
|
if m.Role == model.RoleAssistant {
|
||||||
|
roleLabel = "昔涟"
|
||||||
|
}
|
||||||
|
content := []rune(m.Content)
|
||||||
|
if len(content) > 60 {
|
||||||
|
content = content[:60]
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s: %s", roleLabel, string(content)))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// CacheMessage 缓存消息
|
// CacheMessage 缓存消息
|
||||||
func (o *Orchestrator) CacheMessage(sessionID string, role model.Role, content string) {
|
func (o *Orchestrator) CacheMessage(sessionID string, role model.Role, content string) {
|
||||||
if o.contextBuilder != nil {
|
if o.contextBuilder != nil {
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ func TestParseReviewMessages(t *testing.T) {
|
|||||||
{"动作+聊天", "(歪着头看你) 叶酱,客厅灯早就开着啦♪", 2, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat}},
|
{"动作+聊天", "(歪着头看你) 叶酱,客厅灯早就开着啦♪", 2, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat}},
|
||||||
{"聊天+动作", "我帮你关掉了哦 (轻轻按下遥控器)", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageAction}},
|
{"聊天+动作", "我帮你关掉了哦 (轻轻按下遥控器)", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageAction}},
|
||||||
{"只有括号但无内容", "", 0, nil},
|
{"只有括号但无内容", "", 0, nil},
|
||||||
{"空括号", "()", 1, []model.ReviewMessageType{model.ReviewMessageChat}}, // fallback to chat for unparseable bracket
|
{"空括号", "()", 1, []model.ReviewMessageType{model.ReviewMessageChat}},
|
||||||
{"多段落", "第一段内容\n\n第二段内容", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageChat}},
|
{"多段落", "第一段内容\n\n第二段内容", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageChat}},
|
||||||
{"动作+多段聊天", "(歪头) 第一段\n\n第二段内容", 3, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat, model.ReviewMessageChat}},
|
{"动作+多段聊天", "(歪头) 第一段\n\n第二段内容", 3, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat, model.ReviewMessageChat}},
|
||||||
|
// XML action tag tests
|
||||||
|
{"XML纯动作", "<action>轻轻晃了晃手指</action>", 1, []model.ReviewMessageType{model.ReviewMessageAction}},
|
||||||
|
{"XML动作+聊天", "<action>歪头看着你</action> 叶酱,今天好开心呀♪", 2, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat}},
|
||||||
|
{"XML聊天+动作+聊天", "你说的对 <action>轻轻敲了敲桌子</action> 不过我还有一个想法", 3, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageAction, model.ReviewMessageChat}},
|
||||||
|
{"XML多个动作", "<action>歪头</action> <action>轻轻按下遥控器</action> 帮你关掉啦~", 3, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageAction, model.ReviewMessageChat}},
|
||||||
|
{"XML混合括号降级", "开头聊天 <action>歪头</action> 中间聊天 (括号动作) 结尾聊天", 5, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageAction, model.ReviewMessageChat, model.ReviewMessageAction, model.ReviewMessageChat}},
|
||||||
|
{"XML空标签忽略", "<action></action> 正常聊天", 1, []model.ReviewMessageType{model.ReviewMessageChat}},
|
||||||
|
{"XML多行动作", "<action>走到窗边\n拉开窗帘</action> 今天阳光真好呢♪", 2, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
// codeBlockPattern matches fenced code blocks: ```lang\n...\n```
|
// codeBlockPattern matches fenced code blocks: ```lang\n...\n```
|
||||||
var codeBlockPattern = regexp.MustCompile("`{3}([^\n]*)\n([\\s\\S]*?)`{3}")
|
var codeBlockPattern = regexp.MustCompile("`{3}([^\n]*)\n([\\s\\S]*?)`{3}")
|
||||||
|
|
||||||
|
// actionTagPattern matches <action>...</action> XML tags (supports multiline content).
|
||||||
|
var actionTagPattern = regexp.MustCompile(`(?s)<action>(.*?)</action>`)
|
||||||
|
|
||||||
// markdownPatterns detects common Markdown syntax for auto-classification.
|
// markdownPatterns detects common Markdown syntax for auto-classification.
|
||||||
var markdownPatterns = []*regexp.Regexp{
|
var markdownPatterns = []*regexp.Regexp{
|
||||||
regexp.MustCompile(`^#{1,6}\s`), // headings
|
regexp.MustCompile(`^#{1,6}\s`), // headings
|
||||||
@@ -73,8 +76,46 @@ func parseReviewMessages(text string) []model.ReviewMessage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: bracket-action parser on non-code text
|
// Phase 2: XML action tags + bracket-based fallback
|
||||||
|
var processBracketText func(t string) // pre-declare for mutual reference
|
||||||
|
|
||||||
processText := func(t string) {
|
processText := func(t string) {
|
||||||
|
// Step 1: extract <action> XML tags
|
||||||
|
actionMatches := actionTagPattern.FindAllStringSubmatchIndex(t, -1)
|
||||||
|
type xmlAction struct {
|
||||||
|
start, end int
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
var xmlActions []xmlAction
|
||||||
|
for _, m := range actionMatches {
|
||||||
|
xmlActions = append(xmlActions, xmlAction{
|
||||||
|
start: m[0],
|
||||||
|
end: m[1],
|
||||||
|
content: strings.TrimSpace(t[m[2]:m[3]]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := 0
|
||||||
|
for _, xa := range xmlActions {
|
||||||
|
if xa.start > pos {
|
||||||
|
processBracketText(t[pos:xa.start])
|
||||||
|
}
|
||||||
|
if xa.content != "" {
|
||||||
|
messages = append(messages, model.ReviewMessage{
|
||||||
|
Type: model.ReviewMessageAction,
|
||||||
|
Content: xa.content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pos = xa.end
|
||||||
|
}
|
||||||
|
if pos < len(t) {
|
||||||
|
processBracketText(t[pos:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processBracketText is the bracket-based action parser (backward compat).
|
||||||
|
// Detects (action) and (action) patterns in text that wasn't already handled by XML tags.
|
||||||
|
processBracketText = func(t string) {
|
||||||
remaining := t
|
remaining := t
|
||||||
for len(remaining) > 0 {
|
for len(remaining) > 0 {
|
||||||
actionStart := -1
|
actionStart := -1
|
||||||
@@ -83,11 +124,11 @@ func parseReviewMessages(text string) []model.ReviewMessage {
|
|||||||
|
|
||||||
runes := []rune(remaining)
|
runes := []rune(remaining)
|
||||||
for ri, r := range runes {
|
for ri, r := range runes {
|
||||||
if r == '(' || r == '(' { // fullwidth (
|
if r == '(' || r == '(' {
|
||||||
actionStart = len(string(runes[:ri]))
|
actionStart = len(string(runes[:ri]))
|
||||||
closeRune := ')'
|
closeRune := ')'
|
||||||
if r == '(' {
|
if r == '(' {
|
||||||
closeRune = ')' // fullwidth )
|
closeRune = ')'
|
||||||
}
|
}
|
||||||
for rj := ri + 1; rj < len(runes); rj++ {
|
for rj := ri + 1; rj < len(runes); rj++ {
|
||||||
if runes[rj] == closeRune {
|
if runes[rj] == closeRune {
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ func (pc *PersonaConfig) buildConversationStyle() string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
|
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
|
||||||
sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n")
|
sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n")
|
||||||
sb.WriteString("- 执行操作时(开关设备、查询状态等),用括号包裹动作描述,后面跟自然对话。例如:\"(帮你把客厅灯关掉啦) 嗯,已经关好了~\"\n")
|
sb.WriteString("- 表达动作、表情、肢体语言或执行操作时,使用 <action>...</action> 标签包裹,后面跟自然对话。例如:\"<action>帮你把客厅灯关掉啦</action> 嗯,已经关好了~\"\n")
|
||||||
|
sb.WriteString("- 动作标签只能包含纯动作描述,不要把对话内容放进 <action> 标签里\n")
|
||||||
|
|
||||||
if len(cs.SentenceEnders) > 0 {
|
if len(cs.SentenceEnders) > 0 {
|
||||||
sb.WriteString(fmt.Sprintf("- 句尾可以带这些语气符:%s\n", strings.Join(cs.SentenceEnders, " ")))
|
sb.WriteString(fmt.Sprintf("- 句尾可以带这些语气符:%s\n", strings.Join(cs.SentenceEnders, " ")))
|
||||||
|
|||||||
@@ -28,15 +28,17 @@ type ChatHandler struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
hub *ws.Hub
|
hub *ws.Hub
|
||||||
sessionStore *store.SessionStore
|
sessionStore *store.SessionStore
|
||||||
|
fileStore *store.FileStore
|
||||||
upgrader websocket.Upgrader
|
upgrader websocket.Upgrader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatHandler 创建聊天处理器
|
// NewChatHandler 创建聊天处理器
|
||||||
func NewChatHandler(cfg *config.Config, hub *ws.Hub, sessionStore *store.SessionStore) *ChatHandler {
|
func NewChatHandler(cfg *config.Config, hub *ws.Hub, sessionStore *store.SessionStore, fileStore *store.FileStore) *ChatHandler {
|
||||||
return &ChatHandler{
|
return &ChatHandler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
sessionStore: sessionStore,
|
sessionStore: sessionStore,
|
||||||
|
fileStore: fileStore,
|
||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
@@ -154,15 +156,28 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
|||||||
if len(msg.Attachments) > 0 {
|
if len(msg.Attachments) > 0 {
|
||||||
images := make([]string, 0, len(msg.Attachments))
|
images := make([]string, 0, len(msg.Attachments))
|
||||||
for _, att := range msg.Attachments {
|
for _, att := range msg.Attachments {
|
||||||
if att.Type == "image" && att.URL != "" {
|
if att.Type == "image" {
|
||||||
imgURL := att.URL
|
imgURL := ""
|
||||||
// 将相对路径转换为绝对 URL,方便 AI-Core 访问
|
// 优先使用 file_id 引用(轻量,支持跨设备同步)
|
||||||
|
if att.FileID != "" && h.fileStore != nil {
|
||||||
|
if f, err := h.fileStore.GetFile(att.FileID); err == nil {
|
||||||
|
imgURL = "http://127.0.0.1:" + h.cfg.Port + "/api/v1/files/" + f.ID + "/download"
|
||||||
|
} else {
|
||||||
|
logger.Printf("[chat] file_id 解析失败: %s, err=%v", att.FileID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退: 使用 URL(兼容旧客户端 base64 data URI)
|
||||||
|
if imgURL == "" && att.URL != "" {
|
||||||
|
imgURL = att.URL
|
||||||
if strings.HasPrefix(imgURL, "/") {
|
if strings.HasPrefix(imgURL, "/") {
|
||||||
imgURL = "http://127.0.0.1:" + h.cfg.Port + imgURL
|
imgURL = "http://127.0.0.1:" + h.cfg.Port + imgURL
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if imgURL != "" {
|
||||||
images = append(images, imgURL)
|
images = append(images, imgURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if len(images) > 0 {
|
if len(images) > 0 {
|
||||||
aiReq["images"] = images
|
aiReq["images"] = images
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
|||||||
authHandler := handler.NewAuthHandler(cfg, authDB)
|
authHandler := handler.NewAuthHandler(cfg, authDB)
|
||||||
sessionHandler := handler.NewSessionHandler(hub, sessionStore)
|
sessionHandler := handler.NewSessionHandler(hub, sessionStore)
|
||||||
memoryHandler := handler.NewMemoryHandler(cfg.MemoryServiceURL)
|
memoryHandler := handler.NewMemoryHandler(cfg.MemoryServiceURL)
|
||||||
chatHandler := handler.NewChatHandler(cfg, hub, sessionStore)
|
chatHandler := handler.NewChatHandler(cfg, hub, sessionStore, fileStore)
|
||||||
webhookHandler := handler.NewWebhookHandler(cfg, hub)
|
webhookHandler := handler.NewWebhookHandler(cfg, hub)
|
||||||
notificationHandler := handler.NewNotificationHandler(cfg, hub)
|
notificationHandler := handler.NewNotificationHandler(cfg, hub)
|
||||||
reminderHandler := handler.NewReminderHandler(reminderStore, hub)
|
reminderHandler := handler.NewReminderHandler(reminderStore, hub)
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package ws
|
|||||||
// MessageAttachment 消息附件 (图片等)
|
// MessageAttachment 消息附件 (图片等)
|
||||||
type MessageAttachment struct {
|
type MessageAttachment struct {
|
||||||
Type string `json:"type"` // image
|
Type string `json:"type"` // image
|
||||||
URL string `json:"url"` // 图片 URL 或 data URL
|
URL string `json:"url,omitempty"` // 图片 URL 或 data URL(旧格式,向后兼容)
|
||||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
FileID string `json:"file_id,omitempty"` // 文件 ID(新格式,轻量引用)
|
||||||
|
ThumbnailURL string `json:"thumbnail_url,omitempty"` // 缩略图 URL
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Width int `json:"width,omitempty"`
|
Width int `json:"width,omitempty"`
|
||||||
Height int `json:"height,omitempty"`
|
Height int `json:"height,omitempty"`
|
||||||
|
|||||||
+15
-7
@@ -175,8 +175,9 @@ ws://<gateway>/ws/chat?token=<jwt>&session_id=<optional>&client_id=<optional>&de
|
|||||||
"attachments": [
|
"attachments": [
|
||||||
{
|
{
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"url": "string",
|
"url": "string (base64 data URI, 旧格式, 向后兼容)",
|
||||||
"thumbnail_url": "string",
|
"file_id": "string (文件 UUID, 新格式推荐, 配合 POST /files/upload 使用)",
|
||||||
|
"thumbnail_url": "string (缩略图 URL, 跨设备同步友好)",
|
||||||
"filename": "string",
|
"filename": "string",
|
||||||
"width": 0,
|
"width": 0,
|
||||||
"height": 0,
|
"height": 0,
|
||||||
@@ -184,6 +185,12 @@ ws://<gateway>/ws/chat?token=<jwt>&session_id=<optional>&client_id=<optional>&de
|
|||||||
"description": "string"
|
"description": "string"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
```
|
||||||
|
> **图片附件两种格式**:
|
||||||
|
> - **旧格式** (`url`): base64 data URI,直接内嵌于 WebSocket 消息中,简单但不适合跨设备同步。
|
||||||
|
> - **新格式(推荐)** (`file_id`): 先通过 [`POST /api/v1/files/upload`](#post-filesupload) 上传图片获取 `file_id` 和 `thumbnail_url`,消息中只携带轻量引用。Gateway 自动解析为本地文件 URL 传给 AI-Core。
|
||||||
|
|
||||||
|
```
|
||||||
"timestamp": 1717000000000,
|
"timestamp": 1717000000000,
|
||||||
"client_id": "string",
|
"client_id": "string",
|
||||||
"device_name": "string",
|
"device_name": "string",
|
||||||
@@ -486,12 +493,13 @@ Content-Type: `multipart/form-data`。字段 `file`。最大 20MB。
|
|||||||
|
|
||||||
错误: 400 `{"error":"文件大小超过限制 (最大 20MB)","errorType":"file_too_large"}`, 400 `{"error":"不支持的文件类型: ...","errorType":"unsupported_type"}`
|
错误: 400 `{"error":"文件大小超过限制 (最大 20MB)","errorType":"file_too_large"}`, 400 `{"error":"不支持的文件类型: ...","errorType":"unsupported_type"}`
|
||||||
|
|
||||||
> **文件在 AI 对话中的传递链路**:客户端上传文件后获得的 `url` 为相对路径(如 `/api/v1/files/{id}/download`)。当用户消息携带 `attachments` 时:
|
> **文件在 AI 对话中的传递链路(推荐新流程)**:
|
||||||
> 1. **Gateway** 在转发前将相对路径补全为绝对 URL(`http://127.0.0.1:{port}/api/v1/files/{id}/download`)
|
> 1. **客户端**先调用 [`POST /api/v1/files/upload`](#post-filesupload) 上传图片,获得 `file_id` 和 `thumbnail_url`
|
||||||
> 2. **AI-Core** 的 LLM 适配器在调用外部模型 API 前,将非 `data:` 的图片 URL 下载并转为 base64 data URL
|
> 2. **客户端**发送消息时,`attachments` 中携带 `file_id`(轻量引用,不再内嵌 base64)
|
||||||
> 3. 最终以多模态格式(`[{type: "text", text: "..."}, {type: "image_url", image_url: {url: "data:..."}}]`)传递给 LLM
|
> 3. **Gateway** 收到 `file_id` 后,从 `FileStore` 解析为本地下载 URL(`http://127.0.0.1:{port}/api/v1/files/{id}/download`)
|
||||||
|
> 4. **AI-Core** 下载该 URL 并转为 base64 data URL,以多模态格式传给外部 LLM API
|
||||||
>
|
>
|
||||||
> 即文件存储层对外部 LLM API 透明,无需暴露内网文件服务。
|
> **向后兼容**:`attachments[].url` 仍支持 base64 data URI或相对路径,Gateway 会将相对路径补全为绝对 URL。
|
||||||
|
|
||||||
### GET /files — 列表
|
### GET /files — 列表
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user