feat: 角色视觉体系(头像按角色取)+ 时间注入(loop 每轮当前时间)
【角色视觉体系(红线:每个角色自己的头像/背景,绝不串用昔涟)】
- 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模块绿。红线无写死昔涟头像。
This commit is contained in:
1 parent
950bea9758
commit
7455d09d05
20 files changed
+177
-6
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -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) {
|
||||
|
||||
@@ -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<WsStatus, { label: string; tone: "ok" | "warn" | "neutral"
|
||||
export function TopBar(): React.ReactNode {
|
||||
const wsStatus = useAppStore((s) => s.wsStatus);
|
||||
const mood = useAppStore((s) => s.mood);
|
||||
const currentPersona = useAppStore((s) => s.currentPersona);
|
||||
const mode = useModeStore((s) => s.mode);
|
||||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
||||
|
||||
@@ -41,7 +43,7 @@ export function TopBar(): React.ReactNode {
|
||||
return (
|
||||
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-3 border-b border-[var(--mu-glass-border)] px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="Cyrene" className="h-7 w-7 rounded-full object-cover" />
|
||||
<img src={personaAvatar(currentPersona)} alt={currentPersona?.name ?? "Cyrene"} className="h-7 w-7 rounded-full object-cover" />
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold">Cyrene · 昔涟</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
|
||||
@@ -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 (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
{full ? (
|
||||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
|
||||
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-7 shrink-0" aria-hidden />
|
||||
)}
|
||||
@@ -259,9 +262,10 @@ function AssistantBubble({
|
||||
mode: Mode;
|
||||
active?: boolean;
|
||||
}): React.ReactNode {
|
||||
const currentPersona = useAppStore((s) => s.currentPersona);
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
|
||||
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
<ModeTag mode={mode} />
|
||||
{message.mood ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ChatContext>) => void;
|
||||
setMood: (mood: string | null) => void;
|
||||
|
||||
@@ -88,10 +93,12 @@ export const useAppStore = create<AppStore>()(
|
||||
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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user