feat(web): 前端接会话持久化——拉历史热分页 + 侧栏会话列表
- store: 消息真源移到后端(sessions元数据map + activeSessionId,partialize 移除 messages 改存会话元数据,version:2 migrate 清旧数据) - api: fetchSessions/createSubSession/fetchMessages(热分页before游标)/deleteSession + MessageDto→toChatMessage - Nav: 侧栏「会话」区——主会话置顶 + 次级列表 + +新建会话(Miuix) - 新增 HistoryList: 顶部「加载更早」向上分页 + 保守虚拟化(>200只渲染最近200) - useChat: send 用 activeSessionId;App: token 后 fetchSessions→激活主会话→拉近50条 - Docs/api-client: v0.10 补 WS sessionId + history 回放事件契约
This commit is contained in:
1 parent
48421923f6
commit
bcc53d68f3
9 files changed
+371
-32
No files matched your search
+10
-3
@@ -22,8 +22,11 @@
|
||||
| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,jwt}}`(24h,owner) |
|
||||
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
|
||||
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉真推理(design/06)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` |
|
||||
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}` |
|
||||
| 5 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` |
|
||||
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt}]}`(主会话 `id="web"` 恒在首位,次级 `web:<uuid>`);未注入时退化回渠道会话列表 |
|
||||
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:<uuid>"`);未注入 HistoryStore 回 `501 not_implemented` |
|
||||
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=` 默认 `limit=50`(上限 200)、`before`=上一页最旧 `createdAt`(毫秒),按时间倒序 `{data:[{id,sessionId,role,content,mode?,mood?,image?,thinking?,createdAt}]}`;非法 `limit`/`before` 回 `400`;未注入 HistoryStore 回 `501` |
|
||||
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
|
||||
| 8 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` |
|
||||
|
||||
## 2. REST 端点(管理,`/api/v1/admin/*`)
|
||||
|
||||
@@ -84,7 +87,9 @@
|
||||
## 3. WebSocket `/ws/chat`(实时)
|
||||
|
||||
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
|
||||
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?}`(`turnId`/`seq` 为流式关联字段,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
|
||||
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
|
||||
|
||||
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。
|
||||
|
||||
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)
|
||||
- **事件壳**:`{type, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。
|
||||
@@ -117,6 +122,8 @@
|
||||
- **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.10`(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选 `sessionId` 字段(能确定会话的事件带上);新增 **`history` 回放事件**——WS 握手成功即回放主会话最新 50 条(`{type:"history", sessionId:"web", data:{messages:[...]}}`,`thinking` 已剥离)。`stream_end`/`stream_chunk`/会话编排状态帧(`session_busy` 等)在能确定 session 时带 `sessionId`;`message.delta` 等结构化流帧因 `model.StreamEvent` 无 session 上下文不带(即"若知道就带上"边界)。
|
||||
- `v0.9`(会话消息持久化 + 主/次级会话,design/16,后端-1):新增 `POST /api/v1/sessions`(新建次级会话)、`GET /api/v1/sessions/{id}/messages?limit=&before=`(游标热分页拉历史,时间倒序)、`DELETE /api/v1/sessions/{id}`(删次级会话及其消息);`GET /api/v1/sessions` 在注入 `HistoryStore` 后改返回持久化会话元数据数组(主 + 次级,`type` 区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。
|
||||
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(临时记录)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
|
||||
- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。
|
||||
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
|
||||
|
||||
@@ -6,7 +6,13 @@ import { AdminApp } from "./admin/AdminApp";
|
||||
import { parseAdminHash } from "./admin/router";
|
||||
import { connectWs, disconnectWs } from "./lib/ws";
|
||||
import { initTheme } from "./lib/theme";
|
||||
import { fetchPersonas, getAuthToken } from "./lib/api";
|
||||
import {
|
||||
fetchMessages,
|
||||
fetchPersonas,
|
||||
fetchSessions,
|
||||
getAuthToken,
|
||||
} from "./lib/api";
|
||||
import type { SessionMeta } from "./lib/types";
|
||||
import { useAppStore } from "./store/useAppStore";
|
||||
|
||||
export default function App(): React.ReactNode {
|
||||
@@ -60,6 +66,55 @@ export default function App(): React.ReactNode {
|
||||
};
|
||||
}, [token, setCurrentPersona]);
|
||||
|
||||
const sessions = useAppStore((s) => s.sessions);
|
||||
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
||||
const setSessions = useAppStore((s) => s.setSessions);
|
||||
const setActiveSession = useAppStore((s) => s.setActiveSession);
|
||||
const setMessages = useAppStore((s) => s.setMessages);
|
||||
|
||||
// Load the session list once we hold a token, then focus a session: a persisted
|
||||
// (still-present) session id, else the main session (design/16 §4.1). The main
|
||||
// session has `type === "main"`; fall back to the first entry, then clear.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
fetchSessions()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
const byId = Object.fromEntries(
|
||||
list.map((s) => [s.id, s] as const)
|
||||
) as Record<string, SessionMeta>;
|
||||
setSessions(list);
|
||||
const main = list.find((s) => s.type === "main") ?? list[0];
|
||||
const next =
|
||||
activeSessionId && byId[activeSessionId] ? activeSessionId : main?.id;
|
||||
setActiveSession(next ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
// Backend not wired yet — leave the shell in its untouched state.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, setSessions, setActiveSession]);
|
||||
|
||||
// Whenever the focused session changes (switch, new, or first load) and it is
|
||||
// a session we know about, pull its initial page of history into the panel.
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !sessions[activeSessionId]) return;
|
||||
let cancelled = false;
|
||||
fetchMessages(activeSessionId, undefined, 50)
|
||||
.then((list) => {
|
||||
if (!cancelled) setMessages(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMessages([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSessionId, sessions, setMessages]);
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||
import { getAuthToken } from "../lib/api";
|
||||
import { createSubSession, getAuthToken } from "../lib/api";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { Tag } from "./primitives";
|
||||
@@ -20,7 +20,12 @@ import { Tag } from "./primitives";
|
||||
function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode {
|
||||
const wsStatus = useAppStore((s) => s.wsStatus);
|
||||
const mood = useAppStore((s) => s.mood);
|
||||
const sessions = useAppStore((s) => s.sessions);
|
||||
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
||||
const setSessions = useAppStore((s) => s.setSessions);
|
||||
const setActiveSession = useAppStore((s) => s.setActiveSession);
|
||||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const connected = wsStatus === "connected";
|
||||
const hasToken = !!getAuthToken();
|
||||
|
||||
@@ -30,6 +35,35 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
|
||||
setTheme(next);
|
||||
};
|
||||
|
||||
// Main session pinned first; secondary sessions follow by creation time.
|
||||
const sessionList = Object.values(sessions).sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "main" ? -1 : 1;
|
||||
return a.createdAt - b.createdAt;
|
||||
});
|
||||
|
||||
const selectSession = (id: string) => {
|
||||
setActiveSession(id);
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
const createSession = async () => {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const meta = await createSubSession();
|
||||
// Optimistically append so the new session shows immediately; the backend
|
||||
// will return it on the next GET /sessions too.
|
||||
const next = Object.values(useAppStore.getState().sessions);
|
||||
setSessions([...next, ...(next.some((s) => s.id === meta.id) ? [] : [meta])]);
|
||||
setActiveSession(meta.id);
|
||||
onNavigate();
|
||||
} catch {
|
||||
// Backend not wired yet — leave the list unchanged.
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-2">
|
||||
@@ -55,6 +89,32 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* conversation sessions (design/16 §2) — main fixed, secondary created */}
|
||||
{hasToken ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">
|
||||
会话
|
||||
</div>
|
||||
{sessionList.map((s) => (
|
||||
<NavItem
|
||||
key={s.id}
|
||||
active={s.id === activeSessionId}
|
||||
icon={s.type === "main" ? "chat" : "folder"}
|
||||
label={s.title ?? (s.type === "main" ? "主会话" : "次级会话")}
|
||||
onClick={() => selectSession(s.id)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => void createSession()}
|
||||
disabled={creating}
|
||||
className="flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98] disabled:opacity-60"
|
||||
>
|
||||
<Icon name="chat" size={18} />
|
||||
<span>{creating ? "创建中…" : "+ 新建会话"}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1">
|
||||
<NavItem
|
||||
icon="settings"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { fetchMessages } from "../../lib/api";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { SplitChatBubble } from "../chat";
|
||||
|
||||
/** Page size for the history cursor (design/16 §5.1); the wire returns rows
|
||||
* newest-first, `fetchMessages` reverses them into display order for us. */
|
||||
const PAGE = 50;
|
||||
/** Conservative DOM cap: beyond this we stop mounting every bubble so an
|
||||
* extremely long session never explodes the DOM (design/16 §5.2). Older messages
|
||||
* already loaded into the store are still fetched on "load earlier"; we just
|
||||
* stop rendering the furthest-back ones. */
|
||||
const MAX_RENDER = 200;
|
||||
|
||||
/**
|
||||
* The scrollable message history for the focused session. Owns the "load earlier"
|
||||
* hot-pagination button at the top and a conservative render cap. Streaming
|
||||
* bubbles (the active turn) render exactly as before via SplitChatBubble.
|
||||
*/
|
||||
export function HistoryList({
|
||||
lastAssistantId,
|
||||
}: {
|
||||
lastAssistantId?: string;
|
||||
}): React.ReactNode {
|
||||
const messages = useAppStore((s) => s.messages);
|
||||
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
||||
const prependMessages = useAppStore((s) => s.prependMessages);
|
||||
const [exhausted, setExhausted] = useState(false);
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false);
|
||||
|
||||
// The oldest loaded message's createdAt is the `before` cursor. We show the
|
||||
// button only after a full page has loaded (so there might be more) and until
|
||||
// an upward fetch returns a short page (signalling the beginning reached).
|
||||
const before = messages[0]?.createdAt;
|
||||
const showLoadEarlier = !exhausted && messages.length >= PAGE;
|
||||
|
||||
const loadEarlier = async () => {
|
||||
if (!activeSessionId || before === undefined || loadingEarlier) return;
|
||||
setLoadingEarlier(true);
|
||||
try {
|
||||
const older = await fetchMessages(activeSessionId, before, PAGE);
|
||||
prependMessages(older);
|
||||
if (older.length < PAGE) setExhausted(true);
|
||||
} catch {
|
||||
// Transient backend not wired / offline — user can retry; keep the button.
|
||||
} finally {
|
||||
setLoadingEarlier(false);
|
||||
}
|
||||
};
|
||||
|
||||
const collapsed = messages.length > MAX_RENDER;
|
||||
const visible = collapsed ? messages.slice(-MAX_RENDER) : messages;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLoadEarlier ? (
|
||||
<button
|
||||
onClick={() => void loadEarlier()}
|
||||
disabled={loadingEarlier}
|
||||
className="mx-auto flex items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2 text-sm text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98] disabled:opacity-60"
|
||||
>
|
||||
{loadingEarlier ? "加载中…" : "↥ 加载更早的消息"}
|
||||
</button>
|
||||
) : null}
|
||||
{collapsed ? (
|
||||
<div className="px-2 text-center text-[11px] text-[var(--mu-text-lo)]">
|
||||
已收起 {messages.length - MAX_RENDER} 条较早消息
|
||||
</div>
|
||||
) : null}
|
||||
{visible.map((m) => (
|
||||
<SplitChatBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
active={
|
||||
m.id === lastAssistantId &&
|
||||
m.role === "assistant" &&
|
||||
(m.streaming ?? false)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AutomationCreateResult,
|
||||
AutomationRunResult,
|
||||
ChannelTestResult,
|
||||
ChatMessage,
|
||||
ChatSyncResult,
|
||||
LlmTestRequest,
|
||||
LlmTestResult,
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
PersonaRecord,
|
||||
PersonaUpsertRequest,
|
||||
Role,
|
||||
SessionMeta,
|
||||
SessionRecord,
|
||||
} from "./types";
|
||||
|
||||
@@ -184,9 +186,74 @@ export function chatStream(
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/v1/sessions — known channel sessions. */
|
||||
export function fetchSessions(): Promise<SessionRecord[]> {
|
||||
return request<SessionRecord[]>("/sessions");
|
||||
/** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors
|
||||
* the `chat_messages` row; `mode`/`mood`/`image`/`thinking` are optional. */
|
||||
export interface MessageDto {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
mode?: Mode;
|
||||
mood?: string;
|
||||
image?: string;
|
||||
thinking?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Map a wire message into a display ChatMessage (carrying its sessionId). */
|
||||
function toChatMessage(m: MessageDto): ChatMessage {
|
||||
return {
|
||||
id: m.id,
|
||||
sessionId: m.sessionId,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
mode: m.mode,
|
||||
mood: m.mood,
|
||||
image: m.image,
|
||||
thinking: m.thinking,
|
||||
createdAt: m.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/v1/sessions — the main + secondary sessions for this web account. */
|
||||
export function fetchSessions(): Promise<SessionMeta[]> {
|
||||
return request<SessionMeta[]>("/sessions");
|
||||
}
|
||||
|
||||
/** POST /api/v1/sessions — creates a new secondary session. */
|
||||
export function createSubSession(title?: string): Promise<SessionMeta> {
|
||||
return request<SessionMeta>("/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(title ? { title } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/v1/sessions/{id}/messages?before=&limit= — hot-paginated history.
|
||||
* The wire returns newest-first, but callers render chronologically (oldest
|
||||
* first), so we reverse here: page 1 is the most recent `limit` messages in
|
||||
* display order, and `before` (the oldest message's createdAt) walks upward. */
|
||||
export async function fetchMessages(
|
||||
sessionId: string,
|
||||
before?: number,
|
||||
limit = 50
|
||||
): Promise<ChatMessage[]> {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("limit", String(limit));
|
||||
if (before !== undefined) qs.set("before", String(before));
|
||||
const list = await request<MessageDto[]>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/messages?${qs.toString()}`
|
||||
);
|
||||
return list.map(toChatMessage).reverse();
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/sessions/{id} — removes a secondary session (and its messages). */
|
||||
export function deleteSession(
|
||||
sessionId: string
|
||||
): Promise<{ id: string; deleted: true }> {
|
||||
return request<{ id: string; deleted: true }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/v1/personas — persona summaries. */
|
||||
|
||||
@@ -59,6 +59,9 @@ export interface SessionStatePayload {
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
/** The owning session (backend `session_id`). Set when loaded via the sessions
|
||||
* API; absent on a client-side optimistic bubble until it is persisted. */
|
||||
sessionId?: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
/** Behavior mode this message was produced in — drives its bubble rendering.
|
||||
@@ -76,6 +79,21 @@ export interface ChatMessage {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** The two web session kinds (design/16 §2.1): the fixed main session that every
|
||||
* turn falls into by default, and a user-created secondary session that shares
|
||||
* the same persona memory. */
|
||||
export type SessionType = "main" | "sub";
|
||||
|
||||
/** A web conversation session as returned by GET /api/v1/sessions. The main
|
||||
* session has a fixed id; a secondary session's id is minted on creation. The
|
||||
* message truth lives server-side, so this is metadata only. */
|
||||
export interface SessionMeta {
|
||||
id: string;
|
||||
type: SessionType;
|
||||
title?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type AgentStatus =
|
||||
| "idle"
|
||||
| "running"
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useSendChat(): {
|
||||
sending: boolean;
|
||||
} {
|
||||
const [sending, setSending] = useState(false);
|
||||
const sessionId = useAppStore((s) => s.sessionId);
|
||||
const sessionId = useAppStore((s) => s.activeSessionId);
|
||||
const addUserMessage = useAppStore((s) => s.addUserMessage);
|
||||
const beginAssistant = useAppStore((s) => s.beginAssistant);
|
||||
const endStream = useAppStore((s) => s.endStream);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { useSendChat } from "../lib/useChat";
|
||||
import { sendUp } from "../lib/ws";
|
||||
import { SplitChatBubble } from "../components/chat";
|
||||
import { HistoryList } from "../components/messages/HistoryList";
|
||||
import { ChatInput } from "../components/ChatInput";
|
||||
import { EmptyState } from "../components/primitives";
|
||||
import { Icon } from "../components/Icon";
|
||||
@@ -49,9 +49,13 @@ export function ChatShell(): React.ReactNode {
|
||||
return undefined;
|
||||
}, [messages]);
|
||||
|
||||
// Scroll to the newest message only when a *new* message reaches the tail
|
||||
// (append/session-switch), not when the user hot-pages older history into the
|
||||
// list — otherwise "load earlier" would yank the viewport to the bottom.
|
||||
const lastMessageId = messages[messages.length - 1]?.id;
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages]);
|
||||
}, [lastMessageId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -77,17 +81,7 @@ export function ChatShell(): React.ReactNode {
|
||||
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
|
||||
/>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<SplitChatBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
active={
|
||||
m.id === lastAssistantId &&
|
||||
m.role === "assistant" &&
|
||||
(m.streaming ?? false)
|
||||
}
|
||||
/>
|
||||
))
|
||||
<HistoryList lastAssistantId={lastAssistantId} />
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
FlowGraph,
|
||||
Mode,
|
||||
PersonaRecord,
|
||||
SessionMeta,
|
||||
ToolUsed,
|
||||
WsStatus,
|
||||
} from "../lib/types";
|
||||
@@ -20,8 +21,15 @@ interface ChatContext {
|
||||
}
|
||||
|
||||
export interface AppStore {
|
||||
sessionId: string;
|
||||
/** Session metadata (main + secondary) keyed by session id. The message truth
|
||||
* lives server-side, so this is a small list — safe to persist. */
|
||||
sessions: Record<string, SessionMeta>;
|
||||
/** The session the UI is currently focused on (design/16 §2.1). "" until the
|
||||
* session list is loaded. Messages in `messages` always belong to this id. */
|
||||
activeSessionId: string;
|
||||
wsStatus: WsStatus;
|
||||
/** Messages of the *active* session, in display order (oldest first). Reset on
|
||||
* session switch and refilled from the backend (design/16 §4/§5). */
|
||||
messages: ChatMessage[];
|
||||
mood: string | null;
|
||||
context: ChatContext;
|
||||
@@ -41,11 +49,21 @@ export interface AppStore {
|
||||
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;
|
||||
|
||||
/** Replace the session metadata list. */
|
||||
setSessions: (list: SessionMeta[]) => void;
|
||||
/** Focus a session and clear its message cache (refilled by the caller). */
|
||||
setActiveSession: (id: string) => void;
|
||||
/** Replace the active session's message list (initial page on switch). */
|
||||
setMessages: (list: ChatMessage[]) => void;
|
||||
/** Prepend an older page (chronological) onto the active session's messages. */
|
||||
prependMessages: (list: ChatMessage[]) => void;
|
||||
/** Append a new message, or merge by id if it already exists (WS/dedup). */
|
||||
appendOrUpdateMessage: (msg: ChatMessage) => void;
|
||||
|
||||
addUserMessage: (text: string, mode?: Mode, image?: string) => void;
|
||||
beginAssistant: (mode?: Mode) => string;
|
||||
appendChunk: (delta: string) => void;
|
||||
@@ -77,7 +95,8 @@ export interface AppStore {
|
||||
export const useAppStore = create<AppStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sessionId: "web",
|
||||
sessions: {},
|
||||
activeSessionId: "",
|
||||
wsStatus: "idle",
|
||||
messages: [],
|
||||
mood: null,
|
||||
@@ -97,12 +116,32 @@ export const useAppStore = create<AppStore>()(
|
||||
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 }),
|
||||
|
||||
setSessions: (list) =>
|
||||
set({
|
||||
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
|
||||
}),
|
||||
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
|
||||
setMessages: (list) => set({ messages: list }),
|
||||
prependMessages: (list) =>
|
||||
set((s) => {
|
||||
const seen = new Set(s.messages.map((m) => m.id));
|
||||
const older = list.filter((m) => !seen.has(m.id));
|
||||
return { messages: [...older, ...s.messages] };
|
||||
}),
|
||||
appendOrUpdateMessage: (msg) =>
|
||||
set((s) => {
|
||||
const idx = s.messages.findIndex((m) => m.id === msg.id);
|
||||
if (idx === -1) return { messages: [...s.messages, msg] };
|
||||
const messages = s.messages.slice();
|
||||
messages[idx] = { ...messages[idx], ...msg };
|
||||
return { messages };
|
||||
}),
|
||||
|
||||
addUserMessage: (text, mode, image) =>
|
||||
set((s) => ({
|
||||
messages: [
|
||||
@@ -249,16 +288,32 @@ export const useAppStore = create<AppStore>()(
|
||||
}),
|
||||
{
|
||||
name: "cyrene.app",
|
||||
version: 1,
|
||||
version: 2,
|
||||
// Upgrade from v1 (which persisted a full `messages` array + `sessionId`):
|
||||
// drop those — the backend is now the message truth — and carry forward the
|
||||
// still-meaningful bits (persona avatar, mood, context, backthink).
|
||||
migrate: (persisted) => {
|
||||
const p = (persisted ?? {}) as Partial<AppStore>;
|
||||
return {
|
||||
activeSessionId: "",
|
||||
sessions: {},
|
||||
mood: p.mood ?? null,
|
||||
context: p.context ?? {},
|
||||
backthink: p.backthink ?? [],
|
||||
currentPersona: p.currentPersona ?? null,
|
||||
};
|
||||
},
|
||||
partialize: (s) => ({
|
||||
sessionId: s.sessionId,
|
||||
messages: s.messages,
|
||||
// messages deliberately excluded: the backend is the message truth
|
||||
// (design/16 §1), so we never persist the whole chat into localStorage.
|
||||
// We keep the (small) session metadata + the focused session id so a
|
||||
// refresh lands on the same conversation, plus the resolved persona for
|
||||
// the avatar/background.
|
||||
activeSessionId: s.activeSessionId,
|
||||
sessions: s.sessions,
|
||||
mood: s.mood,
|
||||
context: s.context,
|
||||
backthink: s.backthink,
|
||||
// Persist the resolved persona so a refresh keeps showing THIS persona's
|
||||
// own avatar/background — never falling back to a neutral placeholder or
|
||||
// (worse) another role's image because the mount-time fetch missed.
|
||||
currentPersona: s.currentPersona,
|
||||
}),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user