feat: 角色视觉体系(头像按角色取)+ 时间注入(loop 每轮当前时间)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【角色视觉体系(红线:每个角色自己的头像/背景,绝不串用昔涟)】
- 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:
Aska committed 2026-09-07 02:46:40 +08:00
1 parent 950bea9758
commit 7455d09d05
20 files changed
+177 -6

No files matched your search

+23 -1
View File
@@ -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) {
+3 -1
View File
@@ -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)]">
+6 -2
View File
@@ -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 ? (
+10
View File
@@ -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;
}
+4
View File
@@ -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
View File
@@ -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 }),