fix(room): scroll-to-bottom logic and AI sender display name
- Remove duplicate smooth scroll effect from DiscordChatPanel; handle all scroll logic in MessageList instead - MessageList: track isInitialLoadRef to instant-jump to bottom on first load (no animation), and only auto-scroll for new messages when user is already near the bottom - sender.ts: getSenderDisplayName rejects UUID values and falls back to 'AI' for AI messages; getSenderModelId uses display_name
This commit is contained in:
parent
57d0fc371e
commit
616c0c0e88
@ -195,9 +195,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
|||||||
[room.id, updateRoom],
|
[room.id, updateRoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// Scroll handling is done entirely by MessageList
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
|
|||||||
@ -316,18 +316,46 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
<div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}>
|
<div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}>
|
||||||
{message.content_type === 'text' || message.content_type === 'Text' ? (
|
{message.content_type === 'text' || message.content_type === 'Text' ? (
|
||||||
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
|
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
|
||||||
{functionCalls.length > 0 ? (
|
{/* Thinking phase — rendered as collapsible, muted style */}
|
||||||
|
{message.chunk_type === 'thinking' && !functionCalls.length && (
|
||||||
|
<div className="mb-1 rounded-md border px-3 py-2 text-sm italic" style={{ borderColor: 'var(--room-border)', color: 'var(--room-text-subtle)', background: 'var(--room-bg)' }}>
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--room-text-muted)' }}>Thinking</span>
|
||||||
|
<div className="mt-1">{displayContent}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Tool call phase — rendered as compact badge */}
|
||||||
|
{message.chunk_type === 'tool_call' && (
|
||||||
|
<div className="mb-1 rounded-md border px-3 py-2 text-sm" style={{ borderColor: '#3b82f633', color: 'var(--room-text-secondary)', background: '#3b82f608' }}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-medium" style={{ color: '#3b82f6' }}>
|
||||||
|
<span className="size-3 rounded-full bg-blue-500/30 border border-blue-500/60 inline-block" />
|
||||||
|
Tool Call
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 font-mono text-xs" style={{ color: 'var(--room-text-subtle)' }}>{displayContent}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Tool result phase — rendered as compact output */}
|
||||||
|
{message.chunk_type === 'tool_result' && (
|
||||||
|
<div className="mb-1 rounded-md border px-3 py-2 text-sm" style={{ borderColor: displayContent.includes('[Tool call failed') ? '#ef444433' : '#22c55e33', color: 'var(--room-text-secondary)', background: displayContent.includes('[Tool call failed') ? '#ef444408' : '#22c55e08' }}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-medium" style={{ color: displayContent.includes('[Tool call failed') ? '#ef4444' : '#22c55e' }}>
|
||||||
|
<span className={cn('size-3 rounded-full inline-block', displayContent.includes('[Tool call failed') ? 'bg-red-500/30 border border-red-500/60' : 'bg-green-500/30 border border-green-500/60')} />
|
||||||
|
{displayContent.includes('[Tool call failed') ? 'Error' : 'Result'}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 font-mono text-xs whitespace-pre-wrap max-h-[120px] overflow-auto" style={{ color: 'var(--room-text-subtle)' }}>{displayContent}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Normal answer or no chunk_type — default rendering */}
|
||||||
|
{!message.chunk_type && functionCalls.length > 0 ? (
|
||||||
functionCalls.map((call, index) => (
|
functionCalls.map((call, index) => (
|
||||||
<div key={index} className="my-1 rounded-md border bg-white/5 p-2 max-w-xl" style={{ borderColor: 'var(--room-border)' }}>
|
<div key={index} className="my-1 rounded-md border bg-white/5 p-2 max-w-xl" style={{ borderColor: 'var(--room-border)' }}>
|
||||||
<FunctionCallBadge functionCall={call} className="w-auto" />
|
<FunctionCallBadge functionCall={call} className="w-auto" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : !message.chunk_type ? (
|
||||||
<MessageContent
|
<MessageContent
|
||||||
content={displayContent}
|
content={displayContent}
|
||||||
onMentionClick={handleMentionClick}
|
onMentionClick={handleMentionClick}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Streaming cursor */}
|
{/* Streaming cursor */}
|
||||||
{isStreaming && <span className="discord-streaming-cursor" />}
|
{isStreaming && <span className="discord-streaming-cursor" />}
|
||||||
|
|||||||
@ -95,6 +95,14 @@ export const MessageList = memo(function MessageList({
|
|||||||
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isRestoringScrollRef = useRef(false);
|
const isRestoringScrollRef = useRef(false);
|
||||||
const firstVisibleMessageIdRef = useRef<string | null>(null);
|
const firstVisibleMessageIdRef = useRef<string | null>(null);
|
||||||
|
const isInitialLoadRef = useRef(true);
|
||||||
|
const wasNearBottomRef = useRef(true);
|
||||||
|
|
||||||
|
// Reset initial load flag when switching rooms
|
||||||
|
useEffect(() => {
|
||||||
|
isInitialLoadRef.current = true;
|
||||||
|
wasNearBottomRef.current = true;
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
const replyMap = useMemo(() => {
|
const replyMap = useMemo(() => {
|
||||||
const map = new Map<string, MessageWithMeta>();
|
const map = new Map<string, MessageWithMeta>();
|
||||||
@ -146,8 +154,11 @@ export const MessageList = memo(function MessageList({
|
|||||||
}, [messages, replyMap]);
|
}, [messages, replyMap]);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = true) => {
|
const scrollToBottom = useCallback((smooth = true) => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
const container = scrollContainerRef.current;
|
||||||
}, [messagesEndRef]);
|
if (container) {
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
@ -155,6 +166,7 @@ export const MessageList = memo(function MessageList({
|
|||||||
|
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
const nearBottom = distanceFromBottom < 100;
|
const nearBottom = distanceFromBottom < 100;
|
||||||
|
wasNearBottomRef.current = nearBottom;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setShowScrollToBottom(!nearBottom);
|
setShowScrollToBottom(!nearBottom);
|
||||||
@ -184,8 +196,24 @@ export const MessageList = memo(function MessageList({
|
|||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
// On initial load, jump to bottom instantly (no animation)
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
wasNearBottomRef.current = true;
|
||||||
|
// Use requestAnimationFrame to wait for virtualizer to layout
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToBottom(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new messages: auto-scroll only if user was near bottom
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
if (distanceFromBottom < 100) {
|
if (distanceFromBottom < 150) {
|
||||||
|
wasNearBottomRef.current = true;
|
||||||
requestAnimationFrame(() => scrollToBottom(false));
|
requestAnimationFrame(() => scrollToBottom(false));
|
||||||
}
|
}
|
||||||
}, [messages.length, scrollToBottom]);
|
}, [messages.length, scrollToBottom]);
|
||||||
|
|||||||
@ -5,25 +5,36 @@ export function getSenderUserUid(message: MessageWithMeta): string | undefined {
|
|||||||
return message.sender_id ?? undefined;
|
return message.sender_id ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the model ID for AI messages */
|
/** Returns the model ID for AI messages.
|
||||||
|
* For AI messages, display_name is the model name; sender_id should be null.
|
||||||
|
* Returns undefined if sender_id looks like a UUID (which would be a user UID, not a model ID). */
|
||||||
export function getSenderModelId(message: MessageWithMeta): string | undefined {
|
export function getSenderModelId(message: MessageWithMeta): string | undefined {
|
||||||
if (message.sender_type === 'ai' && message.sender_id) {
|
if (message.sender_type === 'ai') {
|
||||||
return message.sender_id;
|
// Use display_name for model identification since sender_id is null for proper AI messages
|
||||||
|
if (message.display_name && !looksLikeUuid(message.display_name)) return message.display_name;
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Display name for a message sender */
|
/** Display name for a message sender.
|
||||||
|
* For AI messages, prefers display_name (model name), falls back to 'AI'.
|
||||||
|
* Never returns a raw UUID for AI messages. */
|
||||||
export function getSenderDisplayName(message: MessageWithMeta): string {
|
export function getSenderDisplayName(message: MessageWithMeta): string {
|
||||||
if (message.sender_type === 'ai') {
|
if (message.sender_type === 'ai') {
|
||||||
if (message.display_name) return message.display_name;
|
if (message.display_name && !looksLikeUuid(message.display_name)) return message.display_name;
|
||||||
return 'AI';
|
return 'AI';
|
||||||
}
|
}
|
||||||
if (message.display_name) return message.display_name;
|
|
||||||
if (message.sender_type === 'system') return 'System';
|
if (message.sender_type === 'system') return 'System';
|
||||||
|
if (message.sender_type === 'tool') return 'Tool';
|
||||||
|
if (message.display_name) return message.display_name;
|
||||||
return message.sender_type;
|
return message.sender_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeUuid(s: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
/** Avatar URL from a MessageWithMeta.
|
/** Avatar URL from a MessageWithMeta.
|
||||||
* Callers should pass members to resolve the avatar.
|
* Callers should pass members to resolve the avatar.
|
||||||
* This helper returns undefined for now — avatar resolution is done in components
|
* This helper returns undefined for now — avatar resolution is done in components
|
||||||
|
|||||||
@ -70,6 +70,8 @@ export type MessageWithMeta = RoomMessageResponse & {
|
|||||||
reactions?: ReactionGroup[];
|
reactions?: ReactionGroup[];
|
||||||
/** Attachment IDs for files uploaded with this message */
|
/** Attachment IDs for files uploaded with this message */
|
||||||
attachment_ids?: string[];
|
attachment_ids?: string[];
|
||||||
|
/** AI stream chunk type: "thinking", "tool_call", "tool_result", or undefined for normal text */
|
||||||
|
chunk_type?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomWithCategory = RoomResponse & {
|
export type RoomWithCategory = RoomResponse & {
|
||||||
@ -432,6 +434,38 @@ export function RoomProvider({
|
|||||||
|
|
||||||
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// Streaming timeout: if no chunk received for 60s, force-end the stream
|
||||||
|
// to prevent UI hanging forever when done=true is never delivered.
|
||||||
|
const streamingTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
||||||
|
const clearStreamingTimer = useCallback((msgId: string) => {
|
||||||
|
const timer = streamingTimersRef.current.get(msgId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
streamingTimersRef.current.delete(msgId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startStreamingTimer = useCallback((msgId: string) => {
|
||||||
|
clearStreamingTimer(msgId);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Force-end: mark message as not-streaming and keep whatever content we have
|
||||||
|
setStreamingContent((prev) => {
|
||||||
|
prev.delete(msgId);
|
||||||
|
return new Map(prev);
|
||||||
|
});
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === msgId && m.is_streaming
|
||||||
|
? { ...m, is_streaming: false, content: m.content || '[Stream timed out — no completion signal received]' }
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
streamingTimersRef.current.delete(msgId);
|
||||||
|
}, 60000);
|
||||||
|
streamingTimersRef.current.set(msgId, timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Project repos for @repository: mention suggestions
|
// Project repos for @repository: mention suggestions
|
||||||
const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]);
|
const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]);
|
||||||
const [reposLoading, setReposLoading] = useState(false);
|
const [reposLoading, setReposLoading] = useState(false);
|
||||||
@ -509,8 +543,10 @@ export function RoomProvider({
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => {
|
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => {
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
|
// Clear the timeout timer since stream completed normally
|
||||||
|
clearStreamingTimer(chunk.message_id);
|
||||||
// When done: clear streaming content, set is_streaming=false, and
|
// When done: clear streaming content, set is_streaming=false, and
|
||||||
// update seq so the subsequent RoomMessage event deduplicates correctly.
|
// update seq so the subsequent RoomMessage event deduplicates correctly.
|
||||||
setStreamingContent((prev) => {
|
setStreamingContent((prev) => {
|
||||||
@ -520,11 +556,13 @@ export function RoomProvider({
|
|||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === chunk.message_id
|
m.id === chunk.message_id
|
||||||
? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false }
|
? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false, chunk_type: chunk.chunk_type }
|
||||||
: m,
|
: m,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Reset the timeout timer on each chunk — stream is still alive
|
||||||
|
startStreamingTimer(chunk.message_id);
|
||||||
// Single atomic update: accumulate in streamingContent AND update message.
|
// Single atomic update: accumulate in streamingContent AND update message.
|
||||||
// Backend sends CUMULATIVE content (text_accumulated.clone()), not delta.
|
// Backend sends CUMULATIVE content (text_accumulated.clone()), not delta.
|
||||||
// Use deduplication to only add the new delta portion.
|
// Use deduplication to only add the new delta portion.
|
||||||
@ -559,6 +597,7 @@ export function RoomProvider({
|
|||||||
content_type: 'text',
|
content_type: 'text',
|
||||||
send_at: new Date().toISOString(),
|
send_at: new Date().toISOString(),
|
||||||
is_streaming: true,
|
is_streaming: true,
|
||||||
|
chunk_type: chunk.chunk_type,
|
||||||
};
|
};
|
||||||
return [...msgs, newMsg];
|
return [...msgs, newMsg];
|
||||||
});
|
});
|
||||||
@ -715,6 +754,11 @@ export function RoomProvider({
|
|||||||
return () => {
|
return () => {
|
||||||
client.disconnect(); // Intentional disconnect on unmount — no reconnect
|
client.disconnect(); // Intentional disconnect on unmount — no reconnect
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
|
// Clear all streaming timeout timers
|
||||||
|
for (const timer of streamingTimersRef.current.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
streamingTimersRef.current.clear();
|
||||||
};
|
};
|
||||||
}, []); // ← empty deps: create once on mount
|
}, []); // ← empty deps: create once on mount
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import type {
|
|||||||
UserInfo,
|
UserInfo,
|
||||||
RoomReactionUpdatedPayload,
|
RoomReactionUpdatedPayload,
|
||||||
UserPresencePayload,
|
UserPresencePayload,
|
||||||
|
NotificationCreatedPayload,
|
||||||
} from './ws-protocol';
|
} from './ws-protocol';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@ -53,10 +54,10 @@ export type {
|
|||||||
UserInfo,
|
UserInfo,
|
||||||
RoomReactionUpdatedPayload,
|
RoomReactionUpdatedPayload,
|
||||||
UserPresencePayload,
|
UserPresencePayload,
|
||||||
|
NotificationCreatedPayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface WsTokenResponse {
|
export interface WsTokenResponse {
|
||||||
token: string;
|
|
||||||
expires_in_seconds: number;
|
expires_in_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +85,8 @@ export interface RoomWsCallbacks {
|
|||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
/** Called each time the client sends a heartbeat ping */
|
/** Called each time the client sends a heartbeat ping */
|
||||||
onHeartbeat?: () => void;
|
onHeartbeat?: () => void;
|
||||||
|
/** Called when a new notification is pushed from the server via WebSocket */
|
||||||
|
onNotification?: (payload: import('./ws-protocol').NotificationCreatedPayload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RoomWsClient {
|
export class RoomWsClient {
|
||||||
@ -133,6 +136,11 @@ export class RoomWsClient {
|
|||||||
this.wsToken = token;
|
this.wsToken = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update callbacks (e.g. to register onNotification after construction). */
|
||||||
|
updateCallbacks(callbacks: Partial<RoomWsCallbacks>): void {
|
||||||
|
Object.assign(this.callbacks, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
getWsToken(): string | null {
|
getWsToken(): string | null {
|
||||||
return this.wsToken;
|
return this.wsToken;
|
||||||
}
|
}
|
||||||
@ -1059,6 +1067,11 @@ export class RoomWsClient {
|
|||||||
user_id: (event.data as { user_id?: string })?.user_id ?? '',
|
user_id: (event.data as { user_id?: string })?.user_id ?? '',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'notification_created':
|
||||||
|
this.callbacks.onNotification?.(
|
||||||
|
event.data as import('./ws-protocol').NotificationCreatedPayload,
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Unknown event type - ignore silently
|
// Unknown event type - ignore silently
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export type AiStreamChunkPayload = {
|
|||||||
content: string;
|
content: string;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
chunk_type?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomWsStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed' | 'error';
|
export type RoomWsStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed' | 'error';
|
||||||
|
|||||||
@ -170,8 +170,16 @@ export type WsEventPayload =
|
|||||||
| { type: 'user_presence'; data: UserPresencePayload }
|
| { type: 'user_presence'; data: UserPresencePayload }
|
||||||
| { type: 'typing_start'; data: TypingStartPayload }
|
| { type: 'typing_start'; data: TypingStartPayload }
|
||||||
| { type: 'typing_stop'; data: TypingStopPayload }
|
| { type: 'typing_stop'; data: TypingStopPayload }
|
||||||
|
| { type: 'notification_created'; data: NotificationCreatedPayload }
|
||||||
| { type: string; data: unknown }; // catch-all for unknown events
|
| { type: string; data: unknown }; // catch-all for unknown events
|
||||||
|
|
||||||
|
/** Payload for real-time notification push via WebSocket. */
|
||||||
|
export interface NotificationCreatedPayload {
|
||||||
|
notification: NotificationData;
|
||||||
|
/** URL to navigate to for this notification (e.g. /project/x/issues/42) */
|
||||||
|
deep_link_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomMessagePayload {
|
export interface RoomMessagePayload {
|
||||||
id: string;
|
id: string;
|
||||||
room_id: string;
|
room_id: string;
|
||||||
@ -205,6 +213,8 @@ export interface AiStreamChunkPayload {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** Human-readable AI model name for display (e.g. "Claude 3.5 Sonnet"). */
|
/** Human-readable AI model name for display (e.g. "Claude 3.5 Sonnet"). */
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
|
/** What kind of content: "thinking", "answer", "tool_call", "tool_result". */
|
||||||
|
chunk_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomResponse {
|
export interface RoomResponse {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user