mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 00:36:03 +00:00
* feat: add session message monitoring tab to bot detail dialog Add a new "Sessions" tab in the bot detail dialog that displays sent & received messages grouped by sessions. Users can select any session to view its messages in a chat-bubble style layout. Backend changes: - Add sessionId filter to monitoring messages endpoint - Add role column to MonitoringMessage (user/assistant) - Record bot responses in monitoring via record_query_response() - Add DB migration (dbm019) for the new role column Frontend changes: - New BotSessionMonitor component with session list + message viewer - Add Sessions sidebar tab to BotDetailDialog - Add getBotSessions/getSessionMessages API methods to BackendClient - Add i18n translations (en-US, zh-Hans, zh-Hant, ja-JP) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * refactor: remove outdated version comment from PipelineManager class * fix: bump required_database_version to 19 to trigger monitoring_messages.role migration * fix: prevent session message auto-scroll from pushing dialog content out of view Replace scrollIntoView (which scrolls all ancestor containers) with direct scrollTop manipulation on the ScrollArea viewport. This keeps the scroll contained within the messages panel only. * ui: redesign BotSessionMonitor with polished chat UI - Wider session list (w-72) with avatar circles and cleaner layout - Richer chat header with avatar, platform info, and active indicator - User messages now use blue-500 (solid) instead of blue-100 for clear visual distinction - Metadata (time, runner) shown on hover below bubbles, not inside - Proper empty state illustrations for both panels - Better spacing, rounded corners, and shadow treatment - Consistent dark mode styling * fix: infinite re-render loop in DynamicFormComponent The useEffect depended on onSubmit which was a new closure every parent render. Calling onSubmit inside the effect triggered parent state update → re-render → new onSubmit ref → effect re-runs → loop. Fix: use useRef to hold a stable reference to onSubmit, removing it from the useEffect dependency array. Also add DialogDescription to BotDetailDialog to suppress Radix aria-describedby warning. * fix: remove .html suffix from docs.langbot.app links (Mintlify migration) * style: fix prettier and ruff formatting --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
213 lines
6.4 KiB
TypeScript
213 lines
6.4 KiB
TypeScript
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||
import { useForm } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import { z } from 'zod';
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from '@/components/ui/form';
|
||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||
import { useEffect, useRef } from 'react';
|
||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||
|
||
export default function DynamicFormComponent({
|
||
itemConfigList,
|
||
onSubmit,
|
||
initialValues,
|
||
onFileUploaded,
|
||
}: {
|
||
itemConfigList: IDynamicFormItemSchema[];
|
||
onSubmit?: (val: object) => unknown;
|
||
initialValues?: Record<string, object>;
|
||
onFileUploaded?: (fileKey: string) => void;
|
||
}) {
|
||
const isInitialMount = useRef(true);
|
||
const previousInitialValues = useRef(initialValues);
|
||
|
||
// 根据 itemConfigList 动态生成 zod schema
|
||
const formSchema = z.object(
|
||
itemConfigList.reduce(
|
||
(acc, item) => {
|
||
let fieldSchema;
|
||
switch (item.type) {
|
||
case 'integer':
|
||
fieldSchema = z.number();
|
||
break;
|
||
case 'float':
|
||
fieldSchema = z.number();
|
||
break;
|
||
case 'boolean':
|
||
fieldSchema = z.boolean();
|
||
break;
|
||
case 'string':
|
||
fieldSchema = z.string();
|
||
break;
|
||
case 'array[string]':
|
||
fieldSchema = z.array(z.string());
|
||
break;
|
||
case 'select':
|
||
fieldSchema = z.string();
|
||
break;
|
||
case 'llm-model-selector':
|
||
fieldSchema = z.string();
|
||
break;
|
||
case 'knowledge-base-selector':
|
||
fieldSchema = z.string();
|
||
break;
|
||
case 'knowledge-base-multi-selector':
|
||
fieldSchema = z.array(z.string());
|
||
break;
|
||
case 'bot-selector':
|
||
fieldSchema = z.string();
|
||
break;
|
||
case 'prompt-editor':
|
||
fieldSchema = z.array(
|
||
z.object({
|
||
content: z.string(),
|
||
role: z.string(),
|
||
}),
|
||
);
|
||
break;
|
||
default:
|
||
fieldSchema = z.string();
|
||
}
|
||
|
||
if (
|
||
item.required &&
|
||
(fieldSchema instanceof z.ZodString ||
|
||
fieldSchema instanceof z.ZodArray)
|
||
) {
|
||
fieldSchema = fieldSchema.min(1, { message: '此字段为必填项' });
|
||
}
|
||
|
||
return {
|
||
...acc,
|
||
[item.name]: fieldSchema,
|
||
};
|
||
},
|
||
{} as Record<string, z.ZodTypeAny>,
|
||
),
|
||
);
|
||
|
||
type FormValues = z.infer<typeof formSchema>;
|
||
|
||
const form = useForm<FormValues>({
|
||
resolver: zodResolver(formSchema),
|
||
defaultValues: itemConfigList.reduce((acc, item) => {
|
||
// 优先使用 initialValues,如果没有则使用默认值
|
||
const value = initialValues?.[item.name] ?? item.default;
|
||
return {
|
||
...acc,
|
||
[item.name]: value,
|
||
};
|
||
}, {} as FormValues),
|
||
});
|
||
|
||
// 当 initialValues 变化时更新表单值
|
||
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||
useEffect(() => {
|
||
// 首次挂载时,使用 initialValues 初始化表单
|
||
if (isInitialMount.current) {
|
||
isInitialMount.current = false;
|
||
previousInitialValues.current = initialValues;
|
||
return;
|
||
}
|
||
|
||
// 检查 initialValues 是否真的发生了实质性变化
|
||
// 使用 JSON.stringify 进行深度比较
|
||
const hasRealChange =
|
||
JSON.stringify(previousInitialValues.current) !==
|
||
JSON.stringify(initialValues);
|
||
|
||
if (initialValues && hasRealChange) {
|
||
// 合并默认值和初始值
|
||
const mergedValues = itemConfigList.reduce(
|
||
(acc, item) => {
|
||
acc[item.name] = initialValues[item.name] ?? item.default;
|
||
return acc;
|
||
},
|
||
{} as Record<string, object>,
|
||
);
|
||
|
||
Object.entries(mergedValues).forEach(([key, value]) => {
|
||
form.setValue(key as keyof FormValues, value);
|
||
});
|
||
|
||
previousInitialValues.current = initialValues;
|
||
}
|
||
}, [initialValues, form, itemConfigList]);
|
||
|
||
// Stable ref for onSubmit to avoid re-triggering the effect when the
|
||
// parent passes a new closure on every render.
|
||
const onSubmitRef = useRef(onSubmit);
|
||
onSubmitRef.current = onSubmit;
|
||
|
||
// 监听表单值变化
|
||
useEffect(() => {
|
||
// Emit initial form values immediately so the parent always has a valid snapshot,
|
||
// even if the user saves without modifying any field.
|
||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||
const formValues = form.getValues();
|
||
const initialFinalValues = itemConfigList.reduce(
|
||
(acc, item) => {
|
||
acc[item.name] = formValues[item.name] ?? item.default;
|
||
return acc;
|
||
},
|
||
{} as Record<string, object>,
|
||
);
|
||
onSubmitRef.current?.(initialFinalValues);
|
||
|
||
const subscription = form.watch(() => {
|
||
const formValues = form.getValues();
|
||
const finalValues = itemConfigList.reduce(
|
||
(acc, item) => {
|
||
acc[item.name] = formValues[item.name] ?? item.default;
|
||
return acc;
|
||
},
|
||
{} as Record<string, object>,
|
||
);
|
||
onSubmitRef.current?.(finalValues);
|
||
});
|
||
return () => subscription.unsubscribe();
|
||
}, [form, itemConfigList]);
|
||
|
||
return (
|
||
<Form {...form}>
|
||
<div className="space-y-4">
|
||
{itemConfigList.map((config) => (
|
||
<FormField
|
||
key={config.id}
|
||
control={form.control}
|
||
name={config.name as keyof FormValues}
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>
|
||
{extractI18nObject(config.label)}{' '}
|
||
{config.required && <span className="text-red-500">*</span>}
|
||
</FormLabel>
|
||
<FormControl>
|
||
<DynamicFormItemComponent
|
||
config={config}
|
||
field={field}
|
||
onFileUploaded={onFileUploaded}
|
||
/>
|
||
</FormControl>
|
||
{config.description && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{extractI18nObject(config.description)}
|
||
</p>
|
||
)}
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Form>
|
||
);
|
||
}
|