diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 040fc15..fd2ecba 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -330,6 +330,7 @@ func (a *app) wireRuntime() error { Tracer: tracer, Guards: guards, PersonaID: personaID, + Timezone: a.cfg.Timezone, MaxRounds: defaultMaxRounds, WorkingMemory: memory.NewWorkingMemory(0), OnStream: a.streamChunk, diff --git a/configs/personas/cyrene.yaml b/configs/personas/cyrene.yaml index 2ee1248..95f5cf1 100644 --- a/configs/personas/cyrene.yaml +++ b/configs/personas/cyrene.yaml @@ -88,3 +88,10 @@ affection: max: 700 delta: 10 # 对应旧版五级:初识(0)/熟悉(50)/亲近(150)/信赖(350)/羁绊(700) + +# 角色视觉(此角色的头像/背景;只用于本角色,其他角色各自配置,绝不串用) +visual: + avatar: /images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png + chatBackground: + landscape: /images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png + portrait: /images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png b/frontend/web/public/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png new file mode 100755 index 0000000..8b140da Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png differ diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Vertical/1st_Form/1.png b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/1st_Form/1.png new file mode 100755 index 0000000..738b9c4 Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/1st_Form/1.png differ diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png new file mode 100755 index 0000000..00fa4e8 Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png differ diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/2.png b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/2.png new file mode 100755 index 0000000..738b9c4 Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/2.png differ diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index fbedf70..ae3ef03 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -6,12 +6,15 @@ import { AdminApp } from "./admin/AdminApp"; import { parseAdminHash } from "./admin/router"; import { connectWs, disconnectWs } from "./lib/ws"; import { initTheme } from "./lib/theme"; -import { getAuthToken } from "./lib/api"; +import { fetchPersonas, getAuthToken } from "./lib/api"; +import { useAppStore } from "./store/useAppStore"; export default function App(): React.ReactNode { // Apply the persisted (dark-first) theme and open the realtime channel only // when we hold a token — otherwise the gateway rejects the handshake with 401. const [route, setRoute] = useState(() => parseAdminHash(window.location.hash)); + const setCurrentPersona = useAppStore((s) => s.setCurrentPersona); + useEffect(() => { initTheme(); if (getAuthToken()) connectWs(); @@ -23,6 +26,25 @@ export default function App(): React.ReactNode { }; }, []); + // Load personas once on mount and pick the default one as the web main-session + // persona. On failure currentPersona stays null and components fall back to the + // neutral default placeholder (never another role's avatar). + useEffect(() => { + if (!getAuthToken()) return; + let cancelled = false; + fetchPersonas() + .then((list) => { + if (cancelled) return; + setCurrentPersona(list.find((p) => p.isDefault) ?? list[0] ?? null); + }) + .catch(() => { + // Keep currentPersona null → neutral default placeholder. + }); + return () => { + cancelled = true; + }; + }, [setCurrentPersona]); + // The M9 modal UI and the admin subsystem (design/10) are two separate shells. // `/admin` is reached via `#/admin`; every admin module is a hash segment. if (route.admin) { diff --git a/frontend/web/src/components/TopBar.tsx b/frontend/web/src/components/TopBar.tsx index cd230e4..b3109c6 100644 --- a/frontend/web/src/components/TopBar.tsx +++ b/frontend/web/src/components/TopBar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useAppStore } from "../store/useAppStore"; import { useModeStore } from "../store/useModeStore"; import { applyTheme, readTheme, type ThemeMode } from "../lib/theme"; +import { personaAvatar } from "../lib/persona"; import type { Mode, WsStatus } from "../lib/types"; import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; @@ -27,6 +28,7 @@ const wsMeta: Record s.wsStatus); const mood = useAppStore((s) => s.mood); + const currentPersona = useAppStore((s) => s.currentPersona); const mode = useModeStore((s) => s.mode); const [theme, setTheme] = useState(readTheme); @@ -41,7 +43,7 @@ export function TopBar(): React.ReactNode { return (
- Cyrene + {currentPersona?.name
Cyrene · 昔涟
diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index 24490d2..db822ea 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import type { ChatMessage, Mode, ToolUsed } from "../lib/types"; +import { useAppStore } from "../store/useAppStore"; +import { personaAvatar } from "../lib/persona"; import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; import { WakePath } from "./status"; @@ -130,10 +132,11 @@ function AssistantParaBubble({ full: boolean; active?: boolean; }): React.ReactNode { + const currentPersona = useAppStore((s) => s.currentPersona); return (
{full ? ( - + ) : (
)} @@ -259,9 +262,10 @@ function AssistantBubble({ mode: Mode; active?: boolean; }): React.ReactNode { + const currentPersona = useAppStore((s) => s.currentPersona); return (
- +
{message.mood ? ( diff --git a/frontend/web/src/lib/persona.ts b/frontend/web/src/lib/persona.ts new file mode 100644 index 0000000..14827c6 --- /dev/null +++ b/frontend/web/src/lib/persona.ts @@ -0,0 +1,10 @@ +import type { PersonaRecord } from "./types"; + +/** Neutral fallback avatar when a persona has no avatar of its own. Never another + * role's image — a persona without an avatar gets this neutral default. */ +const DEFAULT_AVATAR = "/images/User_Avatar/Default_Avatar.png"; + +/** Resolve a persona's avatar: its own, else the neutral default placeholder. */ +export function personaAvatar(p: PersonaRecord | null | undefined): string { + return p?.avatar || DEFAULT_AVATAR; +} diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index ffe1ca2..4f9b223 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -149,6 +149,10 @@ export interface PersonaRecord { isDefault: boolean; mode: string; memoryNamespace: string; + /** This persona's own avatar image path. Omitted when unset (omitempty). */ + avatar?: string; + /** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */ + chatBackground?: { landscape?: string; portrait?: string }; } export interface LoginResult { diff --git a/frontend/web/src/store/useAppStore.ts b/frontend/web/src/store/useAppStore.ts index 0cbff98..7a0aace 100644 --- a/frontend/web/src/store/useAppStore.ts +++ b/frontend/web/src/store/useAppStore.ts @@ -7,6 +7,7 @@ import type { ChatMessage, FlowGraph, Mode, + PersonaRecord, ToolUsed, WsStatus, } from "../lib/types"; @@ -36,8 +37,12 @@ export interface AppStore { sessionBusy: boolean; pendingCount: number; + /** 当前 persona(web 主会话=默认 persona)。加载前/失败时为 null,头像回退中性默认占位。 */ + currentPersona: PersonaRecord | null; + setWsStatus: (status: WsStatus) => void; setSessionId: (id: string) => void; + setCurrentPersona: (p: PersonaRecord | null) => void; setContext: (patch: Partial) => void; setMood: (mood: string | null) => void; @@ -88,10 +93,12 @@ export const useAppStore = create()( awakenTool: null, sessionBusy: false, pendingCount: 0, + currentPersona: null, taskLogs: {}, setWsStatus: (status) => set({ wsStatus: status }), setSessionId: (id) => set({ sessionId: id }), + setCurrentPersona: (p) => set({ currentPersona: p }), setContext: (patch) => set((s) => ({ context: { ...s.context, ...patch } })), setMood: (mood) => set({ mood }), diff --git a/internal/persona/loader/persona.go b/internal/persona/loader/persona.go index 15ea9c6..c55d7e8 100644 --- a/internal/persona/loader/persona.go +++ b/internal/persona/loader/persona.go @@ -53,6 +53,26 @@ type AffectionParams struct { // Persona is a loaded persona definition. It describes who a character is (the // identity, personality, speech style, catchphrase, background, values) and // never restricts what that character can do. +// PersonaVisual carries the persona-owned visual assets (per-role icon/avatar +// and optional chat background). Each persona configures its OWN visuals; the UI +// renders the current persona's resources, so one role never inherits another's. +type PersonaVisual struct { + // Avatar is the persona's avatar image path (e.g. /images/Cyrene_Avatar/...). + Avatar string `yaml:"avatar,omitempty"` + // ChatBackground optionally sets the persona's chat backdrop, per orientation. + ChatBackground ChatBackground `yaml:"chatBackground,omitempty"` +} + +// ChatBackground holds per-orientation chat wallpaper paths (landscape/portrait). +type ChatBackground struct { + Landscape string `yaml:"landscape,omitempty"` + Portrait string `yaml:"portrait,omitempty"` +} + +// DefaultAvatar is the neutral fallback avatar shown when a persona has no +// avatar configured (so a role never silently borrows another persona's view). +const DefaultAvatar = "/images/User_Avatar/Default_Avatar.png" + type Persona struct { ID string `yaml:"id"` Name string `yaml:"name"` @@ -65,6 +85,7 @@ type Persona struct { Values []string `yaml:"values"` Emotion EmotionParams `yaml:"emotion"` Affection AffectionParams `yaml:"affection"` + Visual PersonaVisual `yaml:"visual,omitempty"` } // EmotionConfig converts this persona's own fields into the params consumed by diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index 3ece7cb..b2fe303 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -16,6 +16,7 @@ import ( "errors" "io" "log/slog" + "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" @@ -92,6 +93,8 @@ type Loop struct { tracer *trace.Tracer guards *guardrail.Pipeline personaID string + timeLocation *time.Location + timeZoneName string maxRounds int toolExec string toolParallelism int @@ -126,6 +129,7 @@ func New(opts Options) *Loop { if compactor == nil { compactor = defaultCompactor{} } + timeLocation, timeZoneName := loadTimezone(opts.Timezone, sourcedLog(opts.Log)) return &Loop{ agent: opts.Agent, tools: opts.Tools, @@ -139,6 +143,8 @@ func New(opts Options) *Loop { tracer: opts.Tracer, guards: opts.Guards, personaID: opts.PersonaID, + timeLocation: timeLocation, + timeZoneName: timeZoneName, maxRounds: maxRounds, toolExec: toolExec, toolParallelism: toolParallelism, diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index 2dc7b5c..ba1e79c 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -94,6 +94,13 @@ func formatMemoryBlock(results []memory.Result, marker string) string { // 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)) diff --git a/internal/runtime/loop/options.go b/internal/runtime/loop/options.go index aebb82e..54336c9 100644 --- a/internal/runtime/loop/options.go +++ b/internal/runtime/loop/options.go @@ -58,6 +58,10 @@ type Options struct { Guards *guardrail.Pipeline // PersonaID scopes memory retrieval and extraction. Blank skips both. PersonaID string + // Timezone is the IANA zone used to render the per-round "current time" + // system message (e.g. "Asia/Shanghai"). Empty disables time injection + // entirely (backward compatible); a name that fails to load also disables it. + Timezone string // MaxRounds is the tool-loop upper bound. Non-positive selects // DefaultMaxRounds (8). MaxRounds int diff --git a/internal/runtime/loop/time.go b/internal/runtime/loop/time.go new file mode 100644 index 0000000..0fc1423 --- /dev/null +++ b/internal/runtime/loop/time.go @@ -0,0 +1,42 @@ +package loop + +import ( + "fmt" + "log/slog" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// weekdaysCN maps time.Weekday (0=Sunday) to a Chinese weekday label. +var weekdaysCN = [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + +// loadTimezone resolves the configured IANA timezone into a *time.Location for +// the per-round time injection. An empty name (or one that fails to load) yields +// nil, disabling injection entirely (backward compatible, graceful degradation). +func loadTimezone(name string, log *slog.Logger) (*time.Location, string) { + if name == "" { + return nil, "" + } + loc, err := time.LoadLocation(name) + if err != nil { + log.Warn("timezone load failed; time injection disabled", "tz", name, "error", err) + return nil, "" + } + return loc, name +} + +// currentTimeBlock renders one per-round "current time" system message so the +// model knows the wall-clock time each round (not a first-turn snapshot). It is +// called from assembleContext only when a timezone was configured. It provides +// the raw clock — whether it is "late and time to sleep" is the persona prompt's +// job, not a hard-coded rule here. +func (l *Loop) currentTimeBlock() model.Message { + now := time.Now().In(l.timeLocation) + text := fmt.Sprintf("当前时间:%s %s,时区 %s", + now.Format("2006-01-02 15:04"), + weekdaysCN[now.Weekday()], + l.timeZoneName, + ) + return model.TextMessage(model.RoleSystem, text) +} diff --git a/internal/services/gateway/extra_test.go b/internal/services/gateway/extra_test.go index fab3e3b..563fa36 100644 --- a/internal/services/gateway/extra_test.go +++ b/internal/services/gateway/extra_test.go @@ -21,6 +21,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" @@ -180,6 +181,10 @@ type stubPersonas struct { func (s *stubPersonas) List() []personareg.Character { return s.list } +// Persona satisfies PersonaStore; tests don't rely on visual assets, so it +// always reports no persona found (empty visuals, safe fallback). +func (s *stubPersonas) Persona(string) (*loader.Persona, bool) { return nil, false } + // stubMemories mimics MemoryStore, returning domain memory items that the gateway // maps to MemorySummary. type stubMemories struct { diff --git a/internal/services/gateway/rest.go b/internal/services/gateway/rest.go index bade30f..300d454 100644 --- a/internal/services/gateway/rest.go +++ b/internal/services/gateway/rest.go @@ -24,6 +24,15 @@ type PersonaSummary struct { IsDefault bool `json:"isDefault"` Mode string `json:"mode"` MemoryNS string `json:"memoryNamespace"` + Avatar string `json:"avatar,omitempty"` + // ChatBackground holds per-orientation wallpaper; a role's own, never another's. + ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"` +} + +// ChatBackgroundSummary mirrors the persona chatBackground on the wire. +type ChatBackgroundSummary struct { + Landscape string `json:"landscape,omitempty"` + Portrait string `json:"portrait,omitempty"` } // MemorySummary is the wire shape of a memory entry in the admin list response. @@ -199,13 +208,28 @@ func (s *Server) collectPersonas() []PersonaSummary { if id == "" { id = c.ID } - out = append(out, PersonaSummary{ + sum := PersonaSummary{ ID: id, Name: c.Name, Character: c.Name, IsDefault: c.Default, Mode: firstMode(c.Modes), - }) + } + // Read this persona's own visual assets (avatar / chat background). Each + // role carries its own; a missing persona or unset visual yields empty, + // never another role's — the frontend falls back to a neutral default. + if p, ok := s.personas.Persona(id); ok && p != nil { + if p.Visual.Avatar != "" { + sum.Avatar = p.Visual.Avatar + } + if p.Visual.ChatBackground.Landscape != "" || p.Visual.ChatBackground.Portrait != "" { + sum.ChatBackground = ChatBackgroundSummary{ + Landscape: p.Visual.ChatBackground.Landscape, + Portrait: p.Visual.ChatBackground.Portrait, + } + } + } + out = append(out, sum) } return out } diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index eedff56..bdcdd3b 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -35,6 +35,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" @@ -89,6 +90,10 @@ type SessionStore interface { // domain type so the wire shape stays out of the store contract. type PersonaStore interface { List() []personareg.Character + // Persona returns the persona with the given id (to read its visual assets). + // The production registry implements it; the wire summary reads Avatar / + // ChatBackground from it so each role's visuals stay role-scoped. + Persona(id string) (*loader.Persona, bool) } // MemoryStore lists memory entries. *runtime/memory.Store is the production