feat: IM平台列 + deviceID标识 + 会话持久化完善
- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted) - agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列) - design/16 §4.4(平台) + §4.5(deviceID) 已入档 - 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿 - 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
This commit is contained in:
1 parent
1bf60c1ae4
commit
37e98ec584
26 files changed
+828
-116
No files matched your search
@@ -116,6 +116,19 @@ function statusLabel(status: ToolStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Small muted source label for a persisted message (design/16 §4.5): shows the
|
||||
* concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered
|
||||
* only when a deviceId is present, so legacy/unlabelled messages stay clean. The
|
||||
* Web client defaults to this label; other clients decide themselves. */
|
||||
function SourceLabel({ deviceId }: { deviceId?: string }): React.ReactNode {
|
||||
if (!deviceId) return null;
|
||||
return (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||
来自 {deviceId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
|
||||
* message's own `mode`: the same role renders differently under different modes,
|
||||
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
||||
@@ -188,6 +201,7 @@ function AssistantParaBubble({
|
||||
{full && message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
|
||||
) : null}
|
||||
{full ? <SourceLabel deviceId={message.deviceId} /> : null}
|
||||
{full ? <WakePath active={active} /> : null}
|
||||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||||
@@ -208,7 +222,7 @@ function UserParaBubble({
|
||||
full: boolean;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex justify-end">
|
||||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||||
<div className="max-w-[80%] rounded-[var(--mu-radius-lg)] bg-[var(--mu-accent)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
{full && message.image ? (
|
||||
<img
|
||||
@@ -219,6 +233,7 @@ function UserParaBubble({
|
||||
) : null}
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
</div>
|
||||
{full ? <SourceLabel deviceId={message.deviceId} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -281,7 +296,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
|
||||
) : null;
|
||||
return (
|
||||
<div className="mu-rise-in flex justify-end">
|
||||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||||
<div className="max-w-[80%] rounded-[var(--mu-radius-lg)] bg-[var(--mu-accent)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
{message.image ? (
|
||||
<img
|
||||
@@ -292,6 +307,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
<SourceLabel deviceId={message.deviceId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -316,6 +332,7 @@ function AssistantBubble({
|
||||
{message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
|
||||
) : null}
|
||||
<SourceLabel deviceId={message.deviceId} />
|
||||
<WakePath active={active} />
|
||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
SessionMeta,
|
||||
SessionRecord,
|
||||
} from "./types";
|
||||
import { getClientId } from "./client";
|
||||
|
||||
/**
|
||||
* REST client for the Cyrene v2 gateway. The gateway wraps every success in
|
||||
@@ -160,21 +161,24 @@ export function health(): Promise<{ status: string }> {
|
||||
return request<{ status: string }>("/health");
|
||||
}
|
||||
|
||||
/** POST /api/v1/chat — one synchronous agent turn. */
|
||||
/** POST /api/v1/chat — one synchronous agent turn. The body carries the sender's
|
||||
* persistent per-browser deviceId (design/16 §4.5) so the persisted user and
|
||||
* assistant messages are tagged with the concrete client. */
|
||||
export function chatSync(
|
||||
sessionId: string,
|
||||
text: string
|
||||
): Promise<ChatSyncResult> {
|
||||
return request<ChatSyncResult>("/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId, text }),
|
||||
body: JSON.stringify({ sessionId, text, deviceId: getClientId() }),
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel.
|
||||
* `image` (a base64 data: URI or URL) is attached for the vision path; the
|
||||
* gateway's chat body currently has no image field, so an unconfigured backend
|
||||
* simply ignores it. */
|
||||
* simply ignores it. `deviceId` mirrors the client's per-browser id onto the
|
||||
* persisted message (design/16 §4.5). */
|
||||
export function chatStream(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
@@ -182,7 +186,7 @@ export function chatStream(
|
||||
): Promise<{ stream: true }> {
|
||||
return request<{ stream: true }>("/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId, text, image, stream: true }),
|
||||
body: JSON.stringify({ sessionId, text, image, stream: true, deviceId: getClientId() }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,6 +201,10 @@ export interface MessageDto {
|
||||
mood?: string;
|
||||
image?: string;
|
||||
thinking?: string;
|
||||
/** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */
|
||||
platform?: string;
|
||||
/** Concrete client/device that produced the message (design/16 §4.5). */
|
||||
deviceId?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -211,6 +219,8 @@ function toChatMessage(m: MessageDto): ChatMessage {
|
||||
mood: m.mood,
|
||||
image: m.image,
|
||||
thinking: m.thinking,
|
||||
platform: m.platform,
|
||||
deviceId: m.deviceId,
|
||||
createdAt: m.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -228,18 +238,23 @@ export function createSubSession(title?: string): Promise<SessionMeta> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** GET /api/v1/sessions/{id}/messages?before=&limit=&platform= — hot-paginated
|
||||
* history, optionally filtered to one source platform (design/16 §4.4). 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. The Web client omits
|
||||
* `platform` for the full shared main session; an IM adapter passes its own
|
||||
* platform to see only its lane. */
|
||||
export async function fetchMessages(
|
||||
sessionId: string,
|
||||
before?: number,
|
||||
limit = 50
|
||||
limit = 50,
|
||||
platform?: string
|
||||
): Promise<ChatMessage[]> {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("limit", String(limit));
|
||||
if (before !== undefined) qs.set("before", String(before));
|
||||
if (platform) qs.set("platform", platform);
|
||||
const list = await request<MessageDto[]>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/messages?${qs.toString()}`
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Per-browser persistent client id (design/16 §4.5). Every chat message the Web
|
||||
// client sends carries this id so the agent knows which browser/device the owner
|
||||
// is chatting from — e.g. "web:chrome:a1b2c3". It survives reloads and is shared
|
||||
// by all tabs of the same browser (localStorage), distinguishing multiple browsers
|
||||
// logged into the same account.
|
||||
|
||||
const CLIENT_ID_KEY = "cyrene.clientId";
|
||||
|
||||
/** Short, human-readable browser key derived from the navigator user agent, so the
|
||||
* deviceID reads like "chrome" rather than a raw UA string. */
|
||||
function browserKey(): string {
|
||||
const ua = navigator.userAgent;
|
||||
if (/edg\//i.test(ua)) return "edge";
|
||||
if (/firefox\//i.test(ua)) return "firefox";
|
||||
if (/chrome\//i.test(ua)) return "chrome";
|
||||
if (/safari\//i.test(ua)) return "safari";
|
||||
return "browser";
|
||||
}
|
||||
|
||||
/** Eight lowercase hex characters of random entropy (one short id token). */
|
||||
function randomToken(): string {
|
||||
if (typeof crypto !== "undefined" && "getRandomValues" in crypto) {
|
||||
const bytes = new Uint8Array(4);
|
||||
crypto.getRandomValues(bytes);
|
||||
let out = "";
|
||||
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
return Math.random().toString(16).slice(2, 10);
|
||||
}
|
||||
|
||||
/** Persistent, per-browser client id: "web:<browser>:<randomToken>". Stored in
|
||||
* localStorage so every tab of the same browser shares one id and it survives
|
||||
* reloads. Degrades to a fresh id when localStorage is unavailable (privacy mode
|
||||
* / a page that blocked storage): the message is still tagged, just not stable. */
|
||||
export function getClientId(): string {
|
||||
const existing = localStorage.getItem(CLIENT_ID_KEY);
|
||||
if (existing && existing.startsWith("web:")) return existing;
|
||||
const id = `web:${browserKey()}:${randomToken()}`;
|
||||
try {
|
||||
localStorage.setItem(CLIENT_ID_KEY, id);
|
||||
} catch {
|
||||
// Storage unavailable: fall back to the freshly generated id for this call.
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -76,6 +76,18 @@ export interface ChatMessage {
|
||||
* as an image card. Absent for legacy persisted messages, so renders fall back
|
||||
* to plain text. */
|
||||
image?: string;
|
||||
/** Source-platform tag of the message (design/16 §4.4): "web" for messages that
|
||||
* originate on the Web main session, or an IM channel (e.g. "telegram",
|
||||
* "weixin-ilink", "qq-obv11") for messages mirrored in from an IM adapter. The
|
||||
* Web client renders the full session; an IM adapter filters to its own platform.
|
||||
* Absent on legacy/client-optimistic messages. */
|
||||
platform?: string;
|
||||
/** Concrete client/device that produced the message (design/16 §4.5), e.g.
|
||||
* "web:chrome:a1b2c3" for a browser or "weixin-ilink:conf0:wx_uid" for an IM
|
||||
* channel. Complements platform (which only names the platform). The Web client
|
||||
* defaults to labeling the bubble source when this is present; absent on
|
||||
* legacy/client-optimistic messages kept without it. */
|
||||
deviceId?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { chatStream } from "./api";
|
||||
import { connectWs, isWsOpen } from "./ws";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { getClientId } from "./client";
|
||||
import { now } from "./util";
|
||||
|
||||
/** Drives one user turn: push the user bubble, blanket a streaming assistant
|
||||
@@ -38,8 +39,11 @@ export function useSendChat(): {
|
||||
// The gateway's chat body still requires a non-empty text, so a pure image
|
||||
// turn (vision) falls back to a default prompt to keep the trigger valid.
|
||||
const prompt = trimmed || (image ? "分析这张图片" : "");
|
||||
addUserMessage(trimmed, undefined, image);
|
||||
beginAssistant(mode);
|
||||
// Tag this browser's persistent client id onto the optimistic bubbles so the
|
||||
// source label shows immediately and matches the persisted turn (design/16 §4.5).
|
||||
const clientId = getClientId();
|
||||
addUserMessage(trimmed, undefined, image, clientId);
|
||||
beginAssistant(mode, clientId);
|
||||
|
||||
try {
|
||||
await chatStream(sessionId, prompt, image);
|
||||
|
||||
@@ -174,6 +174,8 @@ function parsePersistedMessage(v: unknown): ChatMessage | null {
|
||||
mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined,
|
||||
mood: typeof r.mood === "string" ? r.mood : undefined,
|
||||
image: typeof r.image === "string" ? r.image : undefined,
|
||||
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
|
||||
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
|
||||
createdAt: typeof r.createdAt === "number" ? r.createdAt : now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export interface AppStore {
|
||||
* backend id) so a message never renders twice (design/16 §4.3 dedup). */
|
||||
appendOrUpdateMessage: (msg: ChatMessage) => void;
|
||||
|
||||
addUserMessage: (text: string, mode?: Mode, image?: string) => void;
|
||||
beginAssistant: (mode?: Mode) => string;
|
||||
addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string) => void;
|
||||
beginAssistant: (mode?: Mode, deviceId?: string) => string;
|
||||
appendChunk: (delta: string) => void;
|
||||
endStream: () => void;
|
||||
setThinking: (text: string) => void;
|
||||
@@ -172,6 +172,8 @@ export const useAppStore = create<AppStore>()(
|
||||
mode: msg.mode ?? m.mode,
|
||||
mood: msg.mood ?? m.mood,
|
||||
image: msg.image ?? m.image,
|
||||
platform: msg.platform ?? m.platform,
|
||||
deviceId: msg.deviceId ?? m.deviceId,
|
||||
createdAt: msg.createdAt ?? m.createdAt,
|
||||
};
|
||||
return { messages };
|
||||
@@ -181,7 +183,7 @@ export const useAppStore = create<AppStore>()(
|
||||
return { messages: [...s.messages, msg] };
|
||||
}),
|
||||
|
||||
addUserMessage: (text, mode, image) =>
|
||||
addUserMessage: (text, mode, image, deviceId) =>
|
||||
set((s) => ({
|
||||
messages: [
|
||||
...s.messages,
|
||||
@@ -191,12 +193,13 @@ export const useAppStore = create<AppStore>()(
|
||||
content: text,
|
||||
mode,
|
||||
image,
|
||||
deviceId,
|
||||
createdAt: now(),
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
beginAssistant: (mode) => {
|
||||
beginAssistant: (mode, deviceId) => {
|
||||
const id = uid();
|
||||
set((s) => ({
|
||||
messages: [
|
||||
@@ -208,6 +211,7 @@ export const useAppStore = create<AppStore>()(
|
||||
mode,
|
||||
streaming: true,
|
||||
tools: [],
|
||||
deviceId,
|
||||
createdAt: now(),
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user