feat: add challenge and red-blue competitions across API and web

This commit is contained in:
Joey Yakimowich-Payne 2025-10-01 06:49:09 -06:00
commit 8fd3c4bb64
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
77 changed files with 5355 additions and 24 deletions

View file

@ -0,0 +1,124 @@
'use client'
import { useTranslation } from 'react-i18next'
type LeaderboardEntry = {
rank: number
player_name: string
score: number
elapsed_ms?: number
tokens_total?: number
judge_rating?: number
created_at: string
is_current_user?: boolean
}
type Props = {
entries: LeaderboardEntry[]
strategy?: string
}
export default function Leaderboard({ entries, strategy = 'highest_rating' }: Props) {
const { t } = useTranslation()
if (entries.length === 0) {
return (
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-8 text-center'>
<div className='text-text-tertiary'>{t('challenges.leaderboard.empty')}</div>
</div>
)
}
return (
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg shadow-xs'>
<div className='border-b border-divider-subtle px-6 py-4'>
<h2 className='text-lg font-semibold text-text-primary'>{t('challenges.leaderboard.title')}</h2>
</div>
<div className='overflow-x-auto'>
<table className='w-full'>
<thead className='border-b border-divider-subtle bg-components-panel-on-panel-item-bg'>
<tr>
<th className='px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-tertiary'>
{t('challenges.leaderboard.rank')}
</th>
<th className='px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-tertiary'>
{t('challenges.leaderboard.player')}
</th>
<th className='px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-tertiary'>
{t('challenges.leaderboard.score')}
</th>
{strategy === 'fastest' && (
<th className='px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-tertiary'>
{t('challenges.leaderboard.time')}
</th>
)}
{strategy === 'fewest_tokens' && (
<th className='px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-tertiary'>
{t('challenges.leaderboard.tokens')}
</th>
)}
{strategy === 'highest_rating' && (
<th className='px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-tertiary'>
{t('challenges.leaderboard.rating')}
</th>
)}
</tr>
</thead>
<tbody className='divide-y divide-divider-subtle'>
{entries.map((entry, idx) => (
<tr
key={idx}
className={`transition-colors hover:bg-components-panel-on-panel-item-bg ${entry.is_current_user ? 'bg-util-colors-blue-blue-50' : ''}`}
>
<td className='whitespace-nowrap px-6 py-4'>
<div className='flex items-center'>
{entry.rank <= 3 ? (
<span className='text-lg'>
{entry.rank === 1 && '🥇'}
{entry.rank === 2 && '🥈'}
{entry.rank === 3 && '🥉'}
</span>
) : (
<span className='text-sm text-text-tertiary'>#{entry.rank}</span>
)}
</div>
</td>
<td className='whitespace-nowrap px-6 py-4'>
<div className='flex items-center'>
<div className='text-sm font-medium text-text-primary'>
{entry.player_name}
{entry.is_current_user && (
<span className='ml-2 rounded bg-util-colors-blue-blue-100 px-1.5 py-0.5 text-xs text-util-colors-blue-blue-700'>
{t('challenges.leaderboard.yourBest')}
</span>
)}
</div>
</div>
</td>
<td className='whitespace-nowrap px-6 py-4 text-sm text-text-secondary'>
{entry.score.toFixed(1)}
</td>
{strategy === 'fastest' && entry.elapsed_ms !== undefined && (
<td className='whitespace-nowrap px-6 py-4 text-sm text-text-secondary'>
{(entry.elapsed_ms / 1000).toFixed(2)}s
</td>
)}
{strategy === 'fewest_tokens' && entry.tokens_total !== undefined && (
<td className='whitespace-nowrap px-6 py-4 text-sm text-text-secondary'>
{entry.tokens_total}
</td>
)}
{strategy === 'highest_rating' && entry.judge_rating !== undefined && (
<td className='whitespace-nowrap px-6 py-4'>
<div className='flex items-center'>
<span className='text-sm font-medium text-text-primary'>{entry.judge_rating}/10</span>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View file

@ -65,6 +65,8 @@ export const useAvailableNodesMetaData = () => {
nodesMap: {
...availableNodesMetaDataMap,
[BlockEnum.VariableAssigner]: availableNodesMetaDataMap?.[BlockEnum.VariableAggregator],
// Legacy alias for renamed node
'prompt-challenge': availableNodesMetaDataMap?.[BlockEnum.ChallengeEvaluator],
},
}
}, [availableNodesMetaData, availableNodesMetaDataMap])

View file

@ -62,6 +62,8 @@ export const useAvailableNodesMetaData = () => {
nodesMap: {
...availableNodesMetaDataMap,
[BlockEnum.VariableAssigner]: availableNodesMetaDataMap?.[BlockEnum.VariableAggregator],
// Legacy alias for renamed node
'prompt-challenge': availableNodesMetaDataMap?.[BlockEnum.ChallengeEvaluator],
},
}
}, [availableNodesMetaData, availableNodesMetaDataMap])

View file

@ -66,6 +66,9 @@ const getIcon = (type: BlockEnum, className: string) => {
[BlockEnum.KnowledgeBase]: <KnowledgeBase className={className} />,
[BlockEnum.DataSource]: <Datasource className={className} />,
[BlockEnum.DataSourceEmpty]: <></>,
[BlockEnum.ChallengeEvaluator]: <IfElse className={className} />,
[BlockEnum.JudgingLLM]: <Llm className={className} />,
[BlockEnum.TeamChallenge]: <Agent className={className} />,
}[type]
}
const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
@ -92,6 +95,9 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500',
[BlockEnum.KnowledgeBase]: 'bg-util-colors-warning-warning-500',
[BlockEnum.DataSource]: 'bg-components-icon-bg-midnight-solid',
[BlockEnum.ChallengeEvaluator]: 'bg-util-colors-blue-blue-500',
[BlockEnum.JudgingLLM]: 'bg-util-colors-indigo-indigo-500',
[BlockEnum.TeamChallenge]: 'bg-util-colors-green-green-500',
}
const BlockIcon: FC<BlockIconProps> = ({
type,

View file

@ -61,6 +61,7 @@ export const SUPPORT_OUTPUT_VARS_NODE = [
BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop,
BlockEnum.DocExtractor, BlockEnum.ListFilter,
BlockEnum.Agent, BlockEnum.DataSource,
BlockEnum.ChallengeEvaluator, BlockEnum.JudgingLLM, BlockEnum.TeamChallenge,
]
export const AGENT_OUTPUT_STRUCT: Var[] = [

View file

@ -20,6 +20,9 @@ import httpRequestDefault from '@/app/components/workflow/nodes/http/default'
import parameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default'
import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default'
import toolDefault from '@/app/components/workflow/nodes/tool/default'
import challengeEvaluatorDefault from '@/app/components/workflow/nodes/challenge-evaluator/default'
import judgingLLMDefault from '@/app/components/workflow/nodes/judging-llm/default'
import teamChallengeDefault from '@/app/components/workflow/nodes/team-challenge/default'
export const WORKFLOW_COMMON_NODES = [
llmDefault,
@ -41,4 +44,7 @@ export const WORKFLOW_COMMON_NODES = [
httpRequestDefault,
listOperatorDefault,
toolDefault,
challengeEvaluatorDefault,
judgingLLMDefault,
teamChallengeDefault,
]

View file

@ -645,6 +645,41 @@ const formatItem = (
}) as Var[]
break
}
case BlockEnum.JudgingLLM:
case BlockEnum.ChallengeEvaluator:
case BlockEnum.TeamChallenge: {
// Synchronously get outputs if getOutputVars is defined
const nodeType = data.type
if (nodeType === BlockEnum.JudgingLLM) {
res.vars = [
{ variable: 'judge_passed', type: VarType.boolean },
{ variable: 'judge_rating', type: VarType.number },
{ variable: 'judge_feedback', type: VarType.string },
{ variable: 'judge_raw', type: VarType.object },
]
}
else if (nodeType === BlockEnum.ChallengeEvaluator) {
res.vars = [
{ variable: 'challenge_succeeded', type: VarType.boolean },
{ variable: 'judge_rating', type: VarType.number },
{ variable: 'judge_feedback', type: VarType.string },
{ variable: 'message', type: VarType.string },
]
}
else if (nodeType === BlockEnum.TeamChallenge) {
res.vars = [
{ variable: 'team', type: VarType.string },
{ variable: 'judge_passed', type: VarType.boolean },
{ variable: 'judge_rating', type: VarType.number },
{ variable: 'judge_feedback', type: VarType.string },
{ variable: 'categories', type: VarType.object },
{ variable: 'team_points', type: VarType.number },
{ variable: 'total_points', type: VarType.number },
]
}
break
}
}
const { error_strategy } = data

View file

@ -0,0 +1,42 @@
import type { NodeDefault } from '../../types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import type { ChallengeEvaluatorNodeType } from './types'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Utilities,
sort: 3,
type: BlockEnum.ChallengeEvaluator,
helpLinkUri: 'challenge-evaluator',
})
const nodeDefault: NodeDefault<ChallengeEvaluatorNodeType> = {
metaData,
defaultValue: {
evaluation_mode: 'rules',
success_type: 'contains',
success_pattern: '',
scoring_strategy: 'highest_rating',
mask_variables: [],
inputs: {
response: [],
},
},
getOutputVars() {
return [
{ variable: 'challenge_succeeded', type: VarType.boolean },
{ variable: 'judge_rating', type: VarType.number },
{ variable: 'judge_feedback', type: VarType.string },
{ variable: 'message', type: VarType.string },
]
},
checkValid(payload: ChallengeEvaluatorNodeType, t: any) {
let errorMessages = ''
if (payload.evaluation_mode === 'rules' && !payload.success_pattern)
errorMessages = t('workflow.errorMsg.fieldRequired', { field: 'success_pattern' })
return { isValid: !errorMessages, errorMessage: errorMessages }
},
}
export default nodeDefault

View file

@ -0,0 +1,28 @@
import type { FC } from 'react'
import React from 'react'
import type { NodeProps } from '@/app/components/workflow/types'
import type { ChallengeEvaluatorNodeType } from './types'
const Node: FC<NodeProps<ChallengeEvaluatorNodeType>> = ({ data }) => {
const { evaluation_mode, success_type, success_pattern, challenge_id } = data
return (
<div className='mb-1 px-3 py-1'>
{challenge_id ? (
<div className='flex items-center gap-2'>
<div className='rounded bg-components-badge-white-to-dark px-1 py-0.5 text-[10px] font-semibold uppercase text-text-tertiary'>Challenge</div>
<div className='truncate text-xs text-text-secondary' title={challenge_id}>{challenge_id}</div>
</div>
) : (
<div className='flex items-center gap-2'>
<div className='rounded bg-components-badge-white-to-dark px-1 py-0.5 text-[10px] font-semibold uppercase text-text-tertiary'>{evaluation_mode}</div>
<div className='rounded bg-components-badge-white-to-dark px-1 py-0.5 text-[10px] font-semibold uppercase text-text-tertiary'>{success_type}</div>
{success_pattern && (
<div className='min-w-0 truncate text-xs text-text-secondary' title={success_pattern}>"{success_pattern}"</div>
)}
</div>
)}
</div>
)
}
export default React.memo(Node)

View file

@ -0,0 +1,121 @@
import type { FC } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodePanelProps } from '@/app/components/workflow/types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import type { ChallengeEvaluatorNodeType } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import produce from 'immer'
import useSWR from 'swr'
import { fetchChallenges } from '@/service/challenges'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import Select from '@/app/components/base/select'
const i18nPrefix = 'workflow.nodes.challengeEvaluator'
const Panel: FC<NodePanelProps<ChallengeEvaluatorNodeType>> = ({ id, data }) => {
const { t } = useTranslation()
const { inputs, setInputs } = useNodeCrud<ChallengeEvaluatorNodeType>(id, data)
const { data: challenges } = useSWR('challenges:list', fetchChallenges)
const filterVar = useMemo(() => (_: any) => true, [])
const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar })
return (
<div className='pt-2'>
<div className='space-y-4 px-4 pb-4'>
<Field title={t(`${i18nPrefix}.selectedChallenge`)} tooltip={t(`${i18nPrefix}.selectedChallengeTip`)}>
<Select
items={(challenges || []).map((c: any) => ({ value: c.id, name: c.name }))}
defaultValue={data.challenge_id || ''}
onSelect={item => setInputs(produce(inputs, (draft) => { (draft as any).challenge_id = (item?.value as string) || undefined }))}
allowSearch={false}
/>
</Field>
<Field title={t(`${i18nPrefix}.evaluationMode`)} tooltip={t(`${i18nPrefix}.evaluationModeTip`)}>
<Select
items={[
{ value: 'rules', name: 'Rules' },
{ value: 'llm-judge', name: 'Judging LLM' },
{ value: 'custom', name: 'Custom' },
]}
defaultValue={data.evaluation_mode || 'rules'}
onSelect={item => setInputs(produce(inputs, (draft) => { (draft as any).evaluation_mode = item.value as string }))}
allowSearch={false}
/>
</Field>
{!data.challenge_id && data.evaluation_mode === 'rules' && (
<>
<Field title={t(`${i18nPrefix}.successType`)} tooltip={t(`${i18nPrefix}.successTypeTip`)}>
<Select
items={[
{ value: 'contains', name: 'Contains' },
{ value: 'regex', name: 'Regex' },
]}
defaultValue={data.success_type || 'contains'}
onSelect={item => setInputs(produce(inputs, (draft) => { (draft as any).success_type = item.value as string }))}
allowSearch={false}
/>
</Field>
<Field title={t(`${i18nPrefix}.successPattern`)} tooltip={t(`${i18nPrefix}.successPatternTip`)} required>
<Editor
title={<div className='text-xs font-semibold uppercase text-text-secondary'>pattern</div>}
value={data.success_pattern || ''}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).success_pattern = v }))}
readOnly={false}
isShowContext={false}
isChatApp
isChatModel
hasSetBlockStatus={{ history: false, query: false, context: false }}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
isSupportFileVar
/>
</Field>
</>
)}
<Field title={t(`${i18nPrefix}.responseVar`)} tooltip={t(`${i18nPrefix}.responseVarTip`)}>
<VarReferencePicker
nodeId={id}
readonly={false}
isShowNodeName
value={data.inputs?.response || []}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).inputs = { ...(draft as any).inputs, response: v } }))}
filterVar={filterVar}
/>
</Field>
<Field title={t(`${i18nPrefix}.scoringStrategy`)} tooltip={t(`${i18nPrefix}.scoringStrategyTip`)}>
<Select
items={[
{ value: 'first', name: t(`${i18nPrefix}.scoringFirst`) },
{ value: 'fastest', name: t(`${i18nPrefix}.scoringFastest`) },
{ value: 'fewest_tokens', name: t(`${i18nPrefix}.scoringFewestTokens`) },
{ value: 'highest_rating', name: t(`${i18nPrefix}.scoringHighestRating`) },
{ value: 'custom', name: t(`${i18nPrefix}.scoringCustom`) },
]}
defaultValue={data.scoring_strategy || 'highest_rating'}
onSelect={item => setInputs(produce(inputs, (draft) => { (draft as any).scoring_strategy = item.value as string }))}
allowSearch={false}
/>
</Field>
</div>
<Split />
<div>
<OutputVars>
<>
<VarItem name='challenge_succeeded' type='boolean' description='Challenge succeeded' />
<VarItem name='judge_rating' type='number' description='Judge rating' />
<VarItem name='judge_feedback' type='string' description='Judge feedback' />
<VarItem name='message' type='string' description='Message' />
</>
</OutputVars>
</div>
</div>
)
}
export default memo(Panel)

View file

@ -0,0 +1,20 @@
import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
export type ChallengeEvaluatorNodeType = CommonNodeType<{
challenge_id?: string
evaluation_mode?: 'rules' | 'llm-judge' | 'custom'
success_type?: 'regex' | 'contains' | 'custom'
success_pattern?: string
scoring_strategy?: 'first' | 'fastest' | 'fewest_tokens' | 'highest_rating' | 'custom'
mask_variables?: string[]
inputs?: {
response?: ValueSelector
}
}>
export const DEFAULT_CHALLENGE_EVALUATOR_INPUTS: ChallengeEvaluatorNodeType['inputs'] = {
response: [],
}
export const CHALLENGE_EVALUATOR_BLOCK_TYPE = BlockEnum.ChallengeEvaluator

View file

@ -43,6 +43,12 @@ import DataSourcePanel from './data-source/panel'
import KnowledgeBaseNode from './knowledge-base/node'
import KnowledgeBasePanel from './knowledge-base/panel'
import { TransferMethod } from '@/types/app'
import ChallengeEvaluatorNode from './challenge-evaluator/node'
import ChallengeEvaluatorPanel from './challenge-evaluator/panel'
import JudgingLLMNode from './judging-llm/node'
import JudgingLLMPanel from './judging-llm/panel'
import TeamChallengeNode from './team-challenge/node'
import TeamChallengePanel from './team-challenge/panel'
export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Start]: StartNode,
@ -67,6 +73,11 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Agent]: AgentNode,
[BlockEnum.DataSource]: DataSourceNode,
[BlockEnum.KnowledgeBase]: KnowledgeBaseNode,
[BlockEnum.ChallengeEvaluator]: ChallengeEvaluatorNode,
[BlockEnum.JudgingLLM]: JudgingLLMNode,
[BlockEnum.TeamChallenge]: TeamChallengeNode,
// Legacy alias for renamed node
'prompt-challenge': ChallengeEvaluatorNode,
}
export const PanelComponentMap: Record<string, ComponentType<any>> = {
@ -92,6 +103,11 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Agent]: AgentPanel,
[BlockEnum.DataSource]: DataSourcePanel,
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
[BlockEnum.ChallengeEvaluator]: ChallengeEvaluatorPanel,
[BlockEnum.JudgingLLM]: JudgingLLMPanel,
[BlockEnum.TeamChallenge]: TeamChallengePanel,
// Legacy alias for renamed node
'prompt-challenge': ChallengeEvaluatorPanel,
}
export const CUSTOM_NODE_TYPE = 'custom'

View file

@ -0,0 +1,45 @@
import type { NodeDefault } from '../../types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import type { JudgingLLMNodeType } from './types'
import { DEFAULT_JUDGE_MODEL } from './types'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Utilities,
sort: 4,
type: BlockEnum.JudgingLLM,
helpLinkUri: 'judging-llm',
})
const nodeDefault: NodeDefault<JudgingLLMNodeType> = {
metaData,
defaultValue: {
judge_model: DEFAULT_JUDGE_MODEL,
rubric_prompt_template: '',
rating_scale: 10,
pass_threshold: 7,
inputs: {
goal: [],
response: [],
},
},
getOutputVars() {
return [
{ variable: 'judge_passed', type: VarType.boolean },
{ variable: 'judge_rating', type: VarType.number },
{ variable: 'judge_feedback', type: VarType.string },
{ variable: 'judge_raw', type: VarType.object },
]
},
checkValid(payload: JudgingLLMNodeType, t: any) {
let errorMessages = ''
if (!payload.judge_model?.provider)
errorMessages = t('workflow.errorMsg.fieldRequired', { field: t('workflow.common.model') })
if (!errorMessages && !payload.rubric_prompt_template)
errorMessages = t('workflow.errorMsg.fieldRequired', { field: 'rubric_prompt_template' })
return { isValid: !errorMessages, errorMessage: errorMessages }
},
}
export default nodeDefault

View file

@ -0,0 +1,33 @@
import type { FC } from 'react'
import React from 'react'
import type { NodeProps } from '@/app/components/workflow/types'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import type { JudgingLLMNodeType } from './types'
const Node: FC<NodeProps<JudgingLLMNodeType>> = ({ data }) => {
const { judge_model, pass_threshold } = data
const hasSetModel = !!(judge_model?.provider && judge_model?.name)
const { textGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList()
if (!hasSetModel)
return null
return (
<div className='mb-1 px-3 py-1'>
<div className='flex items-center gap-2'>
<ModelSelector
defaultModel={{ provider: judge_model!.provider, model: judge_model!.name }}
modelList={textGenerationModelList}
triggerClassName='!h-6 !rounded-md'
readonly
/>
<div className='rounded bg-components-badge-white-to-dark px-1 py-0.5 text-[10px] font-semibold uppercase text-text-tertiary'>
Pass ≥ {pass_threshold ?? 7}
</div>
</div>
</div>
)
}
export default React.memo(Node)

View file

@ -0,0 +1,143 @@
import type { FC } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodePanelProps } from '@/app/components/workflow/types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import Toast from '@/app/components/base/toast'
import AddButton2 from '@/app/components/base/button/add-button'
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import Input from '@/app/components/base/input'
import type { JudgingLLMNodeType } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import produce from 'immer'
const Panel: FC<NodePanelProps<JudgingLLMNodeType>> = ({ id, data }) => {
const { t } = useTranslation()
const { inputs, setInputs } = useNodeCrud<JudgingLLMNodeType>(id, data)
const filterVar = useMemo(() => (_: any) => true, [])
const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar })
return (
<div className='pt-2'>
<div className='space-y-4 px-4 pb-4'>
<Field title={t('workflow.common.model')} required>
<ModelParameterModal
popupClassName='!w-[387px]'
isInWorkflow
isAdvancedMode={true}
mode={data.judge_model?.mode}
provider={data.judge_model?.provider}
completionParams={data.judge_model?.completion_params}
modelId={data.judge_model?.name}
setModel={async (model: { provider: string; modelId: string; mode?: string }) => {
try {
const { params } = await fetchAndMergeValidCompletionParams(
model.provider,
model.modelId,
data.judge_model?.completion_params || {},
true,
)
setInputs(produce(inputs, (draft) => {
(draft as any).judge_model = {
provider: model.provider,
name: model.modelId,
mode: model.mode || 'chat',
completion_params: params,
}
}))
}
catch {
Toast.notify({ type: 'error', message: t('common.error') })
setInputs(produce(inputs, (draft) => {
(draft as any).judge_model = {
provider: model.provider,
name: model.modelId,
mode: model.mode || 'chat',
completion_params: {},
}
}))
}
}}
onCompletionParamsChange={newParams => setInputs(produce(inputs, (draft) => { (draft as any).judge_model.completion_params = newParams }))}
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={false}
/>
</Field>
<Field title='Rubric Template' required>
<div className='space-y-2'>
<Editor
title={<div className='text-xs font-semibold uppercase text-text-secondary'>system</div>}
value={data.rubric_prompt_template || ''}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).rubric_prompt_template = v }))}
readOnly={false}
isShowContext={false}
isChatApp
isChatModel
hasSetBlockStatus={{ history: false, query: false, context: false }}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
isSupportFileVar
/>
<div className='flex items-center gap-2'>
<AddButton2 onClick={() => setInputs(produce(inputs, (draft) => { (draft as any).rubric_prompt_template = 'You are a strict evaluator. Given a goal and a model response, decide pass/fail, give a rating 0-10, and provide concise feedback.\\n\\nGoal:\\n{goal}\\n\\nResponse:\\n{response}\\n\\nReturn JSON: {"passed": boolean, "rating": number, "feedback": string}.' }))} />
<div className='system-xs-medium-uppercase text-text-tertiary'>Insert default rubric</div>
</div>
</div>
</Field>
<Field title='Pass Threshold'>
<Input
type='number'
wrapperClassName='w-full'
min={0}
max={data.rating_scale || 10}
value={data.pass_threshold ?? 7}
onChange={e => setInputs(produce(inputs, (draft) => { (draft as any).pass_threshold = Number(e.target.value) }))}
/>
</Field>
<Field title='Inputs'>
<div className='space-y-2'>
<div>
<div className='system-xs-medium-uppercase mb-1 text-text-tertiary'>Goal</div>
<VarReferencePicker
nodeId={id}
isShowNodeName
readonly={false}
value={data.inputs?.goal || []}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).inputs = { ...(draft as any).inputs, goal: v } }))}
/>
</div>
<div>
<div className='system-xs-medium-uppercase mb-1 text-text-tertiary'>Response</div>
<VarReferencePicker
nodeId={id}
isShowNodeName
readonly={false}
value={data.inputs?.response || []}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).inputs = { ...(draft as any).inputs, response: v } }))}
/>
</div>
</div>
</Field>
</div>
<Split />
<div>
<OutputVars>
<>
<VarItem name='judge_passed' type='boolean' description={t('workflow.nodes.judgingLLM.outputVars.judgePassed')} />
<VarItem name='judge_rating' type='number' description={t('workflow.nodes.judgingLLM.outputVars.judgeRating')} />
<VarItem name='judge_feedback' type='string' description={t('workflow.nodes.judgingLLM.outputVars.judgeFeedback')} />
</>
</OutputVars>
</div>
</div>
)
}
export default memo(Panel)

View file

@ -0,0 +1,24 @@
import type { CommonNodeType, ModelConfig, ValueSelector } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
export type JudgingLLMNodeType = CommonNodeType<{
judge_model: ModelConfig
rubric_prompt_template: string
rating_scale?: number
pass_threshold?: number
inputs?: {
goal?: ValueSelector
response?: ValueSelector
}
}>
export const DEFAULT_JUDGE_MODEL: ModelConfig = {
provider: '',
name: '',
mode: 'chat',
completion_params: {
temperature: 0.3,
},
}
export const JUDGING_LLM_BLOCK_TYPE = BlockEnum.JudgingLLM

View file

@ -0,0 +1,42 @@
import type { NodeDefault } from '../../types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import type { TeamChallengeNodeType } from './types'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Utilities,
sort: 5,
type: BlockEnum.TeamChallenge,
helpLinkUri: 'team-challenge',
})
const nodeDefault: NodeDefault<TeamChallengeNodeType> = {
metaData,
defaultValue: {
defense_selection_policy: 'latest_best',
attack_selection_policy: 'latest_best',
scoring_strategy: 'red_blue_ratio',
inputs: {
team_choice: [],
attack_prompt: [],
defense_prompt: [],
},
},
getOutputVars() {
return [
{ variable: 'team', type: VarType.string },
{ variable: 'judge_passed', type: VarType.boolean },
{ variable: 'judge_rating', type: VarType.number },
{ variable: 'judge_feedback', type: VarType.string },
{ variable: 'categories', type: VarType.object },
{ variable: 'team_points', type: VarType.number },
{ variable: 'total_points', type: VarType.number },
]
},
checkValid(_payload: TeamChallengeNodeType) {
return { isValid: true }
},
}
export default nodeDefault

View file

@ -0,0 +1,25 @@
import type { FC } from 'react'
import React from 'react'
import type { NodeProps } from '@/app/components/workflow/types'
import type { TeamChallengeNodeType } from './types'
const Node: FC<NodeProps<TeamChallengeNodeType>> = ({ data }) => {
const { red_blue_challenge_id, defense_selection_policy, attack_selection_policy } = data
return (
<div className='mb-1 px-3 py-1'>
{red_blue_challenge_id ? (
<div className='flex items-center gap-2'>
<div className='rounded bg-components-badge-white-to-dark px-1 py-0.5 text-[10px] font-semibold uppercase text-text-tertiary'>Red/Blue</div>
<div className='truncate text-xs text-text-secondary' title={red_blue_challenge_id}>{red_blue_challenge_id}</div>
</div>
) : (
<div className='flex items-center gap-2'>
<div className='rounded bg-components-badge-white-to-dark px-1 py-0.5 text-[10px] font-semibold uppercase text-text-tertiary'>Defense: {defense_selection_policy}</div>
<div className='rounded bg-components-badge-white-to-dark px-1 py-0.5 text-[10px] font-semibold uppercase text-text-tertiary'>Attack: {attack_selection_policy}</div>
</div>
)}
</div>
)
}
export default React.memo(Node)

View file

@ -0,0 +1,106 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodePanelProps } from '@/app/components/workflow/types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import type { TeamChallengeNodeType } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import produce from 'immer'
import useSWR from 'swr'
import { fetchRedBlueChallenges } from '@/service/redBlueChallenges'
import Select from '@/app/components/base/select'
const i18nPrefix = 'workflow.nodes.teamChallenge'
const Panel: FC<NodePanelProps<TeamChallengeNodeType>> = ({ id, data }) => {
const { t } = useTranslation()
const { inputs, setInputs } = useNodeCrud<TeamChallengeNodeType>(id, data)
const { data: redBlue } = useSWR('redBlue:list', fetchRedBlueChallenges)
return (
<div className='pt-2'>
<div className='space-y-4 px-4 pb-4'>
<Field title={t(`${i18nPrefix}.selectedChallenge`)} tooltip={t(`${i18nPrefix}.selectedChallengeTip`)}>
<Select
items={(redBlue || []).map((c: any) => ({ value: c.id, name: c.name }))}
defaultValue={data.red_blue_challenge_id || ''}
onSelect={item => setInputs(produce(inputs, (draft) => { (draft as any).red_blue_challenge_id = (item?.value as string) || undefined }))}
allowSearch={false}
/>
</Field>
<Field title={t(`${i18nPrefix}.defenseSelectionPolicy`)} tooltip={t(`${i18nPrefix}.defenseSelectionPolicyTip`)}>
<Select
items={[
{ value: 'latest_best', name: 'latest_best' },
{ value: 'random_active', name: 'random_active' },
{ value: 'round_robin', name: 'round_robin' },
{ value: 'request_new_if_none', name: 'request_new_if_none' },
]}
defaultValue={data.defense_selection_policy || 'latest_best'}
onSelect={item => setInputs(produce(inputs, (draft) => { (draft as any).defense_selection_policy = item.value as string }))}
allowSearch={false}
/>
</Field>
<Field title={t(`${i18nPrefix}.attackSelectionPolicy`)} tooltip={t(`${i18nPrefix}.attackSelectionPolicyTip`)}>
<Select
items={[
{ value: 'latest_best', name: 'latest_best' },
{ value: 'random_active', name: 'random_active' },
{ value: 'round_robin', name: 'round_robin' },
{ value: 'request_new_if_none', name: 'request_new_if_none' },
]}
defaultValue={data.attack_selection_policy || 'latest_best'}
onSelect={item => setInputs(produce(inputs, (draft) => { (draft as any).attack_selection_policy = item.value as string }))}
allowSearch={false}
/>
</Field>
<Field title={t(`${i18nPrefix}.teamChoiceVar`)} tooltip={t(`${i18nPrefix}.teamChoiceVarTip`)}>
<VarReferencePicker
nodeId={id}
isShowNodeName
readonly={false}
value={data.inputs?.team_choice || []}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).inputs = { ...(draft as any).inputs, team_choice: v } }))}
/>
</Field>
<Field title={t(`${i18nPrefix}.attackPromptVar`)} tooltip={t(`${i18nPrefix}.attackPromptVarTip`)}>
<VarReferencePicker
nodeId={id}
isShowNodeName
readonly={false}
value={data.inputs?.attack_prompt || []}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).inputs = { ...(draft as any).inputs, attack_prompt: v } }))}
/>
</Field>
<Field title={t(`${i18nPrefix}.defensePromptVar`)} tooltip={t(`${i18nPrefix}.defensePromptVarTip`)}>
<VarReferencePicker
nodeId={id}
isShowNodeName
readonly={false}
value={data.inputs?.defense_prompt || []}
onChange={v => setInputs(produce(inputs, (draft) => { (draft as any).inputs = { ...(draft as any).inputs, defense_prompt: v } }))}
/>
</Field>
</div>
<Split />
<div>
<OutputVars>
<>
<VarItem name='team' type='string' description='Team' />
<VarItem name='judge_passed' type='boolean' description='Judge passed' />
<VarItem name='judge_rating' type='number' description='Judge rating' />
<VarItem name='judge_feedback' type='string' description='Judge feedback' />
<VarItem name='categories' type='object' description='Category outcomes' />
<VarItem name='team_points' type='number' description='Team points' />
<VarItem name='total_points' type='number' description='Total points' />
</>
</OutputVars>
</div>
</div>
)
}
export default memo(Panel)

View file

@ -0,0 +1,16 @@
import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
export type TeamChallengeNodeType = CommonNodeType<{
red_blue_challenge_id?: string
defense_selection_policy?: 'latest_best' | 'random_active' | 'round_robin' | 'request_new_if_none'
attack_selection_policy?: 'latest_best' | 'random_active' | 'round_robin' | 'request_new_if_none'
scoring_strategy?: 'red_blue_ratio' | 'custom'
inputs?: {
team_choice?: ValueSelector
attack_prompt?: ValueSelector
defense_prompt?: ValueSelector
}
}>
export const TEAM_CHALLENGE_BLOCK_TYPE = BlockEnum.TeamChallenge

View file

@ -50,6 +50,9 @@ export enum BlockEnum {
DataSource = 'datasource',
DataSourceEmpty = 'datasource-empty',
KnowledgeBase = 'knowledge-index',
ChallengeEvaluator = 'challenge-evaluator',
JudgingLLM = 'judging-llm',
TeamChallenge = 'team-challenge',
}
export enum ControlMode {