### What problem does this PR solve? Feat: Added meta data to the chat configuration page #8531 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.2
| @@ -6,6 +6,10 @@ export interface PromptConfig { | |||
| prologue: string; | |||
| system: string; | |||
| tts?: boolean; | |||
| quote: boolean; | |||
| keyword: boolean; | |||
| refine_multiturn: boolean; | |||
| use_kg: boolean; | |||
| } | |||
| export interface Parameter { | |||
| @@ -26,6 +30,7 @@ export interface Variable { | |||
| presence_penalty?: number; | |||
| temperature?: number; | |||
| top_p?: number; | |||
| llm_id?: string; | |||
| } | |||
| export interface IDialog { | |||
| @@ -50,6 +55,8 @@ export interface IDialog { | |||
| update_time: number; | |||
| vector_similarity_weight: number; | |||
| similarity_threshold: number; | |||
| top_k: number; | |||
| top_n: number; | |||
| } | |||
| export interface IConversation { | |||
| @@ -563,9 +563,16 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s | |||
| crossLanguage: 'Cross-language search', | |||
| crossLanguageTip: `Select one or more languages for cross‑language search. If no language is selected, the system searches with the original query.`, | |||
| createChat: 'Create chat', | |||
| metadata: 'Metadata', | |||
| metadataTip: 'Metadata', | |||
| metadata: 'Meta Data', | |||
| metadataTip: | |||
| 'Metadata filtering is the process of using metadata attributes (such as tags, categories, or access permissions) to refine and control the retrieval of relevant information within a system.', | |||
| conditions: 'Conditions', | |||
| addCondition: 'Add Condition', | |||
| meta: { | |||
| disabled: 'Disabled', | |||
| automatic: 'Automatic', | |||
| manual: 'Manual', | |||
| }, | |||
| }, | |||
| setting: { | |||
| profile: 'Profile', | |||
| @@ -558,6 +558,16 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 | |||
| tavilyApiKeyHelp: '如何获取?', | |||
| crossLanguage: '跨语言搜索', | |||
| crossLanguageTip: `选择一种或多种语言进行跨语言搜索。如果未选择任何语言,系统将使用原始查询进行搜索。`, | |||
| metadata: '元数据', | |||
| metadataTip: | |||
| '元数据过滤是使用元数据属性(例如标签、类别或访问权限)来优化和控制系统内相关信息检索的过程。', | |||
| conditions: '条件', | |||
| addCondition: '增加条件', | |||
| meta: { | |||
| disabled: '禁用', | |||
| automatic: '自动', | |||
| manual: '手动', | |||
| }, | |||
| }, | |||
| setting: { | |||
| profile: '概要', | |||
| @@ -59,7 +59,7 @@ export const LogicalOperatorIcon = function OperatorIcon({ | |||
| return icon; | |||
| }; | |||
| function useBuildSwitchOperatorOptions() { | |||
| export function useBuildSwitchOperatorOptions() { | |||
| const { t } = useTranslation(); | |||
| const switchOperatorOptions = useMemo(() => { | |||
| @@ -14,19 +14,6 @@ import { MetadataFilterConditions } from './metadata-filter-conditions'; | |||
| const emptyResponseField = ['prompt_config', 'empty_response']; | |||
| const MetadataOptions = Object.values(DatasetMetadata).map((x) => { | |||
| let value: DatasetMetadata | boolean = x; | |||
| if (x === DatasetMetadata.Disabled) { | |||
| value = false; | |||
| } else if (x === DatasetMetadata.Automatic) { | |||
| value = true; | |||
| } | |||
| return { | |||
| value, | |||
| label: x, | |||
| }; | |||
| }); | |||
| const AssistantSetting = ({ | |||
| show, | |||
| form, | |||
| @@ -35,7 +22,14 @@ const AssistantSetting = ({ | |||
| const { t } = useTranslate('chat'); | |||
| const { data } = useFetchTenantInfo(true); | |||
| const metadata = Form.useWatch(['meta_data_filter', 'auto'], form); | |||
| const MetadataOptions = Object.values(DatasetMetadata).map((x) => { | |||
| return { | |||
| value: x, | |||
| label: t(`meta.${x}`), | |||
| }; | |||
| }); | |||
| const metadata = Form.useWatch(['meta_data_filter', 'method'], form); | |||
| const kbIds = Form.useWatch(['kb_ids'], form); | |||
| const hasKnowledge = Array.isArray(kbIds) && kbIds.length > 0; | |||
| @@ -176,8 +170,9 @@ const AssistantSetting = ({ | |||
| {hasKnowledge && ( | |||
| <Form.Item | |||
| label={t('metadata')} | |||
| name={['meta_data_filter', 'auto']} | |||
| name={['meta_data_filter', 'method']} | |||
| tooltip={t('metadataTip')} | |||
| initialValue={DatasetMetadata.Disabled} | |||
| > | |||
| <Select options={MetadataOptions} /> | |||
| </Form.Item> | |||
| @@ -1,5 +1,6 @@ | |||
| import { useFetchKnowledgeMetadata } from '@/hooks/use-knowledge-request'; | |||
| import { SwitchOperatorOptions } from '@/pages/agent/constant'; | |||
| import { useBuildSwitchOperatorOptions } from '@/pages/agent/form/switch-form'; | |||
| import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; | |||
| import { | |||
| Button, | |||
| @@ -12,9 +13,12 @@ import { | |||
| Space, | |||
| } from 'antd'; | |||
| import { useCallback } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| export function MetadataFilterConditions({ kbIds }: { kbIds: string[] }) { | |||
| const metadata = useFetchKnowledgeMetadata(kbIds); | |||
| const { t } = useTranslation(); | |||
| const switchOperatorOptions = useBuildSwitchOperatorOptions(); | |||
| const renderItems = useCallback( | |||
| (add: FormListOperation['add']) => { | |||
| @@ -50,22 +54,22 @@ export function MetadataFilterConditions({ kbIds }: { kbIds: string[] }) { | |||
| <Form.Item | |||
| {...restField} | |||
| name={[name, 'key']} | |||
| rules={[{ required: true, message: 'Missing first name' }]} | |||
| rules={[{ required: true, message: t('common.pleaseInput') }]} | |||
| > | |||
| <Input placeholder="First Name" /> | |||
| <Input placeholder={t('common.pleaseInput')} /> | |||
| </Form.Item> | |||
| <Form.Item {...restField} name={[name, 'op']} className="w-20"> | |||
| <Select | |||
| options={SwitchOperatorOptions} | |||
| options={switchOperatorOptions} | |||
| popupMatchSelectWidth={false} | |||
| /> | |||
| </Form.Item> | |||
| <Form.Item | |||
| {...restField} | |||
| name={[name, 'value']} | |||
| rules={[{ required: true, message: 'Missing last name' }]} | |||
| rules={[{ required: true, message: t('common.pleaseInput') }]} | |||
| > | |||
| <Input placeholder="Last Name" /> | |||
| <Input placeholder={t('common.pleaseInput')} /> | |||
| </Form.Item> | |||
| <MinusCircleOutlined onClick={() => remove(name)} /> | |||
| </Space> | |||
| @@ -73,7 +77,7 @@ export function MetadataFilterConditions({ kbIds }: { kbIds: string[] }) { | |||
| <Form.Item> | |||
| <Dropdown trigger={['click']} menu={{ items: renderItems(add) }}> | |||
| <Button type="dashed" block icon={<PlusOutlined />}> | |||
| Add Condition | |||
| {t('chat.addCondition')} | |||
| </Button> | |||
| </Dropdown> | |||
| </Form.Item> | |||
| @@ -11,108 +11,101 @@ import { | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { Input } from '@/components/ui/input'; | |||
| import { Textarea } from '@/components/ui/textarea'; | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { useFormContext } from 'react-hook-form'; | |||
| import { Subhead } from './subhead'; | |||
| export default function ChatBasicSetting() { | |||
| const { t } = useTranslate('chat'); | |||
| const form = useFormContext(); | |||
| return ( | |||
| <section> | |||
| <Subhead>Basic settings</Subhead> | |||
| <div className="space-y-8"> | |||
| <FormField | |||
| control={form.control} | |||
| name={'icon'} | |||
| render={({ field }) => ( | |||
| <div className="space-y-6"> | |||
| <FormItem className="w-full"> | |||
| <FormLabel>{t('assistantAvatar')}</FormLabel> | |||
| <FormControl> | |||
| <FileUploader | |||
| value={field.value} | |||
| onValueChange={field.onChange} | |||
| maxFileCount={1} | |||
| maxSize={4 * 1024 * 1024} | |||
| // progresses={progresses} | |||
| // pass the onUpload function here for direct upload | |||
| // onUpload={uploadFiles} | |||
| // disabled={isUploading} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| </div> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name="name" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('assistantName')}</FormLabel> | |||
| <div className="space-y-8"> | |||
| <FormField | |||
| control={form.control} | |||
| name={'icon'} | |||
| render={({ field }) => ( | |||
| <div className="space-y-6"> | |||
| <FormItem className="w-full"> | |||
| <FormLabel>{t('assistantAvatar')}</FormLabel> | |||
| <FormControl> | |||
| <Input {...field}></Input> | |||
| <FileUploader | |||
| value={field.value} | |||
| onValueChange={field.onChange} | |||
| maxFileCount={1} | |||
| maxSize={4 * 1024 * 1024} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name="description" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('description')}</FormLabel> | |||
| <FormControl> | |||
| <Input {...field}></Input> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name={'prompt_config.empty_response'} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('emptyResponse')}</FormLabel> | |||
| <FormControl> | |||
| <Input {...field}></Input> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name={'prompt_config.prologue'} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('setAnOpener')}</FormLabel> | |||
| <FormControl> | |||
| <Input {...field}></Input> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <SwitchFormField | |||
| name={'prompt_config.quote'} | |||
| label={t('quote')} | |||
| ></SwitchFormField> | |||
| <SwitchFormField | |||
| name={'prompt_config.keyword'} | |||
| label={t('keyword')} | |||
| ></SwitchFormField> | |||
| <SwitchFormField | |||
| name={'prompt_config.tts'} | |||
| label={t('tts')} | |||
| ></SwitchFormField> | |||
| <KnowledgeBaseFormField></KnowledgeBaseFormField> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name="name" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('assistantName')}</FormLabel> | |||
| <FormControl> | |||
| <Input {...field}></Input> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name="description" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('description')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea {...field}></Textarea> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name={'prompt_config.empty_response'} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('emptyResponse')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea {...field}></Textarea> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name={'prompt_config.prologue'} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('setAnOpener')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea {...field}></Textarea> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <SwitchFormField | |||
| name={'prompt_config.quote'} | |||
| label={t('quote')} | |||
| ></SwitchFormField> | |||
| <SwitchFormField | |||
| name={'prompt_config.keyword'} | |||
| label={t('keyword')} | |||
| ></SwitchFormField> | |||
| <SwitchFormField | |||
| name={'prompt_config.tts'} | |||
| label={t('tts')} | |||
| ></SwitchFormField> | |||
| <KnowledgeBaseFormField></KnowledgeBaseFormField> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -9,35 +9,31 @@ import { | |||
| import { Textarea } from '@/components/ui/textarea'; | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { useFormContext } from 'react-hook-form'; | |||
| import { Subhead } from './subhead'; | |||
| export function ChatModelSettings() { | |||
| const { t } = useTranslate('chat'); | |||
| const form = useFormContext(); | |||
| return ( | |||
| <section> | |||
| <Subhead>Model Setting</Subhead> | |||
| <div className="space-y-8"> | |||
| <FormField | |||
| control={form.control} | |||
| name="prompt_config.system" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('system')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea | |||
| placeholder="Tell us a little bit about yourself" | |||
| className="resize-none" | |||
| {...field} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <LlmSettingFieldItems></LlmSettingFieldItems> | |||
| </div> | |||
| </section> | |||
| <div className="space-y-8"> | |||
| <FormField | |||
| control={form.control} | |||
| name="prompt_config.system" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('system')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea | |||
| placeholder="Tell us a little bit about yourself" | |||
| className="resize-none" | |||
| {...field} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <LlmSettingFieldItems prefix="llm_setting"></LlmSettingFieldItems> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -15,42 +15,38 @@ import { Textarea } from '@/components/ui/textarea'; | |||
| import { UseKnowledgeGraphFormField } from '@/components/use-knowledge-graph-item'; | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { useFormContext } from 'react-hook-form'; | |||
| import { Subhead } from './subhead'; | |||
| export function ChatPromptEngine() { | |||
| const { t } = useTranslate('chat'); | |||
| const form = useFormContext(); | |||
| return ( | |||
| <section> | |||
| <Subhead>Prompt engine</Subhead> | |||
| <div className="space-y-8"> | |||
| <FormField | |||
| control={form.control} | |||
| name="prompt_config.system" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('system')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea | |||
| placeholder="Tell us a little bit about yourself" | |||
| className="resize-none" | |||
| {...field} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <SimilaritySliderFormField></SimilaritySliderFormField> | |||
| <TopNFormField></TopNFormField> | |||
| <SwitchFormField | |||
| name={'prompt_config.refine_multiturn'} | |||
| label={t('multiTurn')} | |||
| ></SwitchFormField> | |||
| <UseKnowledgeGraphFormField name="prompt_config.use_kg"></UseKnowledgeGraphFormField> | |||
| <RerankFormFields></RerankFormFields> | |||
| </div> | |||
| </section> | |||
| <div className="space-y-8"> | |||
| <FormField | |||
| control={form.control} | |||
| name="prompt_config.system" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('system')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea | |||
| placeholder="Tell us a little bit about yourself" | |||
| className="resize-none" | |||
| {...field} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <SimilaritySliderFormField></SimilaritySliderFormField> | |||
| <TopNFormField></TopNFormField> | |||
| <SwitchFormField | |||
| name={'prompt_config.refine_multiturn'} | |||
| label={t('multiTurn')} | |||
| ></SwitchFormField> | |||
| <UseKnowledgeGraphFormField name="prompt_config.use_kg"></UseKnowledgeGraphFormField> | |||
| <RerankFormFields></RerankFormFields> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -1,23 +0,0 @@ | |||
| import { | |||
| Sheet, | |||
| SheetContent, | |||
| SheetHeader, | |||
| SheetTitle, | |||
| SheetTrigger, | |||
| } from '@/components/ui/sheet'; | |||
| import { PropsWithChildren } from 'react'; | |||
| import { ChatSettings } from './chat-settings'; | |||
| export function ChatSettingSheet({ children }: PropsWithChildren) { | |||
| return ( | |||
| <Sheet> | |||
| <SheetTrigger asChild>{children}</SheetTrigger> | |||
| <SheetContent> | |||
| <SheetHeader> | |||
| <SheetTitle>Chat Settings</SheetTitle> | |||
| </SheetHeader> | |||
| <ChatSettings></ChatSettings> | |||
| </SheetContent> | |||
| </Sheet> | |||
| ); | |||
| } | |||
| @@ -1,6 +1,10 @@ | |||
| import { Button } from '@/components/ui/button'; | |||
| import { Separator } from '@/components/ui/separator'; | |||
| import { useFetchDialog } from '@/hooks/use-chat-request'; | |||
| import { transformBase64ToFile } from '@/utils/file-util'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { PanelRightClose } from 'lucide-react'; | |||
| import { useEffect } from 'react'; | |||
| import { FormProvider, useForm } from 'react-hook-form'; | |||
| import { z } from 'zod'; | |||
| import ChatBasicSetting from './chat-basic-settings'; | |||
| @@ -11,6 +15,7 @@ import { useChatSettingSchema } from './use-chat-setting-schema'; | |||
| type ChatSettingsProps = { switchSettingVisible(): void }; | |||
| export function ChatSettings({ switchSettingVisible }: ChatSettingsProps) { | |||
| const formSchema = useChatSettingSchema(); | |||
| const { data } = useFetchDialog(); | |||
| const form = useForm<z.infer<typeof formSchema>>({ | |||
| resolver: zodResolver(formSchema), | |||
| @@ -34,6 +39,14 @@ export function ChatSettings({ switchSettingVisible }: ChatSettingsProps) { | |||
| console.log(values); | |||
| } | |||
| useEffect(() => { | |||
| const nextData = { | |||
| ...data, | |||
| icon: data.icon ? [transformBase64ToFile(data.icon)] : [], | |||
| }; | |||
| form.reset(nextData as z.infer<typeof formSchema>); | |||
| }, [data, form]); | |||
| return ( | |||
| <section className="p-5 w-[400px] max-w-[20%]"> | |||
| <div className="flex justify-between items-center text-base"> | |||
| @@ -49,7 +62,9 @@ export function ChatSettings({ switchSettingVisible }: ChatSettingsProps) { | |||
| className="space-y-6 overflow-auto max-h-[87vh] pr-4" | |||
| > | |||
| <ChatBasicSetting></ChatBasicSetting> | |||
| <Separator /> | |||
| <ChatPromptEngine></ChatPromptEngine> | |||
| <Separator /> | |||
| <ChatModelSettings></ChatModelSettings> | |||
| </form> | |||
| </FormProvider> | |||
| @@ -1,9 +0,0 @@ | |||
| import { PropsWithChildren } from 'react'; | |||
| export function Subhead({ children }: PropsWithChildren) { | |||
| return ( | |||
| <div className="text-xl font-bold mb-4 text-colors-text-neutral-strong"> | |||
| {children} | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -1,3 +1,4 @@ | |||
| import { LlmSettingSchema } from '@/components/llm-setting-items/next'; | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { z } from 'zod'; | |||
| @@ -31,6 +32,7 @@ export function useChatSettingSchema() { | |||
| top_n: z.number(), | |||
| vector_similarity_weight: z.number(), | |||
| top_k: z.number(), | |||
| llm_setting: z.object(LlmSettingSchema), | |||
| }); | |||
| return formSchema; | |||
| @@ -2,6 +2,7 @@ import { MoreButton } from '@/components/more-button'; | |||
| import { RAGFlowAvatar } from '@/components/ragflow-avatar'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { Card, CardContent } from '@/components/ui/card'; | |||
| import { SearchInput } from '@/components/ui/input'; | |||
| import { useSetModalState } from '@/hooks/common-hooks'; | |||
| import { | |||
| useFetchDialog, | |||
| @@ -9,7 +10,7 @@ import { | |||
| } from '@/hooks/use-chat-request'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { PanelLeftClose, PanelRightClose, Plus } from 'lucide-react'; | |||
| import { useCallback } from 'react'; | |||
| import { useCallback, useState } from 'react'; | |||
| import { useHandleClickConversationCard } from '../hooks/use-click-card'; | |||
| import { useSelectDerivedConversationList } from '../hooks/use-select-conversation-list'; | |||
| @@ -25,6 +26,7 @@ export function Sessions({ | |||
| useSelectDerivedConversationList(); | |||
| const { data } = useFetchDialog(); | |||
| const { visible, switchVisible } = useSetModalState(true); | |||
| const [searchStr, setSearchStr] = useState(''); | |||
| const handleCardClick = useCallback( | |||
| (conversationId: string, isNew: boolean) => () => { | |||
| @@ -61,11 +63,17 @@ export function Sessions({ | |||
| /> | |||
| </section> | |||
| <div className="flex justify-between items-center mb-4 pt-10"> | |||
| <span className="text-xl font-bold">Conversations</span> | |||
| <span className="text-base font-bold">Conversations</span> | |||
| <Button variant={'ghost'} onClick={addTemporaryConversation}> | |||
| <Plus></Plus> | |||
| </Button> | |||
| </div> | |||
| <div className="pb-4"> | |||
| <SearchInput | |||
| onChange={(e) => setSearchStr(e.target.value)} | |||
| value={searchStr} | |||
| ></SearchInput> | |||
| </div> | |||
| <div className="space-y-4 flex-1 overflow-auto"> | |||
| {conversationList.map((x) => ( | |||
| <Card | |||