package sessionread import ( "strconv" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // Layout of the material (design/22 §8.1 返回文本). The first three lines are the // hard attribution header: they are present on *every* non-error result, including // the empty-session one, because their whole purpose is to keep the reading role // from treating someone else's words as her own memory or her own voice. const ( // headerLine is the constant first line of every material. headerLine = "[外部会话材料 · 只读 · 不属于你的记忆]" // roleLabelPrefix, sessionLabelPrefix and spanLabelPrefix open the attribution // header's role line, session line and time-span line. roleLabelPrefix = "来源角色:" sessionLabelPrefix = "来源会话:" spanLabelPrefix = "时间跨度:" // separatorLine closes the header and opens the transcript. separatorLine = "————" // unknownRoleLabel stands in for a role id this runtime cannot name and has no // default for, so the attribution is never silently dropped. unknownRoleLabel = "(未设定角色)" // ownerLabel labels a user message whose sender name is blank — the owner's own // private chat never carries one (design/16 §4.6). ownerLabel = "主人" // systemLabel labels a stored system message, which is neither a role utterance // nor a user utterance. systemLabel = "系统" ) // Message-line and header timestamp layouts. They mirror the loop's model-facing // clock (internal/runtime/loop/time.go renders the per-round "当前时间" with the same // wall-clock layout), so the material reads in the same shape as the runtime's own // time injection. const ( timestampLayout = "2006-01-02 15:04" timeOnlyLayout = "15:04" ) // Layout fragments of the material. const ( titleOpen = "「" titleClose = "」" roleIDOpen = "(" roleIDClose = ")" spanArrow = " → " noteOpen = "(" noteClose = ")" cursorKey = "before=" cursorInfix = ";更早用 " + cursorKey earliestNote = ";已到最早" recentInfix = "最近 " countSuffix = " 条" lineOpen = "[" lineClose = "] " speakerColon = ":" // emptyNoMsg is the body of a session that never received a message. emptyNoMsg = "该会话没有消息。" // emptyOlderSuffix is the body of a page that came back empty because the // cursor ran past the oldest message of a session that does have history. emptyOlderSuffix = " 之前已没有更早的消息。" ) // material is one fetched page of an external session, ready to render. msgs is // newest-first, exactly as the store returns it; the transcript is rendered // oldest-first. type material struct { sessionID string title string personaID string before int64 msgs []history.Message // hasOlder reports whether the page filled up, i.e. whether an older page may // still exist. It is a heuristic (no look-ahead query is issued): a session with // exactly limit messages also reports true, and the follow-up call then answers // "no older messages" instead of a transcript. hasOlder bool } // render builds the material text: the constant attribution header, the time-span // line when there are messages, and one line per message. func (r *Reader) render(m material) string { lines := []string{ headerLine, roleLabelPrefix + r.roleLabel(m.personaID), sessionLabelPrefix + sessionLabel(m.sessionID, m.title), } if len(m.msgs) == 0 { lines = append(lines, separatorLine, emptyBody(m)) return strings.Join(lines, "\n") } lines = append(lines, r.spanLine(m), separatorLine) for i := len(m.msgs) - 1; i >= 0; i-- { lines = append(lines, r.messageLine(m.msgs[i])) } return strings.Join(lines, "\n") } // emptyBody is the body of an empty material. A session that never received a // message says so; a page that came back empty because the cursor ran past the // oldest message says that instead, so the two are never confused. func emptyBody(m material) string { if m.before > 0 { return cursorKey + strconv.FormatInt(m.before, 10) + emptyOlderSuffix } return emptyNoMsg } // spanLine renders the time-span line: the oldest and the newest message of the // page, how many messages the page carries, and the cursor for the next older page // (or an explicit "already the earliest"). The second timestamp drops the date when // both ends fall on the same local day. func (r *Reader) spanLine(m material) string { oldest, newest := m.msgs[len(m.msgs)-1], m.msgs[0] span := r.formatAt(oldest.CreatedAt) + spanArrow + r.formatEnd(newest.CreatedAt, oldest.CreatedAt) return spanLabelPrefix + span + noteOpen + m.scopeNote() + noteClose } // scopeNote is the parenthesized tail of the time-span line: the page size plus // either the next page's cursor or the explicit "nothing older" marker. func (m material) scopeNote() string { note := recentInfix + strconv.Itoa(len(m.msgs)) + countSuffix if !m.hasOlder { return note + earliestNote } return note + cursorInfix + strconv.FormatInt(m.msgs[len(m.msgs)-1].CreatedAt, 10) } // messageLine renders one transcript line: the message's own timestamp and the // speaker it is attributed to. The body is copied verbatim — a multi-line message // therefore keeps its own newlines and is never rewritten or collapsed. func (r *Reader) messageLine(msg history.Message) string { return lineOpen + r.formatAt(msg.CreatedAt) + lineClose + r.speakerLabel(msg) + speakerColon + msg.Content } // speakerLabel decides who a message is attributed to. An assistant message is // attributed by the message's OWN persona_id (design/22 §8.1 约束 5) — never by the // reading session's role — so a greeting in the middle of a transcript is still // credited to the role that actually said it. An empty persona id means the global // default role. A user message uses its stored sender name, falling back to 主人. func (r *Reader) speakerLabel(msg history.Message) string { switch msg.Role { case history.RoleAssistant: return r.roleName(msg.PersonaID) case history.RoleSystem: return systemLabel default: if name := strings.TrimSpace(msg.SenderName); name != "" { return name } return ownerLabel } } // roleName resolves a role id to its display name, mapping an empty id to the // global default role. An id this runtime cannot name is shown as-is, so an // unknown role is still visible as a distinct speaker. func (r *Reader) roleName(id string) string { if id == "" { id = r.personas.DefaultPersonaID() } if id == "" { return unknownRoleLabel } if name, ok := r.personas.PersonaName(id); ok { if trimmed := strings.TrimSpace(name); trimmed != "" { return trimmed } } return id } // roleLabel renders the header's role line: the display name followed by the role // id in parentheses (design/22 §8.1 "雪(yuki)"). When the id cannot be named, or // when the name and the id coincide, the name alone is shown. func (r *Reader) roleLabel(id string) string { name := r.roleName(id) resolved := id if resolved == "" { resolved = r.personas.DefaultPersonaID() } if resolved == "" || resolved == name { return name } return name + roleIDOpen + resolved + roleIDClose } // sessionLabel renders the header's session line: the session id, followed by its // title in corner brackets when it has one (design/22 §8.1 "web:5f1c…「调试日志」"). // The id is never truncated: it is what the model needs to page through the very // same session. func sessionLabel(sessionID, title string) string { if trimmed := strings.TrimSpace(title); trimmed != "" { return sessionID + titleOpen + trimmed + titleClose } return sessionID } // formatAt renders a millisecond timestamp in the reader's location. func (r *Reader) formatAt(ms int64) string { return time.UnixMilli(ms).In(r.loc).Format(timestampLayout) } // formatEnd renders the time-span line's closing timestamp: date and time when it // falls on a different local day than the opening one, otherwise the time alone. func (r *Reader) formatEnd(ms, opening int64) string { end := time.UnixMilli(ms).In(r.loc) start := time.UnixMilli(opening).In(r.loc) if end.Year() == start.Year() && end.YearDay() == start.YearDay() { return end.Format(timeOnlyLayout) } return end.Format(timestampLayout) }