feat(webui): add lightweight session messaging via mentions

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 2bdb11eeba
commit 0e184965e8
76 changed files with 8297 additions and 658 deletions
+34 -11
View File
@@ -23,6 +23,7 @@ import type {
ProviderOAuthLoginResult,
ProviderSettingsUpdate,
SessionDeleteResult,
SessionListHandle,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
@@ -166,6 +167,22 @@ function splitKey(key: string): { channel: string; chatId: string } {
return { channel: key.slice(0, idx), chatId: key.slice(idx + 1) };
}
function normalizeSessionListHandle(value: unknown): SessionListHandle | null {
if (!value || typeof value !== "object") return null;
const handle = value as Partial<SessionListHandle>;
const id = typeof handle.id === "string" ? handle.id.trim() : "";
const name = typeof handle.name === "string" ? handle.name.trim() : "";
if (
!/^handle_[a-f0-9]{32}$/i.test(id)
|| !name
|| !/^[\p{L}\p{N}_-]+$/u.test(name)
|| !Number.isInteger(handle.color_slot)
|| (handle.color_slot ?? -1) < 0
|| (handle.color_slot ?? 8) >= 8
) return null;
return { id, name, color_slot: handle.color_slot as number };
}
export async function listSessions(
token: string,
base: string = "",
@@ -179,6 +196,7 @@ export async function listSessions(
model_preset?: string | null;
run_started_at?: number | null;
workspace_scope?: WorkspaceScopePayload | null;
handle?: SessionListHandle | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -186,17 +204,22 @@ export async function listSessions(
undefined,
API_READ_TIMEOUT_MS,
);
return body.sessions.map((s) => ({
key: s.key,
...splitKey(s.key),
createdAt: s.created_at,
updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "",
modelPreset: s.model_preset ?? null,
runStartedAt: s.run_started_at ?? null,
workspaceScope: s.workspace_scope ?? null,
}));
return body.sessions.map((s) => {
const rawSession = normalizeSessionListHandle(s.handle);
const handle = rawSession ? { ...rawSession, session_key: s.key } : null;
return {
key: s.key,
...splitKey(s.key),
createdAt: s.created_at,
updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "",
modelPreset: s.model_preset ?? null,
runStartedAt: s.run_started_at ?? null,
workspaceScope: s.workspace_scope ?? null,
handle,
};
});
}
/** Disk-backed WebUI display thread snapshot (separate from agent session). */
+20 -4
View File
@@ -5,6 +5,7 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
OutboundMedia,
SessionHandle,
SessionMention,
SidebarStatePayload,
GoalStateWsPayload,
@@ -195,7 +196,7 @@ export class NanobotClient {
private knownChats = new Set<string>();
/** Temporary chats are connection-owned and intentionally not reattached. */
private temporaryChatIds = new Set<string>();
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
/** Per-chat run projection, started optimistically and reconciled by lifecycle events. */
private runStartedAtByChatId = new Map<string, number>();
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
private runStartedAtByTurnKey = new Map<string, number>();
@@ -537,6 +538,14 @@ export class NanobotClient {
}
}
private startRunLocally(chatId: string, turnId: string): void {
const startedAt = Date.now() / 1000;
this.runStartedAtByTurnKey.set(this.runSendKey(chatId, turnId), startedAt);
const previous = this.runStartedAtByChatId.get(chatId);
this.runStartedAtByChatId.set(chatId, startedAt);
if (previous !== startedAt) this.emitRunStatus(chatId, startedAt);
}
private settleRunTurn(chatId: string, turnId?: string): void {
if (!turnId) return;
this.clearPendingMessageSend(chatId, turnId);
@@ -716,7 +725,7 @@ export class NanobotClient {
}
}
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
private recordRunStatus(chatId: string, ev: InboundEvent): void {
if (ev.event === "turn_end") {
this.recordRunCompletion(chatId, ev.turn_id);
return;
@@ -967,6 +976,7 @@ export class NanobotClient {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
sessionMentions?: SessionMention[];
sessionHandles?: SessionHandle[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
@@ -986,6 +996,9 @@ export class NanobotClient {
...(options?.sessionMentions?.length
? { session_mentions: options.sessionMentions }
: {}),
...(options?.sessionHandles?.length
? { session_handles: options.sessionHandles }
: {}),
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
@@ -1004,7 +1017,10 @@ export class NanobotClient {
}
if (options?.turnId && !isSystemCommandTurnId(options.turnId)) {
const startsNewRun = options.startsNewRun !== false;
if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId);
if (startsNewRun) {
this.advanceRunGeneration(chatId, options.turnId);
this.startRunLocally(chatId, options.turnId);
}
this.trackPendingMessageSend(chatId, options.turnId, startsNewRun);
}
this.queueSend(frame);
@@ -1240,7 +1256,7 @@ export class NanobotClient {
if (chatId) {
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
this.recordGoalStatusForRunStrip(chatId, parsed);
this.recordRunStatus(chatId, parsed);
if (supersededRunCompletion) return;
this.recordGoalStateSnapshot(chatId, parsed);
this.dispatch(chatId, parsed);
+33 -1
View File
@@ -66,6 +66,8 @@ export interface UIMessage {
mcpPresets?: UIMcpPresetAttachment[];
/** Persisted sessions explicitly referenced by this user turn. */
sessionMentions?: SessionMention[];
/** Active session handles structurally selected by this user turn. */
sessionHandles?: SessionHandle[];
/** Assistant turn: accumulated model reasoning / thinking text. Built up
* incrementally from ``reasoning_delta`` frames; finalized when
* ``reasoning_end`` arrives. */
@@ -79,6 +81,8 @@ export interface UIMessage {
completedAt?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Structured provenance for a message delivered by another session. */
sessionMessage?: UISessionMessage;
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
turnId?: string;
turnPhase?: UITurnPhase;
@@ -110,13 +114,31 @@ export interface UIMcpPresetAttachment {
}
export interface SessionMention {
/** Text token inserted in the composer, without the leading @. */
/** Text token inserted in the composer, without the leading #. */
name: string;
/** Stable persisted-session identifier used by read_session. */
session_key: string;
title: string;
}
/** Exact public handle DTO returned by the session-list endpoint. */
export interface SessionListHandle {
id: string;
name: string;
color_slot: number;
}
/** Public session handle enriched with its UI navigation target. */
export interface SessionHandle extends SessionListHandle {
session_key: string;
}
export interface UISessionMessage {
direction: "incoming" | "outgoing";
message_id: string;
session: SessionListHandle;
}
export interface SessionAutomationJob {
id: string;
name: string;
@@ -337,6 +359,8 @@ export interface ChatSummary {
/** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null;
workspaceScope?: WorkspaceScopePayload | null;
/** Stable, server-owned @handle for this session. */
handle?: SessionHandle | null;
}
export type WorkspaceAccessMode = "restricted" | "full";
@@ -1248,6 +1272,13 @@ export type InboundEvent =
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
} & InboundTurnMetadata)
| ({
event: "session_message";
chat_id: string;
text: string;
created_at_ms: number;
session_message: UISessionMessage;
} & InboundTurnMetadata)
| ({
event: "file_edit";
chat_id: string;
@@ -1442,6 +1473,7 @@ export type Outbound =
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
session_mentions?: SessionMention[];
session_handles?: SessionHandle[];
quoted_context?: string;
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;