feat: add challenge and red-blue competitions across API and web
This commit is contained in:
parent
f5161d9add
commit
8fd3c4bb64
77 changed files with 5355 additions and 24 deletions
102
web/__tests__/challenge-submission.test.ts
Normal file
102
web/__tests__/challenge-submission.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { submitChallengeAttempt } from '@/service/challenges'
|
||||
import { postPublic } from '@/service/base'
|
||||
import { PUBLIC_API_PREFIX } from '@/config'
|
||||
|
||||
jest.mock('@/service/base', () => ({
|
||||
getPublic: jest.fn(),
|
||||
postPublic: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockedPostPublic = postPublic as jest.MockedFunction<typeof postPublic>
|
||||
const originalFetch = globalThis.fetch
|
||||
let fetchMock: jest.Mock
|
||||
|
||||
describe('submitChallengeAttempt', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.fn()
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
mockedPostPublic.mockReset()
|
||||
mockedPostPublic.mockResolvedValue({ result: 'success' } as any)
|
||||
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('throws when challenge web app is not published', async () => {
|
||||
await expect(
|
||||
submitChallengeAttempt('challenge-id', 'app-id', undefined, 'chat', 'hello'),
|
||||
).rejects.toThrow('Challenge app is not published')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(mockedPostPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requests a passport token and submits chat attempts through /chat-messages', async () => {
|
||||
const passportToken = 'chat-passport-token'
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ access_token: passportToken }),
|
||||
})
|
||||
|
||||
await submitChallengeAttempt('challenge-123', 'app-abc', 'site-code-xyz', 'chat', 'solve this')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${PUBLIC_API_PREFIX}/passport`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-App-Code': 'site-code-xyz',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
expect(mockedPostPublic).toHaveBeenCalledWith('/chat-messages', expect.objectContaining({
|
||||
body: {
|
||||
query: 'solve this',
|
||||
inputs: {},
|
||||
response_mode: 'blocking',
|
||||
conversation_id: '',
|
||||
},
|
||||
}))
|
||||
|
||||
const storedToken = JSON.parse(localStorage.getItem('token') || '{}')
|
||||
expect(storedToken.version).toBe(2)
|
||||
expect(storedToken['challenge-123'].DEFAULT).toBe(passportToken)
|
||||
})
|
||||
|
||||
it('requests a passport token and submits workflow attempts through /workflows/run', async () => {
|
||||
const passportToken = 'workflow-passport-token'
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ access_token: passportToken }),
|
||||
})
|
||||
|
||||
await submitChallengeAttempt('challenge-456', 'app-def', 'site-code-xyz', 'workflow', 'my answer')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${PUBLIC_API_PREFIX}/passport`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-App-Code': 'site-code-xyz',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
expect(mockedPostPublic).toHaveBeenCalledWith('/workflows/run', expect.objectContaining({
|
||||
body: {
|
||||
inputs: {
|
||||
user_prompt: 'my answer',
|
||||
},
|
||||
response_mode: 'blocking',
|
||||
},
|
||||
}))
|
||||
|
||||
const storedToken = JSON.parse(localStorage.getItem('token') || '{}')
|
||||
expect(storedToken['challenge-456'].DEFAULT).toBe(passportToken)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Select from '@/app/components/base/select'
|
||||
import { createConsoleChallenge } from '@/service/console/challenges'
|
||||
import { useAppFullList } from '@/service/use-apps'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export default function CreateChallengeModal({ show, onHide, onSuccess }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState({
|
||||
app_id: '',
|
||||
workflow_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
goal: '',
|
||||
is_active: true,
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { data: appsData } = useAppFullList()
|
||||
const apps = appsData?.data || []
|
||||
const { data: workflowData } = useAppWorkflow(form.app_id)
|
||||
const hasWorkflow = !!workflowData?.graph
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.app_id || !form.name) {
|
||||
Toast.notify({ type: 'error', message: 'App ID and Name are required' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await createConsoleChallenge(form)
|
||||
Toast.notify({ type: 'success', message: 'Challenge created successfully' })
|
||||
onSuccess()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Failed to create challenge' })
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={onHide}
|
||||
title={t('challenges.console.create')}
|
||||
className='!max-w-[640px]'
|
||||
>
|
||||
<div className='space-y-4 p-8'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.appId')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Select
|
||||
className='w-full'
|
||||
defaultValue={form.app_id}
|
||||
onSelect={item => setForm({ ...form, app_id: item.value as string, workflow_id: '' })}
|
||||
placeholder={t('common.placeholder.select')}
|
||||
items={apps.map(app => ({
|
||||
value: app.id,
|
||||
name: app.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.app_id && hasWorkflow && (
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.workflowId')}
|
||||
</label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
className='flex-1'
|
||||
value={workflowData?.id || ''}
|
||||
disabled
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => setForm({ ...form, workflow_id: workflowData?.id || '' })}
|
||||
>
|
||||
Use Workflow
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-text-tertiary'>
|
||||
{workflowData?.id ? `Workflow ID: ${workflowData.id}` : 'No workflow published'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.name')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t('challenges.console.form.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.description')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
placeholder={t('challenges.console.form.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.goal')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.goal}
|
||||
onChange={e => setForm({ ...form, goal: e.target.value })}
|
||||
placeholder={t('challenges.console.form.goalPlaceholder')}
|
||||
rows={2}
|
||||
/>
|
||||
<div className='mt-1 text-xs text-text-tertiary'>
|
||||
This will be shown to players. Challenge logic is defined in the workflow using Challenge Evaluator nodes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.isActive')}
|
||||
</label>
|
||||
<Switch
|
||||
defaultValue={form.is_active}
|
||||
onChange={v => setForm({ ...form, is_active: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={handleSubmit} loading={loading}>
|
||||
{t('common.operation.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
133
web/app/(commonLayout)/console/challenges/page.tsx
Normal file
133
web/app/(commonLayout)/console/challenges/page.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAddLine, RiDeleteBinLine } from '@remixicon/react'
|
||||
import { deleteConsoleChallenge, listConsoleChallenges, updateConsoleChallenge } from '@/service/console/challenges'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import CreateChallengeModal from './create-challenge-modal'
|
||||
|
||||
export default function ConsoleChallengesPage() {
|
||||
const { t } = useTranslation()
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await listConsoleChallenges()
|
||||
setItems(data)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDeleteId)
|
||||
return
|
||||
try {
|
||||
await deleteConsoleChallenge(pendingDeleteId)
|
||||
Toast.notify({ type: 'success', message: 'Challenge deleted' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Delete failed' })
|
||||
}
|
||||
finally {
|
||||
setPendingDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (item: any) => {
|
||||
try {
|
||||
await updateConsoleChallenge(item.id, { is_active: !item.is_active })
|
||||
Toast.notify({ type: 'success', message: item.is_active ? 'Deactivated' : 'Activated' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Update failed' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-components-panel-bg'>
|
||||
<div className='flex items-center justify-between border-b border-divider-subtle px-12 py-4'>
|
||||
<h1 className='text-xl font-semibold text-text-primary'>{t('challenges.console.title')}</h1>
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
{t('challenges.console.create')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto px-12 py-6'>
|
||||
{loading ? (
|
||||
<div className='text-text-tertiary'>{t('common.loading')}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-16'>
|
||||
<div className='mb-2 text-text-secondary'>{t('challenges.console.empty')}</div>
|
||||
<div className='text-sm text-text-tertiary'>{t('challenges.console.emptyDesc')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className='group relative rounded-xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs transition-shadow hover:shadow-md'>
|
||||
<div className='mb-2 text-base font-semibold text-text-primary'>{item.name}</div>
|
||||
{item.description && (
|
||||
<div className='mb-2 line-clamp-2 text-sm text-text-secondary'>{item.description}</div>
|
||||
)}
|
||||
{item.goal && (
|
||||
<div className='mb-2 line-clamp-1 text-xs text-text-tertiary'>Goal: {item.goal}</div>
|
||||
)}
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<div className={`rounded px-2 py-0.5 text-xs font-medium ${item.is_active ? 'bg-util-colors-green-green-100 text-util-colors-green-green-700' : 'bg-components-badge-gray text-text-tertiary'}`}>
|
||||
{item.is_active ? t('challenges.console.status.active') : t('challenges.console.status.inactive')}
|
||||
</div>
|
||||
<div className='flex gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => handleToggleActive(item)}
|
||||
>
|
||||
{item.is_active ? t('challenges.console.actions.deactivate') : t('challenges.console.actions.activate')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => setPendingDeleteId(item.id)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showModal && (
|
||||
<CreateChallengeModal
|
||||
show={showModal}
|
||||
onHide={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false)
|
||||
void load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm
|
||||
isShow={Boolean(pendingDeleteId)}
|
||||
title={t('challenges.console.actions.deleteConfirm')}
|
||||
content={t('challenges.console.actions.deleteConfirm')}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Select from '@/app/components/base/select'
|
||||
import { createRedBlueChallenge } from '@/service/console/challenges'
|
||||
import { useAppFullList } from '@/service/use-apps'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export default function CreateRedBlueModal({ show, onHide, onSuccess }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState({
|
||||
app_id: '',
|
||||
workflow_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
judge_suite: ['CBRNE', 'SA', 'SH', 'RWH', 'V', 'M'],
|
||||
defense_selection_policy: 'latest_best',
|
||||
attack_selection_policy: 'latest_best',
|
||||
scoring_strategy: 'red_blue_ratio',
|
||||
is_active: true,
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { data: appsData } = useAppFullList()
|
||||
const apps = appsData?.data || []
|
||||
const { data: workflowData } = useAppWorkflow(form.app_id)
|
||||
const hasWorkflow = !!workflowData?.graph
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.app_id || !form.name) {
|
||||
Toast.notify({ type: 'error', message: 'App ID and Name are required' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await createRedBlueChallenge(form)
|
||||
Toast.notify({ type: 'success', message: 'Red/Blue challenge created successfully' })
|
||||
onSuccess()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Failed to create challenge' })
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={onHide}
|
||||
title={t('challenges.console.createRedBlue')}
|
||||
className='!max-w-[640px]'
|
||||
>
|
||||
<div className='space-y-4 p-8'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.appId')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Select
|
||||
className='w-full'
|
||||
defaultValue={form.app_id}
|
||||
onSelect={item => setForm({ ...form, app_id: item.value as string, workflow_id: '' })}
|
||||
placeholder={t('common.placeholder.select')}
|
||||
items={apps.map(app => ({
|
||||
value: app.id,
|
||||
name: app.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.app_id && hasWorkflow && (
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.workflowId')}
|
||||
</label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
className='flex-1'
|
||||
value={workflowData?.id || ''}
|
||||
disabled
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => setForm({ ...form, workflow_id: workflowData?.id || '' })}
|
||||
>
|
||||
Use Workflow
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-text-tertiary'>
|
||||
{workflowData?.id ? `Workflow ID: ${workflowData.id}` : 'No workflow published'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.name')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t('challenges.console.form.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.description')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
placeholder={t('challenges.console.form.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.defensePolicy')}
|
||||
</label>
|
||||
<select
|
||||
className='input-select w-full'
|
||||
value={form.defense_selection_policy}
|
||||
onChange={e => setForm({ ...form, defense_selection_policy: e.target.value })}
|
||||
>
|
||||
<option value='latest_best'>Latest Best</option>
|
||||
<option value='random_active'>Random Active</option>
|
||||
<option value='round_robin'>Round Robin</option>
|
||||
<option value='request_new_if_none'>Request New If None</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.attackPolicy')}
|
||||
</label>
|
||||
<select
|
||||
className='input-select w-full'
|
||||
value={form.attack_selection_policy}
|
||||
onChange={e => setForm({ ...form, attack_selection_policy: e.target.value })}
|
||||
>
|
||||
<option value='latest_best'>Latest Best</option>
|
||||
<option value='random_active'>Random Active</option>
|
||||
<option value='round_robin'>Round Robin</option>
|
||||
<option value='request_new_if_none'>Request New If None</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.judgeSuite')}
|
||||
</label>
|
||||
<div className='text-xs text-text-tertiary'>
|
||||
Categories: {form.judge_suite.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.isActive')}
|
||||
</label>
|
||||
<Switch
|
||||
defaultValue={form.is_active}
|
||||
onChange={v => setForm({ ...form, is_active: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={handleSubmit} loading={loading}>
|
||||
{t('common.operation.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
136
web/app/(commonLayout)/console/red-blue-challenges/page.tsx
Normal file
136
web/app/(commonLayout)/console/red-blue-challenges/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAddLine, RiDeleteBinLine } from '@remixicon/react'
|
||||
import { deleteRedBlueChallenge, listRedBlueChallenges, updateRedBlueChallenge } from '@/service/console/challenges'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import CreateRedBlueModal from './create-red-blue-modal'
|
||||
|
||||
export default function ConsoleRedBlueChallengesPage() {
|
||||
const { t } = useTranslation()
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await listRedBlueChallenges()
|
||||
setItems(data)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDeleteId)
|
||||
return
|
||||
try {
|
||||
await deleteRedBlueChallenge(pendingDeleteId)
|
||||
Toast.notify({ type: 'success', message: 'Challenge deleted' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Delete failed' })
|
||||
}
|
||||
finally {
|
||||
setPendingDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (item: any) => {
|
||||
try {
|
||||
await updateRedBlueChallenge(item.id, { is_active: !item.is_active })
|
||||
Toast.notify({ type: 'success', message: item.is_active ? 'Deactivated' : 'Activated' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Update failed' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-components-panel-bg'>
|
||||
<div className='flex items-center justify-between border-b border-divider-subtle px-12 py-4'>
|
||||
<h1 className='text-xl font-semibold text-text-primary'>{t('challenges.redBlue.title')}</h1>
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
{t('challenges.console.createRedBlue')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto px-12 py-6'>
|
||||
{loading ? (
|
||||
<div className='text-text-tertiary'>{t('common.loading')}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-16'>
|
||||
<div className='mb-2 text-text-secondary'>{t('challenges.console.empty')}</div>
|
||||
<div className='text-sm text-text-tertiary'>{t('challenges.console.emptyDesc')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className='group relative rounded-xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs transition-shadow hover:shadow-md'>
|
||||
<div className='mb-2 flex items-start justify-between'>
|
||||
<div className='text-base font-semibold text-text-primary'>{item.name}</div>
|
||||
<div className='flex gap-1'>
|
||||
<div className='rounded bg-util-colors-red-red-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-util-colors-red-red-700'>RED</div>
|
||||
<div className='rounded bg-util-colors-blue-blue-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-util-colors-blue-blue-700'>BLUE</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className='mb-2 line-clamp-2 text-sm text-text-secondary'>{item.description}</div>
|
||||
)}
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<div className={`rounded px-2 py-0.5 text-xs font-medium ${item.is_active ? 'bg-util-colors-green-green-100 text-util-colors-green-green-700' : 'bg-components-badge-gray text-text-tertiary'}`}>
|
||||
{item.is_active ? t('challenges.console.status.active') : t('challenges.console.status.inactive')}
|
||||
</div>
|
||||
<div className='flex gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => handleToggleActive(item)}
|
||||
>
|
||||
{item.is_active ? t('challenges.console.actions.deactivate') : t('challenges.console.actions.activate')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => setPendingDeleteId(item.id)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showModal && (
|
||||
<CreateRedBlueModal
|
||||
show={showModal}
|
||||
onHide={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false)
|
||||
void load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm
|
||||
isShow={Boolean(pendingDeleteId)}
|
||||
title={t('challenges.console.actions.deleteConfirm')}
|
||||
content={t('challenges.console.actions.deleteConfirm')}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
web/app/challenges/[id]/page.tsx
Normal file
206
web/app/challenges/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { RiCheckLine, RiCloseLine, RiLoader4Line } from '@remixicon/react'
|
||||
import { fetchChallengeDetail, fetchChallengeLeaderboard, submitChallengeAttempt } from '@/service/challenges'
|
||||
import Leaderboard from '@/app/components/challenge/leaderboard'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
export default function ChallengeDetailPage() {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
|
||||
const [challenge, setChallenge] = useState<any>(null)
|
||||
const [leaderboard, setLeaderboard] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [userInput, setUserInput] = useState('')
|
||||
const [lastResult, setLastResult] = useState<{ success: boolean; message?: string; rating?: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [detail, leaders] = await Promise.all([
|
||||
fetchChallengeDetail(id),
|
||||
fetchChallengeLeaderboard(id),
|
||||
])
|
||||
setChallenge(detail)
|
||||
setLeaderboard(leaders)
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Failed to load challenge' })
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
if (id)
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!userInput.trim()) {
|
||||
Toast.notify({ type: 'error', message: 'Please enter a response' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!challenge?.app_id) {
|
||||
Toast.notify({ type: 'error', message: 'Challenge is not configured with an app' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setLastResult(null)
|
||||
try {
|
||||
// Execute the workflow with the user's input
|
||||
// Endpoint varies by app type (chat vs workflow)
|
||||
const result = await submitChallengeAttempt(
|
||||
id,
|
||||
challenge.app_id,
|
||||
challenge.app_site_code,
|
||||
challenge.app_mode || 'workflow',
|
||||
userInput,
|
||||
)
|
||||
|
||||
// Extract challenge results from workflow output
|
||||
// Response structure differs by app mode:
|
||||
// - Chat apps: result.data.answer + result.data.metadata.outputs
|
||||
// - Workflow apps: result.data (direct outputs)
|
||||
const isChatApp = challenge.app_mode === 'chat' || challenge.app_mode === 'advanced-chat'
|
||||
const workflowOutputs = isChatApp
|
||||
? (result.data?.metadata?.outputs || {})
|
||||
: (result.data || {})
|
||||
|
||||
const success = workflowOutputs.challenge_succeeded || false
|
||||
const rating = workflowOutputs.judge_rating
|
||||
const feedback = workflowOutputs.judge_feedback || workflowOutputs.message || result.data?.answer
|
||||
|
||||
setLastResult({
|
||||
success,
|
||||
message: feedback || (success ? 'Challenge passed!' : 'Challenge not passed.'),
|
||||
rating,
|
||||
})
|
||||
|
||||
if (success) {
|
||||
Toast.notify({ type: 'success', message: 'Challenge completed!' })
|
||||
// Refresh leaderboard
|
||||
const leaders = await fetchChallengeLeaderboard(id)
|
||||
setLeaderboard(leaders)
|
||||
}
|
||||
}
|
||||
catch (e: any) {
|
||||
console.error('Submission error:', e)
|
||||
Toast.notify({ type: 'error', message: e.message || 'Submission failed' })
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-components-panel-bg'>
|
||||
<div className='text-text-tertiary'>{t('common.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!challenge) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-components-panel-bg'>
|
||||
<div className='text-text-secondary'>Challenge not found</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-components-panel-bg'>
|
||||
<div className='mx-auto max-w-5xl px-4 py-12 sm:px-6 lg:px-8'>
|
||||
<div className='mb-8'>
|
||||
<h1 className='mb-2 text-3xl font-bold text-text-primary'>{challenge.name}</h1>
|
||||
{challenge.description && (
|
||||
<p className='text-lg text-text-secondary'>{challenge.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='lg:col-span-2'>
|
||||
{challenge.goal && (
|
||||
<div className='mb-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
|
||||
<h2 className='mb-2 text-sm font-medium uppercase tracking-wide text-text-tertiary'>
|
||||
{t('challenges.player.goal')}
|
||||
</h2>
|
||||
<p className='text-text-primary'>{challenge.goal}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
|
||||
<h2 className='mb-4 text-lg font-semibold text-text-primary'>
|
||||
{t('challenges.player.yourAttempt')}
|
||||
</h2>
|
||||
|
||||
<Textarea
|
||||
value={userInput}
|
||||
onChange={e => setUserInput(e.target.value)}
|
||||
placeholder='Enter your response here...'
|
||||
rows={8}
|
||||
className='mb-4 w-full'
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!userInput.trim()}
|
||||
className='w-full'
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<RiLoader4Line className='mr-2 h-4 w-4 animate-spin' />
|
||||
{t('common.operation.processing')}
|
||||
</>
|
||||
) : (
|
||||
t('challenges.player.submit')
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{lastResult && (
|
||||
<div className={`mt-4 rounded-lg border p-4 ${lastResult.success ? 'border-util-colors-green-green-500 bg-util-colors-green-green-50' : 'border-util-colors-orange-orange-500 bg-util-colors-orange-orange-50'}`}>
|
||||
<div className='flex items-start gap-3'>
|
||||
{lastResult.success ? (
|
||||
<RiCheckLine className='h-5 w-5 shrink-0 text-util-colors-green-green-600' />
|
||||
) : (
|
||||
<RiCloseLine className='h-5 w-5 shrink-0 text-util-colors-orange-orange-600' />
|
||||
)}
|
||||
<div className='flex-1'>
|
||||
<div className={`mb-1 font-medium ${lastResult.success ? 'text-util-colors-green-green-700' : 'text-util-colors-orange-orange-700'}`}>
|
||||
{lastResult.success ? t('challenges.player.status.success') : t('challenges.player.status.failed')}
|
||||
</div>
|
||||
{lastResult.message && (
|
||||
<div className='text-sm text-text-secondary'>{lastResult.message}</div>
|
||||
)}
|
||||
{lastResult.rating !== undefined && (
|
||||
<div className='mt-2 text-sm text-text-tertiary'>
|
||||
{t('challenges.leaderboard.rating')}: {lastResult.rating}/10
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='lg:col-span-1'>
|
||||
<Leaderboard entries={leaderboard} strategy={challenge.scoring_strategy} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
web/app/challenges/page.tsx
Normal file
78
web/app/challenges/page.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import { RiArrowRightLine, RiAwardLine } from '@remixicon/react'
|
||||
import { fetchChallenges } from '@/service/challenges'
|
||||
import type { ChallengeListItem } from '@/service/challenges'
|
||||
|
||||
export default function ChallengesListPage() {
|
||||
const { t } = useTranslation()
|
||||
const [challenges, setChallenges] = useState<ChallengeListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchChallenges()
|
||||
console.log('Loaded challenges:', data)
|
||||
setChallenges(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load challenges:', error)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-components-panel-bg'>
|
||||
<div className='mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8'>
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-4xl font-bold text-text-primary'>{t('challenges.player.browse')}</h1>
|
||||
<p className='text-lg text-text-secondary'>Test your skills and compete on the leaderboard</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className='text-center text-text-tertiary'>{t('common.loading')}</div>
|
||||
) : challenges.length === 0 ? (
|
||||
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-12 text-center'>
|
||||
<RiAwardLine className='mx-auto mb-4 h-12 w-12 text-text-quaternary' />
|
||||
<div className='text-text-secondary'>No challenges available yet</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{challenges.map(challenge => (
|
||||
<Link
|
||||
key={challenge.id}
|
||||
href={`/challenges/${challenge.id}`}
|
||||
className='group block'
|
||||
>
|
||||
<div className='h-full rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs transition-all hover:border-components-button-primary-bg hover:shadow-md'>
|
||||
<div className='mb-3 flex items-start justify-between'>
|
||||
<RiAwardLine className='h-8 w-8 text-util-colors-cyan-cyan-500' />
|
||||
<RiArrowRightLine className='h-5 w-5 text-text-quaternary transition-transform group-hover:translate-x-1' />
|
||||
</div>
|
||||
<h3 className='mb-2 text-lg font-semibold text-text-primary'>{challenge.name}</h3>
|
||||
{challenge.description && (
|
||||
<p className='mb-3 line-clamp-2 text-sm text-text-secondary'>{challenge.description}</p>
|
||||
)}
|
||||
{challenge.goal && (
|
||||
<div className='mt-4 rounded-lg bg-components-panel-on-panel-item-bg p-3'>
|
||||
<div className='mb-1 text-xs font-medium uppercase text-text-tertiary'>Goal</div>
|
||||
<div className='line-clamp-2 text-sm text-text-secondary'>{challenge.goal}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
web/app/components/challenge/leaderboard.tsx
Normal file
124
web/app/components/challenge/leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
121
web/app/components/workflow/nodes/challenge-evaluator/panel.tsx
Normal file
121
web/app/components/workflow/nodes/challenge-evaluator/panel.tsx
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
45
web/app/components/workflow/nodes/judging-llm/default.ts
Normal file
45
web/app/components/workflow/nodes/judging-llm/default.ts
Normal 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
|
||||
33
web/app/components/workflow/nodes/judging-llm/node.tsx
Normal file
33
web/app/components/workflow/nodes/judging-llm/node.tsx
Normal 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)
|
||||
143
web/app/components/workflow/nodes/judging-llm/panel.tsx
Normal file
143
web/app/components/workflow/nodes/judging-llm/panel.tsx
Normal 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)
|
||||
24
web/app/components/workflow/nodes/judging-llm/types.ts
Normal file
24
web/app/components/workflow/nodes/judging-llm/types.ts
Normal 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
|
||||
42
web/app/components/workflow/nodes/team-challenge/default.ts
Normal file
42
web/app/components/workflow/nodes/team-challenge/default.ts
Normal 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
|
||||
25
web/app/components/workflow/nodes/team-challenge/node.tsx
Normal file
25
web/app/components/workflow/nodes/team-challenge/node.tsx
Normal 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)
|
||||
106
web/app/components/workflow/nodes/team-challenge/panel.tsx
Normal file
106
web/app/components/workflow/nodes/team-challenge/panel.tsx
Normal 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)
|
||||
16
web/app/components/workflow/nodes/team-challenge/types.ts
Normal file
16
web/app/components/workflow/nodes/team-challenge/types.ts
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
191
web/app/red-blue-challenges/[id]/page.tsx
Normal file
191
web/app/red-blue-challenges/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { RiLoader4Line, RiShieldLine, RiSwordLine } from '@remixicon/react'
|
||||
import { fetchRedBlueLeaderboard, submitRedBluePrompt } from '@/service/redBlueChallenges'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
export default function RedBlueChallengeDetailPage() {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
|
||||
const [team, setTeam] = useState<'red' | 'blue' | null>(null)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [lastResult, setLastResult] = useState<any>(null)
|
||||
const [leaderboard, setLeaderboard] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const leaders = await fetchRedBlueLeaderboard(id)
|
||||
setLeaderboard(leaders)
|
||||
}
|
||||
catch (e: any) {
|
||||
console.error('Failed to load leaderboard:', e)
|
||||
}
|
||||
}
|
||||
if (id)
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!team || !prompt.trim()) {
|
||||
Toast.notify({ type: 'error', message: 'Please choose a team and enter a prompt' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setLastResult(null)
|
||||
try {
|
||||
const result = await submitRedBluePrompt(id, team, prompt)
|
||||
setLastResult(result)
|
||||
Toast.notify({ type: 'success', message: 'Prompt submitted!' })
|
||||
setPrompt('')
|
||||
|
||||
// Refresh leaderboard
|
||||
const leaders = await fetchRedBlueLeaderboard(id)
|
||||
setLeaderboard(leaders)
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Submission failed' })
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-components-panel-bg'>
|
||||
<div className='mx-auto max-w-5xl px-4 py-12 sm:px-6 lg:px-8'>
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-3xl font-bold text-text-primary'>{t('challenges.redBlue.title')}</h1>
|
||||
<p className='text-lg text-text-secondary'>Join the Red or Blue team and compete</p>
|
||||
</div>
|
||||
|
||||
{!team ? (
|
||||
<div className='grid gap-6 sm:grid-cols-2'>
|
||||
<button
|
||||
onClick={() => setTeam('red')}
|
||||
className='group rounded-xl border-2 border-util-colors-red-red-300 bg-util-colors-red-red-50 p-8 shadow-xs transition-all hover:border-util-colors-red-red-500 hover:shadow-md'
|
||||
>
|
||||
<RiSwordLine className='mx-auto mb-4 h-16 w-16 text-util-colors-red-red-600' />
|
||||
<h2 className='mb-2 text-2xl font-bold text-util-colors-red-red-700'>
|
||||
{t('challenges.redBlue.red')}
|
||||
</h2>
|
||||
<p className='text-util-colors-red-red-600'>{t('challenges.redBlue.redDesc')}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTeam('blue')}
|
||||
className='group rounded-xl border-2 border-util-colors-blue-blue-300 bg-util-colors-blue-blue-50 p-8 shadow-xs transition-all hover:border-util-colors-blue-blue-500 hover:shadow-md'
|
||||
>
|
||||
<RiShieldLine className='mx-auto mb-4 h-16 w-16 text-util-colors-blue-blue-600' />
|
||||
<h2 className='mb-2 text-2xl font-bold text-util-colors-blue-blue-700'>
|
||||
{t('challenges.redBlue.blue')}
|
||||
</h2>
|
||||
<p className='text-util-colors-blue-blue-600'>{t('challenges.redBlue.blueDesc')}</p>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='lg:col-span-2'>
|
||||
<div className={`rounded-xl border-2 p-6 shadow-xs ${team === 'red' ? 'border-util-colors-red-red-300 bg-util-colors-red-red-50' : 'border-util-colors-blue-blue-300 bg-util-colors-blue-blue-50'}`}>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className={`text-xl font-bold ${team === 'red' ? 'text-util-colors-red-red-700' : 'text-util-colors-blue-blue-700'}`}>
|
||||
{team === 'red' ? t('challenges.redBlue.submitAttack') : t('challenges.redBlue.submitDefense')}
|
||||
</h2>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setTeam(null)
|
||||
setPrompt('')
|
||||
setLastResult(null)
|
||||
}}
|
||||
>
|
||||
Switch Team
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder={team === 'red' ? t('challenges.redBlue.attackPrompt') : t('challenges.redBlue.defensePrompt')}
|
||||
rows={8}
|
||||
className='mb-4 w-full'
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!prompt.trim()}
|
||||
className='w-full'
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<RiLoader4Line className='mr-2 h-4 w-4 animate-spin' />
|
||||
{t('common.operation.processing')}
|
||||
</>
|
||||
) : (
|
||||
t('challenges.player.submit')
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{lastResult && (
|
||||
<div className='mt-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4'>
|
||||
<h3 className='mb-2 font-medium text-text-primary'>{t('challenges.redBlue.results')}</h3>
|
||||
{lastResult.judge_rating !== undefined && (
|
||||
<div className='text-sm text-text-secondary'>
|
||||
{t('challenges.leaderboard.rating')}: {lastResult.judge_rating}/10
|
||||
</div>
|
||||
)}
|
||||
{lastResult.team_points !== undefined && (
|
||||
<div className='mt-1 text-sm text-text-secondary'>
|
||||
Points earned: {lastResult.team_points}
|
||||
</div>
|
||||
)}
|
||||
{lastResult.judge_feedback && (
|
||||
<div className='mt-2 text-sm text-text-tertiary'>{lastResult.judge_feedback}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='lg:col-span-1'>
|
||||
{leaderboard && (
|
||||
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
|
||||
<h3 className='mb-4 text-lg font-semibold text-text-primary'>Standings</h3>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between rounded-lg bg-util-colors-red-red-50 p-3'>
|
||||
<span className='font-medium text-util-colors-red-red-700'>
|
||||
{t('challenges.redBlue.redPoints')}
|
||||
</span>
|
||||
<span className='text-xl font-bold text-util-colors-red-red-700'>
|
||||
{leaderboard.red_points || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between rounded-lg bg-util-colors-blue-blue-50 p-3'>
|
||||
<span className='font-medium text-util-colors-blue-blue-700'>
|
||||
{t('challenges.redBlue.bluePoints')}
|
||||
</span>
|
||||
<span className='text-xl font-bold text-util-colors-blue-blue-700'>
|
||||
{leaderboard.blue_points || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
web/app/red-blue-challenges/page.tsx
Normal file
23
web/app/red-blue-challenges/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Link from 'next/link'
|
||||
import { fetchRedBlueChallenges } from '@/service/redBlueChallenges'
|
||||
|
||||
export default async function RedBlueChallengesPage() {
|
||||
const items = await fetchRedBlueChallenges()
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<h1 className="mb-4 text-xl font-semibold">Red / Blue Challenges</h1>
|
||||
<ul className="space-y-2">
|
||||
{items.map(i => (
|
||||
<li key={i.id} className="rounded border p-3">
|
||||
<Link href={`/red-blue-challenges/${i.id}`} className="text-primary hover:underline">
|
||||
{i.name}
|
||||
</Link>
|
||||
{i.description && (
|
||||
<p className="mt-1 text-sm text-gray-500">{i.description}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ const NAMESPACES = [
|
|||
'app-overview',
|
||||
'app',
|
||||
'billing',
|
||||
'challenges',
|
||||
'common',
|
||||
'custom',
|
||||
'dataset-creation',
|
||||
|
|
|
|||
80
web/i18n/en-US/challenges.ts
Normal file
80
web/i18n/en-US/challenges.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
export default {
|
||||
console: {
|
||||
title: 'Challenges',
|
||||
create: 'Create Challenge',
|
||||
createRedBlue: 'Create Red/Blue Challenge',
|
||||
edit: 'Edit Challenge',
|
||||
empty: 'No challenges yet',
|
||||
emptyDesc: 'Create your first challenge to get started',
|
||||
form: {
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Enter challenge name',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Describe the challenge',
|
||||
goal: 'Goal',
|
||||
goalPlaceholder: 'What should players achieve?',
|
||||
appId: 'App ID',
|
||||
workflowId: 'Workflow ID',
|
||||
evaluatorType: 'Evaluator Type',
|
||||
successType: 'Success Type',
|
||||
successPattern: 'Success Pattern',
|
||||
scoringStrategy: 'Scoring Strategy',
|
||||
isActive: 'Active',
|
||||
judgeSuite: 'Judge Suite',
|
||||
defensePolicy: 'Defense Selection Policy',
|
||||
attackPolicy: 'Attack Selection Policy',
|
||||
},
|
||||
actions: {
|
||||
activate: 'Activate',
|
||||
deactivate: 'Deactivate',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Are you sure you want to delete this challenge?',
|
||||
},
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
},
|
||||
},
|
||||
player: {
|
||||
title: 'Challenges',
|
||||
browse: 'Browse Challenges',
|
||||
play: 'Play Challenge',
|
||||
submit: 'Submit',
|
||||
tryAgain: 'Try Again',
|
||||
viewLeaderboard: 'View Leaderboard',
|
||||
yourAttempt: 'Your Attempt',
|
||||
goal: 'Goal',
|
||||
status: {
|
||||
success: 'Success!',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
},
|
||||
},
|
||||
leaderboard: {
|
||||
title: 'Leaderboard',
|
||||
rank: 'Rank',
|
||||
player: 'Player',
|
||||
score: 'Score',
|
||||
time: 'Time',
|
||||
tokens: 'Tokens',
|
||||
rating: 'Rating',
|
||||
empty: 'No attempts yet',
|
||||
yourBest: 'Your Best',
|
||||
},
|
||||
redBlue: {
|
||||
title: 'Red vs Blue',
|
||||
chooseTeam: 'Choose Your Team',
|
||||
red: 'Red Team',
|
||||
blue: 'Blue Team',
|
||||
redDesc: 'Attack: Try to bypass defenses',
|
||||
blueDesc: 'Defense: Prevent attacks',
|
||||
submitAttack: 'Submit Attack',
|
||||
submitDefense: 'Submit Defense',
|
||||
attackPrompt: 'Attack Prompt',
|
||||
defensePrompt: 'Defense Prompt',
|
||||
results: 'Results',
|
||||
redPoints: 'Red Points',
|
||||
bluePoints: 'Blue Points',
|
||||
},
|
||||
}
|
||||
|
|
@ -270,6 +270,9 @@ const translation = {
|
|||
'loop-end': 'Exit Loop',
|
||||
'knowledge-index': 'Knowledge Base',
|
||||
'datasource': 'Data Source',
|
||||
'challenge-evaluator': 'Challenge Evaluator',
|
||||
'judging-llm': 'Judging LLM',
|
||||
'team-challenge': 'Team Challenge',
|
||||
},
|
||||
blocksAbout: {
|
||||
'start': 'Define the initial parameters for launching a workflow',
|
||||
|
|
@ -294,6 +297,9 @@ const translation = {
|
|||
'agent': 'Invoking large language models to answer questions or process natural language',
|
||||
'knowledge-index': 'Knowledge Base About',
|
||||
'datasource': 'Data Source About',
|
||||
'challenge-evaluator': 'Evaluate a model response against success rules or an LLM judge and record attempts.',
|
||||
'judging-llm': 'Judge a response with a rubric using an LLM and output pass/rating/feedback.',
|
||||
'team-challenge': 'Coordinate Red/Blue prompts, run judging, and emit team scores.',
|
||||
},
|
||||
operator: {
|
||||
zoomIn: 'Zoom In',
|
||||
|
|
@ -864,6 +870,69 @@ const translation = {
|
|||
last_record: 'Last record',
|
||||
},
|
||||
},
|
||||
challengeEvaluator: {
|
||||
evaluationMode: 'Evaluation Mode',
|
||||
evaluationModeTip: 'Choose how the response is evaluated: rules, LLM judge, or custom.',
|
||||
successType: 'Success Type',
|
||||
successTypeTip: 'Contains: substring match (case-insensitive). Regex: JavaScript regular expression.',
|
||||
successPattern: 'Success Pattern',
|
||||
successPatternPlaceholder: 'Enter regex or substring',
|
||||
successPatternTip: 'Supports variables. For regex, do not add surrounding slashes.',
|
||||
responseVar: 'Response Variable',
|
||||
responseVarTip: 'Pick the upstream response to evaluate.',
|
||||
selectedChallenge: 'Selected Challenge',
|
||||
selectedChallengeTip: 'Select an existing challenge to use its stored rules and settings.',
|
||||
scoringStrategy: 'Scoring Strategy',
|
||||
scoringStrategyTip: 'How to rank successful attempts on the leaderboard.',
|
||||
scoringFirst: 'First (earliest success)',
|
||||
scoringFastest: 'Fastest (lowest time)',
|
||||
scoringFewestTokens: 'Fewest Tokens',
|
||||
scoringHighestRating: 'Highest Rating',
|
||||
scoringCustom: 'Custom',
|
||||
outputVars: {
|
||||
challengeSucceeded: 'Challenge Succeeded',
|
||||
judgeRating: 'Judge Rating',
|
||||
judgeFeedback: 'Judge Feedback',
|
||||
message: 'Result Message',
|
||||
},
|
||||
},
|
||||
judgingLLM: {
|
||||
rubricTemplate: 'Rubric Template',
|
||||
rubricTemplatePlaceholder: 'Define your evaluation criteria',
|
||||
rubricTemplateTip: 'Template for the judge. Use {goal} and {response} placeholders.',
|
||||
passThreshold: 'Pass Threshold',
|
||||
passThresholdTip: 'Minimum rating (0-10) required to pass.',
|
||||
insertDefaultRubric: 'Insert default rubric',
|
||||
outputVars: {
|
||||
judgePassed: 'Judge Passed',
|
||||
judgeRating: 'Judge Rating',
|
||||
judgeFeedback: 'Judge Feedback',
|
||||
judgeRaw: 'Judge Raw Output',
|
||||
},
|
||||
},
|
||||
teamChallenge: {
|
||||
defenseSelectionPolicy: 'Defense Selection Policy',
|
||||
defenseSelectionPolicyTip: 'Choose how a defense prompt is selected to pair against incoming attacks.',
|
||||
attackSelectionPolicy: 'Attack Selection Policy',
|
||||
attackSelectionPolicyTip: 'Choose how an attack prompt is selected to test your defense.',
|
||||
teamChoiceVar: 'Team Choice Variable',
|
||||
teamChoiceVarTip: 'Variable that yields "red" or "blue" to select the role.',
|
||||
attackPromptVar: 'Attack Prompt Variable',
|
||||
attackPromptVarTip: 'Variable that provides the attacker\'s prompt.',
|
||||
defensePromptVar: 'Defense Prompt Variable',
|
||||
defensePromptVarTip: 'Variable that provides the defender\'s prompt (system prompt).',
|
||||
selectedChallenge: 'Selected Challenge',
|
||||
selectedChallengeTip: 'Pick a Red/Blue challenge definition to orchestrate evaluations.',
|
||||
outputVars: {
|
||||
team: 'Team',
|
||||
judgePassed: 'Judge Passed',
|
||||
judgeRating: 'Judge Rating',
|
||||
judgeFeedback: 'Judge Feedback',
|
||||
categories: 'Categories',
|
||||
teamPoints: 'Team Points',
|
||||
totalPoints: 'Total Points',
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
strategy: {
|
||||
label: 'Agentic Strategy',
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"and_qq >= 14.9"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS='--inspect' next dev --turbopack",
|
||||
"dev": "cross-env NODE_OPTIONS='' next dev --turbopack",
|
||||
"build": "next build",
|
||||
"build:docker": "next build && node scripts/optimize-standalone.js",
|
||||
"start": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cross-env PORT=$npm_config_port HOSTNAME=$npm_config_host node .next/standalone/server.js",
|
||||
|
|
|
|||
1
web/run.sh
Normal file
1
web/run.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
pnpm run dev
|
||||
104
web/service/challenges.ts
Normal file
104
web/service/challenges.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { getPublic, postPublic } from './base'
|
||||
import { PUBLIC_API_PREFIX } from '@/config'
|
||||
import { getInitialTokenV2, isTokenV1 } from '@/app/components/share/utils'
|
||||
import { CONVERSATION_ID_INFO } from '@/app/components/base/chat/constants'
|
||||
|
||||
export type ChallengeListItem = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
goal?: string
|
||||
app_id?: string
|
||||
workflow_id?: string
|
||||
app_mode?: string
|
||||
app_site_code?: string
|
||||
}
|
||||
|
||||
export async function fetchChallenges(): Promise<ChallengeListItem[]> {
|
||||
const res = await getPublic<{ result: string; data: ChallengeListItem[] }>('/challenges')
|
||||
return res.data ?? []
|
||||
}
|
||||
|
||||
export async function fetchChallengeDetail(id: string) {
|
||||
const res = await getPublic<{ result: string; data: any }>(`/challenges/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function fetchChallengeLeaderboard(id: string) {
|
||||
const res = await getPublic<{ result: string; data: any[] }>(`/challenges/${id}/leaderboard`)
|
||||
return res.data ?? []
|
||||
}
|
||||
|
||||
export async function submitChallengeAttempt(
|
||||
challengeId: string,
|
||||
appId: string,
|
||||
appSiteCode: string | undefined,
|
||||
appMode: string,
|
||||
userInput: string,
|
||||
) {
|
||||
if (!appSiteCode)
|
||||
throw new Error('Challenge app is not published. Please enable the app site for this challenge.')
|
||||
|
||||
const passportRes = await fetch(`${PUBLIC_API_PREFIX}/passport`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-App-Code': appSiteCode,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!passportRes.ok) {
|
||||
let message = 'Unable to start challenge. Please try again.'
|
||||
try {
|
||||
const data = await passportRes.json()
|
||||
message = data?.message || message
|
||||
}
|
||||
catch { /* ignore json parse errors */ }
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const passportData = await passportRes.json() as { access_token?: string }
|
||||
const accessToken = passportData?.access_token
|
||||
if (!accessToken)
|
||||
throw new Error('Challenge authorization failed. Please refresh and try again.')
|
||||
|
||||
// Persist token using the same structure expected by getAccessToken(true)
|
||||
const storageKey = 'token'
|
||||
const userKey = 'DEFAULT'
|
||||
const rawTokenStore = localStorage.getItem(storageKey) || JSON.stringify(getInitialTokenV2())
|
||||
let tokenStore: Record<string, any>
|
||||
try {
|
||||
const parsed = JSON.parse(rawTokenStore)
|
||||
tokenStore = isTokenV1(parsed) ? getInitialTokenV2() : parsed
|
||||
}
|
||||
catch {
|
||||
tokenStore = getInitialTokenV2()
|
||||
}
|
||||
|
||||
tokenStore[challengeId] = {
|
||||
...(tokenStore[challengeId] || {}),
|
||||
[userKey]: accessToken,
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(tokenStore))
|
||||
localStorage.removeItem(CONVERSATION_ID_INFO)
|
||||
|
||||
if (appMode === 'chat' || appMode === 'advanced-chat' || appMode === 'agent-chat') {
|
||||
return await postPublic<any>('/chat-messages', {
|
||||
body: {
|
||||
query: userInput,
|
||||
inputs: {},
|
||||
response_mode: 'blocking',
|
||||
conversation_id: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return await postPublic<any>('/workflows/run', {
|
||||
body: {
|
||||
inputs: {
|
||||
user_prompt: userInput,
|
||||
},
|
||||
response_mode: 'blocking',
|
||||
},
|
||||
})
|
||||
}
|
||||
99
web/service/console/challenges.ts
Normal file
99
web/service/console/challenges.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { request } from '@/service/base'
|
||||
|
||||
export type ConsoleChallenge = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
goal?: string
|
||||
is_active?: boolean
|
||||
success_type?: string
|
||||
success_pattern?: string
|
||||
scoring_strategy?: string
|
||||
app_id?: string
|
||||
workflow_id?: string
|
||||
}
|
||||
|
||||
export async function listConsoleChallenges() {
|
||||
const resp = await request<{ data: ConsoleChallenge[] }>('/challenges', {}, {})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function createConsoleChallenge(payload: {
|
||||
app_id: string
|
||||
workflow_id?: string
|
||||
name: string
|
||||
description?: string
|
||||
goal?: string
|
||||
success_type?: string
|
||||
success_pattern?: string
|
||||
scoring_strategy?: string
|
||||
is_active?: boolean
|
||||
}) {
|
||||
const resp = await request<{ data: { id: string } }>('/challenges', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}, {})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateConsoleChallenge(id: string, payload: Partial<ConsoleChallenge>) {
|
||||
const resp = await request<{ data: ConsoleChallenge }>(`/challenges/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
}, {})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteConsoleChallenge(id: string) {
|
||||
await request(`/challenges/${id}`, {
|
||||
method: 'DELETE',
|
||||
}, {})
|
||||
}
|
||||
|
||||
export type RedBlueChallenge = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
judge_suite?: string[]
|
||||
defense_selection_policy?: string
|
||||
attack_selection_policy?: string
|
||||
scoring_strategy?: string
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export async function listRedBlueChallenges() {
|
||||
const resp = await request<{ data: RedBlueChallenge[] }>('/red-blue-challenges', {}, {})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function createRedBlueChallenge(payload: {
|
||||
app_id: string
|
||||
workflow_id?: string
|
||||
name: string
|
||||
description?: string
|
||||
judge_suite?: string[]
|
||||
defense_selection_policy?: string
|
||||
attack_selection_policy?: string
|
||||
scoring_strategy?: string
|
||||
is_active?: boolean
|
||||
}) {
|
||||
const resp = await request<{ data: { id: string } }>('/red-blue-challenges', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}, {})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateRedBlueChallenge(id: string, payload: Partial<RedBlueChallenge>) {
|
||||
const resp = await request<{ data: RedBlueChallenge }>(`/red-blue-challenges/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
}, {})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteRedBlueChallenge(id: string) {
|
||||
await request(`/red-blue-challenges/${id}`, {
|
||||
method: 'DELETE',
|
||||
}, {})
|
||||
}
|
||||
27
web/service/console/redBlueChallenges.ts
Normal file
27
web/service/console/redBlueChallenges.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { request } from '@/service/base'
|
||||
|
||||
export type ConsoleRedBlueChallenge = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export async function listConsoleRedBlueChallenges() {
|
||||
const resp = await request<{ data: ConsoleRedBlueChallenge[] }>('/red-blue-challenges', {}, {})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function createConsoleRedBlueChallenge(payload: {
|
||||
tenant_id: string
|
||||
app_id: string
|
||||
name: string
|
||||
description?: string
|
||||
judge_suite: Record<string, any>
|
||||
}) {
|
||||
const resp = await request<{ data: { id: string } }>('/red-blue-challenges', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}, {})
|
||||
return resp.data
|
||||
}
|
||||
24
web/service/redBlueChallenges.ts
Normal file
24
web/service/redBlueChallenges.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { getPublic, postPublic } from './base'
|
||||
|
||||
export type RedBlueListItem = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export async function fetchRedBlueChallenges(): Promise<RedBlueListItem[]> {
|
||||
const res = await getPublic<{ result: string; data: RedBlueListItem[] }>('/red-blue-challenges')
|
||||
return res.data ?? []
|
||||
}
|
||||
|
||||
export async function fetchRedBlueLeaderboard(id: string) {
|
||||
const res = await getPublic<{ result: string; data: any }>(`/red-blue-challenges/${id}/leaderboard`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function submitRedBluePrompt(id: string, team: 'red' | 'blue', prompt: string) {
|
||||
const res = await postPublic<{ result: string; data: any }>(`/red-blue-challenges/${id}/submit`, {
|
||||
body: { team, prompt },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue