feat: IM平台列 + deviceID标识 + 会话持久化完善
- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted) - agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列) - design/16 §4.4(平台) + §4.5(deviceID) 已入档 - 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿 - 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
This commit is contained in:
1 parent
1bf60c1ae4
commit
37e98ec584
26 files changed
+828
-116
No files matched your search
@@ -32,6 +32,8 @@ type historyMessage struct {
|
||||
Mood string `json:"mood,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -108,8 +110,9 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
before = v
|
||||
}
|
||||
platform := r.URL.Query().Get("platform")
|
||||
|
||||
msgs, err := s.history.List(r.Context(), id, before, limit)
|
||||
msgs, err := s.history.List(r.Context(), id, before, limit, platform)
|
||||
if err != nil {
|
||||
s.log.Error("history list messages", "err", err, "session", id)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "messages_failed", "failed to list messages"))
|
||||
@@ -157,6 +160,8 @@ func toHistoryMessage(m history.Message) historyMessage {
|
||||
Mood: m.Mood,
|
||||
Image: m.Image,
|
||||
Thinking: m.Thinking,
|
||||
Platform: m.Platform,
|
||||
DeviceID: m.DeviceID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,41 @@ func TestHistoryListMessagesPagination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryListMessagesPlatformFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, st := newHistoryEnv(t)
|
||||
ctx := context.Background()
|
||||
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "web", Platform: history.PlatformWeb, CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("seed web: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000}); err != nil {
|
||||
t.Fatalf("seed tg: %v", err)
|
||||
}
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// No platform → the full main session (both lanes).
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var all []historyMessage
|
||||
decodeDataSlice(t, body, &all)
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("all = %d, want 2", len(all))
|
||||
}
|
||||
|
||||
// ?platform=telegram → only the telegram lane, and the DTO carries platform.
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10&platform=telegram", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var tg []historyMessage
|
||||
decodeDataSlice(t, body, &tg)
|
||||
if len(tg) != 1 || tg[0].Content != "tg" || tg[0].Platform != "telegram" {
|
||||
t.Fatalf("telegram page = %+v, want one platform=telegram message", tg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryDeleteSubSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, st := newHistoryEnv(t)
|
||||
|
||||
@@ -70,10 +70,13 @@ type loginResponse struct {
|
||||
// triggers streaming over the WebSocket channel and returns immediately. Image is
|
||||
// an optional base64 data: URI or http(s) URL attached for the vision path; the
|
||||
// loop routes it through the vision/OCR tool or the main model (design/06 §0).
|
||||
// DeviceID is the client-produced identifier of the sending browser/device
|
||||
// (design/16 §4.5), e.g. "web:chrome:a1b2c3"; it is optional and persisted as-is.
|
||||
type chatRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Text string `json:"text"`
|
||||
Image string `json:"image,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.Stream {
|
||||
s.publishChatStream(r.Context(), req.SessionID, req.Text, req.Image)
|
||||
s.publishChatStream(r.Context(), req.SessionID, req.Text, req.Image, req.DeviceID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stream": true})
|
||||
return
|
||||
}
|
||||
@@ -141,7 +144,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
// it binds the full-access env: memory read+write, unchanged behavior.
|
||||
msgs := userChatMessages(req.Text, req.Image)
|
||||
if s.hub.orchestrator != nil {
|
||||
sr, res := s.hub.orchestrator.SubmitSync(r.Context(), req.SessionID, msgs, loop.OwnerEnv())
|
||||
sr, res := s.hub.orchestrator.SubmitSync(withDeviceID(r.Context(), req.DeviceID), req.SessionID, msgs, loop.OwnerEnv())
|
||||
if !sr.Accepted {
|
||||
if sr.PendingFull {
|
||||
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
|
||||
|
||||
@@ -113,7 +113,7 @@ type HistoryStore interface {
|
||||
// (with the assigned id/timestamp) so the orchestrator can broadcast it for
|
||||
// cross-end realtime sync (design/16 §4.2).
|
||||
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
|
||||
List(ctx context.Context, sessionID string, before int64, limit int) ([]history.Message, error)
|
||||
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||||
DeleteSession(ctx context.Context, sessionID string) error
|
||||
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
||||
@@ -459,20 +459,25 @@ func newOrchestrator(s *Server) *Orchestrator {
|
||||
Emit: func(sessionID, typ string, data map[string]any) {
|
||||
s.hub.broadcast(wsEvent{Type: typ, SessionID: sessionID, Data: data})
|
||||
},
|
||||
OnPersisted: func(msg history.Message) {
|
||||
// Broadcast the complete message so every browser of the same account
|
||||
// appends it without a manual refresh (design/16 §4.2). The frontend
|
||||
// dedups by the backend-generated id, and thinking is stripped to stay
|
||||
// consistent with the WS history replay (design/16 §6).
|
||||
wire := toHistoryMessage(msg)
|
||||
wire.Thinking = ""
|
||||
s.hub.broadcast(wsEvent{Type: "message_persisted", SessionID: msg.SessionID, Data: map[string]any{"message": wire}})
|
||||
},
|
||||
Log: s.log,
|
||||
History: s.history,
|
||||
OnPersisted: s.BroadcastPersisted,
|
||||
Log: s.log,
|
||||
History: s.history,
|
||||
})
|
||||
}
|
||||
|
||||
// BroadcastPersisted broadcasts a just-persisted chat message as a
|
||||
// `message_persisted` WS event so every browser of the same account appends it
|
||||
// without a manual refresh (design/16 §4.2). It is shared by the orchestrator
|
||||
// (Web turns) and the channel bridge (IM owner-private messages routed into the
|
||||
// main web session), so both paths emit the identical frame. Thinking is stripped
|
||||
// to stay consistent with the WS history replay (design/16 §6). It is a no-op-safe
|
||||
// call when the hub has broadcast wired.
|
||||
func (s *Server) BroadcastPersisted(msg history.Message) {
|
||||
wire := toHistoryMessage(msg)
|
||||
wire.Thinking = ""
|
||||
s.hub.broadcast(wsEvent{Type: "message_persisted", SessionID: msg.SessionID, Data: map[string]any{"message": wire}})
|
||||
}
|
||||
|
||||
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||
// discard logger for nil so Server can always log safely.
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
|
||||
@@ -128,10 +128,13 @@ type sessionState struct {
|
||||
}
|
||||
|
||||
// pendingMsg is one queued user turn. It carries its own env so a resume runs
|
||||
// under the same memory-isolation boundary the message arrived with.
|
||||
// under the same memory-isolation boundary the message arrived with, plus the
|
||||
// client deviceID of the originating turn (design/16 §4.5) so the resumed
|
||||
// assistant reply is tagged with the same client that sent the message.
|
||||
type pendingMsg struct {
|
||||
messages []model.Message
|
||||
env loop.SessionEnv
|
||||
deviceID string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
@@ -215,6 +218,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
st = &sessionState{mode: ModeIdle}
|
||||
o.states[sessionID] = st
|
||||
}
|
||||
deviceID := deviceIDFrom(ctx)
|
||||
if st.mode != ModeIdle {
|
||||
if len(st.pending) >= o.maxPending {
|
||||
mode := st.mode
|
||||
@@ -222,11 +226,11 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": mode, "max": o.maxPending})
|
||||
return SubmitResult{PendingFull: true}, nil
|
||||
}
|
||||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, at: time.Now()})
|
||||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, at: time.Now()})
|
||||
depth := len(st.pending)
|
||||
mode := st.mode
|
||||
o.mu.Unlock()
|
||||
o.persistUserMessages(ctx, sessionID, messages)
|
||||
o.persistUserMessages(ctx, sessionID, messages, deviceID)
|
||||
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
|
||||
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth}, nil
|
||||
}
|
||||
@@ -240,30 +244,32 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
|
||||
o.persistUserMessages(ctx, sessionID, messages)
|
||||
o.persistUserMessages(ctx, sessionID, messages, deviceID)
|
||||
|
||||
if sync {
|
||||
res, err := o.run(runCtx, sessionID, messages, env)
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
_ = o.completeTurn(sessionID, gen, res, err)
|
||||
_ = o.completeTurn(sessionID, gen, res, err, deviceID)
|
||||
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
|
||||
}
|
||||
|
||||
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel)
|
||||
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel, deviceID)
|
||||
return SubmitResult{Accepted: true, Immediate: true}, nil
|
||||
}
|
||||
|
||||
// goTurn runs a single turn on a background goroutine and, when it finishes,
|
||||
// hands the outcome to completeTurn so the orchestrator can resume the queue.
|
||||
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc) {
|
||||
// deviceID tags the persistent assistant reply with the client that sent the turn
|
||||
// (design/16 §4.5).
|
||||
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID string) {
|
||||
defer cancel()
|
||||
res, err := o.run(ctx, sessionID, messages, env)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
_ = o.completeTurn(sessionID, gen, res, err)
|
||||
_ = o.completeTurn(sessionID, gen, res, err, deviceID)
|
||||
}
|
||||
|
||||
// completeTurn runs after a turn settles (either it finished, errored, or was
|
||||
@@ -272,8 +278,9 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
|
||||
// message "remembers" the prior context instead of starting from a blank slate
|
||||
// (design/15 §2.2: incremental, not full replay). A stale generation (an
|
||||
// interrupt or a newer top-level turn superseded this one) is a no-op.
|
||||
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error) error {
|
||||
o.persistAssistantReply(sessionID, res)
|
||||
// deviceID is the originating turn's client id, used to tag the persisted reply.
|
||||
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID string) error {
|
||||
o.persistAssistantReply(sessionID, res, deviceID)
|
||||
o.mu.Lock()
|
||||
st := o.states[sessionID]
|
||||
if st == nil || st.gen != gen {
|
||||
@@ -298,7 +305,7 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth})
|
||||
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel)
|
||||
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel, next.deviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -430,6 +437,23 @@ func (o *Orchestrator) notifyPersisted(msg history.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// deviceIDContextKey is the context key carrying a message's client deviceID
|
||||
// (design/16 §4.5) from the HTTP/WS boundary into the orchestrator's persistence.
|
||||
type deviceIDContextKey struct{}
|
||||
|
||||
// withDeviceID returns a context carrying deviceID so the orchestrator can tag the
|
||||
// persisted user message. An empty deviceID is a no-op (no tag is written).
|
||||
func withDeviceID(ctx context.Context, deviceID string) context.Context {
|
||||
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
|
||||
}
|
||||
|
||||
// deviceIDFrom returns the deviceID carried by ctx, or "" when none was set. It
|
||||
// returns only well-formed non-empty values; anything else is treated as absent.
|
||||
func deviceIDFrom(ctx context.Context) string {
|
||||
v, _ := ctx.Value(deviceIDContextKey{}).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// boundedTail returns a fresh copy of the last maxTail messages, so continuation
|
||||
// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input
|
||||
// slice or another state's tail.
|
||||
|
||||
@@ -175,7 +175,7 @@ func (f *fakeHistory) AppendFull(_ context.Context, msg history.Message) (histor
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (f *fakeHistory) List(context.Context, string, int64, int) ([]history.Message, error) {
|
||||
func (f *fakeHistory) List(context.Context, string, int64, int, string) ([]history.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -230,6 +230,38 @@ func TestSubmitPersistsUserAndAssistant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPersistsDeviceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(1)
|
||||
hist := &fakeHistory{}
|
||||
o := NewOrchestrator(OrchestratorOptions{
|
||||
Run: run.Run,
|
||||
Log: slog.Default(),
|
||||
History: hist,
|
||||
})
|
||||
run.tickets <- struct{}{}
|
||||
// The HTTP/WS boundary threads the browser deviceID through ctx (design/16 §4.5);
|
||||
// both the user turn and the assistant reply must be tagged with it.
|
||||
ctx := withDeviceID(context.Background(), "web:chrome:a1b2c3")
|
||||
sr, res := o.SubmitSync(ctx, "web", []model.Message{userMsg("你好")}, loop.OwnerEnv())
|
||||
if !sr.Accepted || !sr.Immediate {
|
||||
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("sync result is nil")
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
||||
}
|
||||
if recs[0].Role != history.RoleUser || recs[0].Content != "你好" || recs[0].DeviceID != "web:chrome:a1b2c3" {
|
||||
t.Fatalf("user record = %+v, want DeviceID web:chrome:a1b2c3", recs[0])
|
||||
}
|
||||
if recs[1].Role != history.RoleAssistant || recs[1].Content == "" || recs[1].DeviceID != "web:chrome:a1b2c3" {
|
||||
t.Fatalf("assistant record = %+v, want DeviceID web:chrome:a1b2c3", recs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPersistedBroadcast(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(1)
|
||||
|
||||
@@ -16,10 +16,11 @@ import (
|
||||
// persistUserMessages writes each RoleUser message of a submitted turn to the
|
||||
// history store so the raw user stream is durable before the turn resolves, then
|
||||
// notifies the transport so every browser of the same account can append it
|
||||
// without a manual refresh (design/16 §3.3, §4.2). It is a no-op when no history
|
||||
// store is wired. Rejected turns (empty, pending-full) never reach it, so nothing
|
||||
// vacuous is persisted.
|
||||
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message) {
|
||||
// without a manual refresh (design/16 §3.3, §4.2). deviceID tags the message with
|
||||
// the sending client (design/16 §4.5). It is a no-op when no history store is
|
||||
// wired. Rejected turns (empty, pending-full) never reach it, so nothing vacuous
|
||||
// is persisted.
|
||||
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID string) {
|
||||
if o.history == nil {
|
||||
return
|
||||
}
|
||||
@@ -31,9 +32,9 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
|
||||
if text == "" && image == "" {
|
||||
continue
|
||||
}
|
||||
full, err := o.history.AppendFull(ctx, history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image})
|
||||
full, err := o.history.AppendFull(ctx, history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, DeviceID: deviceID})
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator persist user", "err", err, "session", sessionID)
|
||||
o.log.Error("orchestrator persist user", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
continue
|
||||
}
|
||||
o.notifyPersisted(full)
|
||||
@@ -41,17 +42,18 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
|
||||
}
|
||||
|
||||
// persistAssistantReply writes the final assistant text of a settled turn to the
|
||||
// history store, then notifies the transport (design/16 §3.3, §4.2). It is a
|
||||
// no-op when no history store is wired or the turn produced no text
|
||||
// (interrupted/errored turns leave res nil). The write runs under a fresh
|
||||
// background context so a canceled turn context never aborts it.
|
||||
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result) {
|
||||
// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags
|
||||
// the reply with the client that sent the turn (design/16 §4.5). It is a no-op
|
||||
// when no history store is wired or the turn produced no text (interrupted/errored
|
||||
// turns leave res nil). The write runs under a fresh background context so a
|
||||
// canceled turn context never aborts it.
|
||||
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID string) {
|
||||
if o.history == nil || res == nil || res.Text == "" {
|
||||
return
|
||||
}
|
||||
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text})
|
||||
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, DeviceID: deviceID})
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID)
|
||||
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
return
|
||||
}
|
||||
o.notifyPersisted(full)
|
||||
|
||||
@@ -235,8 +235,11 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
// The Web main session is the owner's private conversation (JWT = owner), so
|
||||
// it binds the full-access env: memory read+write, unchanged behavior.
|
||||
msgs := userChatMessages(req.Text, req.Image)
|
||||
// Thread the client's deviceID into the orchestrator via ctx so both the
|
||||
// persisted user turn and the assistant reply are tagged (design/16 §4.5).
|
||||
ctx = withDeviceID(ctx, req.DeviceID)
|
||||
if h.orchestrator != nil {
|
||||
_ = h.orchestrator.Submit(context.Background(), req.SessionID, msgs, loop.OwnerEnv())
|
||||
_ = h.orchestrator.Submit(ctx, req.SessionID, msgs, loop.OwnerEnv())
|
||||
return
|
||||
}
|
||||
_, _ = h.runTurn(ctx, req.SessionID, msgs, loop.OwnerEnv())
|
||||
@@ -305,6 +308,10 @@ type chatStreamRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Text string `json:"text"`
|
||||
Image string `json:"image,omitempty"`
|
||||
// DeviceID is the client-produced identifier of the sending browser/device
|
||||
// (design/16 §4.5), e.g. "web:chrome:a1b2c3"; it is optional and persisted
|
||||
// alongside the message so the agent knows which end the owner is chatting from.
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
}
|
||||
|
||||
// handleWS upgrades /ws/chat after authenticating the JWT from the query token or
|
||||
@@ -432,11 +439,13 @@ func (c *wsConn) enqueueJSON(ev wsEvent) {
|
||||
}
|
||||
|
||||
// publishChatStream publishes a ws.chat streaming trigger (REST stream mode).
|
||||
func (s *Server) publishChatStream(ctx context.Context, sessionID, text, image string) {
|
||||
// deviceID is the sending browser/device id carried on the payload so the ws.chat
|
||||
// consumer can tag the persisted turn (design/16 §4.5).
|
||||
func (s *Server) publishChatStream(ctx context.Context, sessionID, text, image, deviceID string) {
|
||||
if s.bus == nil {
|
||||
return
|
||||
}
|
||||
_ = s.bus.Publish(ctx, wsChatSubject, map[string]any{"sessionId": sessionID, "text": text, "image": image})
|
||||
_ = s.bus.Publish(ctx, wsChatSubject, map[string]any{"sessionId": sessionID, "text": text, "image": image, "deviceId": deviceID})
|
||||
}
|
||||
|
||||
// wsToken extracts the JWT from the ?token= query or the Authorization header.
|
||||
|
||||
Reference in new issue
Block a user