feat(chat): enhance chat functionality with beforeSend hooks and composer status components (#927)

This commit is contained in:
Felix
2026-04-28 16:10:08 +08:00
committed by GitHub
parent 9c930b2198
commit ebeb2893a1
4 changed files with 84 additions and 4 deletions

View File

@@ -8,5 +8,9 @@ export type {
SidebarExtension,
RouteExtension,
SettingsSectionExtension,
ChatBeforeSendContext,
ChatBeforeSendResult,
ChatComposerStatusProps,
ChatExtension,
I18nResources,
} from './types';

View File

@@ -1,10 +1,13 @@
import i18n from '@/i18n';
import i18n from 'i18next';
import type {
RendererExtension,
NavItemDef,
RouteDef,
SettingsSectionDef,
SkillDetailMetaProps,
ChatComposerStatusProps,
ChatBeforeSendContext,
ChatBeforeSendResult,
} from './types';
import type { ComponentType } from 'react';
@@ -62,6 +65,33 @@ class RendererExtensionRegistry {
return this.extensions.flatMap((ext) => ext.skills?.detailMetaComponents ?? []);
}
getChatComposerStatusComponents(): ComponentType<ChatComposerStatusProps>[] {
return this.extensions.flatMap((ext) => ext.chat?.composerStatusComponents ?? []);
}
hasChatBeforeSendHooks(): boolean {
return this.extensions.some((ext) => (ext.chat?.beforeSend?.length ?? 0) > 0);
}
async runChatBeforeSend(context: ChatBeforeSendContext): Promise<ChatBeforeSendResult> {
for (const ext of this.extensions) {
for (const hook of ext.chat?.beforeSend ?? []) {
try {
const result = await hook(context);
if (!result.ok) {
return result;
}
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
}
return { ok: true };
}
async initializeAll(): Promise<void> {
for (const ext of this.extensions) {
try {

View File

@@ -1,5 +1,6 @@
import type { ComponentType } from 'react';
import type { Skill } from '../types/skill';
import type { GatewayStatus } from '../types/gateway';
export interface NavItemDef {
to: string;
@@ -47,12 +48,36 @@ export interface SkillsExtension {
detailMetaComponents?: ComponentType<SkillDetailMetaProps>[];
}
export interface ChatBeforeSendContext {
text: string;
attachments?: unknown[];
targetAgentId?: string | null;
}
export interface ChatBeforeSendResult {
ok: boolean;
message?: string;
}
export interface ChatComposerStatusProps {
gatewayStatus: GatewayStatus;
}
export interface ChatExtension {
id: string;
composerStatusComponents?: ComponentType<ChatComposerStatusProps>[];
beforeSend?: Array<(
context: ChatBeforeSendContext,
) => ChatBeforeSendResult | Promise<ChatBeforeSendResult>>;
}
export interface RendererExtension {
id: string;
sidebar?: SidebarExtension;
routes?: RouteExtension;
settings?: SettingsSectionExtension;
skills?: SkillsExtension;
chat?: ChatExtension;
i18nResources?: I18nResources;
setup?(): void | Promise<void>;
teardown?(): void;

View File

@@ -18,6 +18,8 @@ import { useAgentsStore } from '@/stores/agents';
import { useChatStore } from '@/stores/chat';
import type { AgentSummary } from '@/types/agent';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { rendererExtensionRegistry } from '@/extensions/registry';
// ── Types ────────────────────────────────────────────────────────
@@ -109,6 +111,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
[agents, targetAgentId],
);
const showAgentPicker = mentionableAgents.length > 0;
const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents();
// Auto-resize textarea
useEffect(() => {
@@ -288,13 +291,28 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
const canSend = (input.trim() || attachments.length > 0) && allReady && !disabled && !sending;
const canStop = sending && !disabled && !!onStop;
const handleSend = useCallback(() => {
const handleSend = useCallback(async () => {
if (!canSend) return;
const readyAttachments = attachments.filter(a => a.status === 'ready');
// Capture values before clearing — clear input immediately for snappy UX,
// but keep attachments available for the async send
const textToSend = input.trim();
const attachmentsToSend = readyAttachments.length > 0 ? readyAttachments : undefined;
if (rendererExtensionRegistry.hasChatBeforeSendHooks()) {
const guard = await rendererExtensionRegistry.runChatBeforeSend({
text: textToSend,
attachments: attachmentsToSend,
targetAgentId,
});
if (!guard.ok) {
if (guard.message) {
toast.error(guard.message);
}
return;
}
}
// Capture values before clearing — clear input immediately for snappy UX,
// but keep attachments available for the async send
console.log(`[handleSend] text="${textToSend.substring(0, 50)}", attachments=${attachments.length}, ready=${readyAttachments.length}, sending=${!!attachmentsToSend}`);
if (attachmentsToSend) {
console.log('[handleSend] Attachment details:', attachmentsToSend.map(a => ({
@@ -530,6 +548,9 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
pid: gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : '',
})}
</span>
{chatComposerStatusComponents.map((Component, index) => (
<Component key={`${index}`} gatewayStatus={gatewayStatus} />
))}
</div>
{hasFailedAttachments && (
<Button