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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user