【角色视觉体系(红线:每个角色自己的头像/背景,绝不串用昔涟)】
- persona.go:Persona 加 Visual{avatar, chatBackground{landscape,portrait}} + DefaultAvatar;每个角色各自配。
- cyrene.yaml:昔涟配 avatar(3F-Q-Happy-1) + chatBackground(横/竖屏)。
- PersonaStore 加 Persona(id);PersonaSummary 加 avatar/chatBackground;collectPersonas 按角色取
Persona(id) 视觉(无视觉→空,前端回退默认占位,绝不串角色)。
- 前端:types PersonaRecord 加 avatar/chatBackground;persona.ts personaAvatar(p)=p?.avatar||默认占位
(殊无昔涟 fallback);store 加 currentPersona;App 挂载 fetchPersonas 取默认角色;
TopBar/chat 头像改 personaAvatar(currentPersona)(写死昔涟头像已清除)。
【时间注入(她自然知道现在几点,深夜催睡交给人格/capacity)】
- options.go Options 加 Timezone;loop.go Loop 加 timeLocation/timeZoneName + New 解析 + loadTimezone;
time.go currentTimeBlock()(当前时间+周几+时区,每轮刷新);memory.go assembleContext 在「工作记忆」
前注入时间 system(keyed 在 timeLocation!=nil,无时区不注入不破坏测试);wire.go Timezone=a.cfg.Timezone。
go build/vet/test 全绿(gateway/loop/persona);前端 tsc/vite build 75模块绿。红线无写死昔涟头像。
199 lines
7.1 KiB
Go
199 lines
7.1 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// injectMemory retrieves the persona's relevant long-term memories and prepends
|
||
// them as a leading system message, so the model can ground its answer in recalled
|
||
// context (design §6 / §2). It is re-run every round via assembleContext so the
|
||
// retrieval reflects the latest conversation rather than a first-turn snapshot. It
|
||
// returns the input messages unchanged when no retriever/persona is configured,
|
||
// retrieval fails, or nothing relevant is found.
|
||
//
|
||
// The session env on ctx gates the retrieval: the owner-full env keeps the full
|
||
// recall (unchanged behavior); the restricted env (group chat / stranger,
|
||
// design/02 §3, design/11 §2) is read-only isolation — only non-owner-private
|
||
// project memory is injected so no owner-sensitive memory leaks to a group or
|
||
// stranger.
|
||
func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message {
|
||
if l.retriever == nil || l.personaID == "" {
|
||
return messages
|
||
}
|
||
env := sessionEnvFrom(ctx)
|
||
block, err := l.retrieveMemoryBlock(ctx, env, queryFromMessages(messages))
|
||
if err != nil {
|
||
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
|
||
return messages
|
||
}
|
||
if block == "" {
|
||
return messages
|
||
}
|
||
out := make([]model.Message, 0, len(messages)+1)
|
||
out = append(out, model.TextMessage(model.RoleSystem, block))
|
||
out = append(out, messages...)
|
||
return out
|
||
}
|
||
|
||
// retrieveMemoryBlock returns the memory injection block for env. The owner-full
|
||
// env keeps the pre-existing Inject behavior. The restricted env (group chat /
|
||
// stranger) injects only project-scope memory (人人可读, not owner-private) and
|
||
// marks the block as group-isolated so callers can tell an isolated read from the
|
||
// owner's full read.
|
||
func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, query string) (string, error) {
|
||
if !env.Restricted() {
|
||
return l.retriever.Inject(ctx, l.personaID, query, 0)
|
||
}
|
||
results, err := l.retriever.Retrieve(ctx, l.personaID, query, 0)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
shared := make([]memory.Result, 0, len(results))
|
||
for _, r := range results {
|
||
if r.Item.Scope == memory.ScopeProject {
|
||
shared = append(shared, r)
|
||
}
|
||
}
|
||
if len(shared) == 0 {
|
||
return "", nil
|
||
}
|
||
return formatMemoryBlock(shared, "群聊隔离"), nil
|
||
}
|
||
|
||
// formatMemoryBlock builds the leading system block from retrieved results. The
|
||
// non-empty marker prefixes the block so an isolated group read is distinguishable
|
||
// from the owner's full read.
|
||
func formatMemoryBlock(results []memory.Result, marker string) string {
|
||
var b strings.Builder
|
||
if marker != "" {
|
||
b.WriteString(marker)
|
||
b.WriteString(":")
|
||
}
|
||
b.WriteString("相关记忆(按相关度 × 衰减排序):")
|
||
for _, res := range results {
|
||
b.WriteByte('\n')
|
||
b.WriteString("- [")
|
||
b.WriteString(string(res.Item.Kind))
|
||
b.WriteString("] ")
|
||
b.WriteString(res.Item.Content)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// assembleContext builds the model-visible message list for one round (design
|
||
// §1.3 每轮 assemble, the loop's equivalent of OpenClaw's transformContext): it
|
||
// re-injects the persona's relevant long-term memory — retrieval is re-run every
|
||
// round from the current conversation, not cached from the first turn, so it
|
||
// supports cross-round refresh — folds in the per-session working-memory summary
|
||
// when present, and applies the optional TransformContext hook last so a caller
|
||
// can project/filter what the model actually sees. It returns the input unchanged
|
||
// when nothing is configured.
|
||
func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message {
|
||
var leading []model.Message
|
||
// Per-round "current time" so the model naturally knows the wall clock (not a
|
||
// first-turn snapshot). Keyed on a configured timezone so tests and no-TZ runs
|
||
// keep their exact leading-message counts. It sits before working memory / the
|
||
// memory block and can still be projected by transformContext.
|
||
if l.timeLocation != nil {
|
||
leading = append(leading, l.currentTimeBlock())
|
||
}
|
||
if l.working != nil {
|
||
if s := l.working.Summarize(); s != "" {
|
||
leading = append(leading, model.TextMessage(model.RoleSystem, "工作记忆: "+s))
|
||
}
|
||
}
|
||
leading = append(leading, l.injectMemory(ctx, messages)...)
|
||
|
||
out := leading
|
||
if l.transformContext != nil {
|
||
out = l.transformContext(ctx, leading, round)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// compactIfNeeded compacts the conversation when its estimated context exceeds
|
||
// the configured MaxContextTokens. It is a no-op when compaction is disabled
|
||
// (non-positive MaxContextTokens), the compactor is nil, the estimate is within
|
||
// budget, or the compactor returns an error — a compaction failure is logged and
|
||
// the conversation is kept as-is rather than failing the run.
|
||
func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) []model.Message {
|
||
if l.maxContextTokens <= 0 || l.compactor == nil {
|
||
return messages
|
||
}
|
||
compacted, err := l.compactor.Compact(ctx, messages, l.maxContextTokens)
|
||
if err != nil {
|
||
l.log.Warn("context compact", "error", err, "tokens", l.maxContextTokens)
|
||
return messages
|
||
}
|
||
if compacted == nil {
|
||
return messages
|
||
}
|
||
return compacted
|
||
}
|
||
|
||
// extractMemory runs the extraction hook (design §6) over the current
|
||
// conversation and persists any derived items. Errors are logged, never fatal, so
|
||
// a bad extraction cannot abort the run.
|
||
//
|
||
// In a restricted context (group chat / stranger, !MemWritable) extraction is
|
||
// skipped entirely: the conversation may read shared memory read-only and may
|
||
// never persist anything that could leak owner-sensitive information (design/02
|
||
// §3, design/11 §2).
|
||
func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step int) {
|
||
env := sessionEnvFrom(ctx)
|
||
if env.Restricted() {
|
||
l.log.Debug("memory extract skipped: read-only context", "step", step, "env", env.ActorEnv)
|
||
return
|
||
}
|
||
if l.extractor == nil || l.memory == nil {
|
||
return
|
||
}
|
||
items, err := l.extractor.Extract(ctx, messages)
|
||
if err != nil {
|
||
l.log.Warn("memory extract", "step", step, "error", err)
|
||
return
|
||
}
|
||
for _, it := range items {
|
||
if it.PersonaID == "" && l.personaID != "" {
|
||
it.PersonaID = l.personaID
|
||
}
|
||
if _, err := l.memory.Add(ctx, it); err != nil {
|
||
l.log.Warn("memory store", "step", step, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// queryFromMessages derives a retrieval query from the conversation: the last
|
||
// user turn's text, falling back to the last message's text, then "".
|
||
func queryFromMessages(messages []model.Message) string {
|
||
for i := len(messages) - 1; i >= 0; i-- {
|
||
if messages[i].Role == model.RoleUser {
|
||
return messageText(messages[i])
|
||
}
|
||
}
|
||
if n := len(messages); n > 0 {
|
||
return messageText(messages[n-1])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// messageText returns the concatenated plain-text payload of a message's text
|
||
// parts, which is the shared view used to build a retrieval query.
|
||
func messageText(m model.Message) string {
|
||
var b strings.Builder
|
||
for _, part := range m.Content {
|
||
if part.Type != model.ContentTypeText {
|
||
continue
|
||
}
|
||
if b.Len() > 0 {
|
||
b.WriteByte(' ')
|
||
}
|
||
b.WriteString(part.Text)
|
||
}
|
||
return b.String()
|
||
}
|