Compare commits

..

8 Commits

Author SHA1 Message Date
yyh
f0cc019f7e docs(e2e): replace static step index with discovery command
The hardcoded step reference table would go stale as steps are
added or modified. Replace it with a grep command and directory
pointers so the information is always current.
2026-04-10 19:38:02 +08:00
yyh
7237aa5eb8 docs(e2e): add scenario writing guide and reusable step reference
The existing AGENTS.md covered setup and lifecycle but lacked guidance
on how to write new tests. Add two sections:

- Writing new scenarios: workflow, feature/step conventions, Playwright
  locator priority, assertion patterns, Cucumber expressions, scoping
- Reusable step reference: indexed table of all existing steps grouped
  by domain so authors can discover and reuse before writing new ones
2026-04-10 19:36:36 +08:00
66183c1f0a docs(contributing): move agent attribution guidance to PR template (#34919) 2026-04-10 11:11:12 +00:00
130ad295d0 refactor(api): replace Any with precise types in db_migration_lock (#34891)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-10 11:09:33 +00:00
6612ba69b1 fix(workflow): correct maximized editor panel layout in execution logs (#34909) 2026-04-10 10:59:09 +00:00
2dc015b360 fix(api): default parent_mode to paragraph for hierarchical chunking via API (#34635)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-10 10:55:40 +00:00
2eb43b1e1f refactor: make DefaultFieldsMixin compatible with TypeBase (MappedAsDataclass) (#34686) 2026-04-10 18:53:27 +08:00
8633b2f1f7 refactor(tools): replace redundant dict[str, str] with EmojiIconDict (#34786)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-10 18:53:05 +08:00
200 changed files with 1209 additions and 18509 deletions

View File

@ -7,6 +7,7 @@
## Summary
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
<!-- If this PR was created by an automated agent, add `From <Tool Name>` as the final line of the description. Example: `From Codex`. -->
## Screenshots

View File

@ -77,7 +77,7 @@ if $web_modified; then
fi
cd ./web || exit 1
pnpm exec vp staged
vp staged
if $web_ts_modified; then
echo "Running TypeScript type-check:tsgo"

View File

@ -97,8 +97,3 @@ Feel free to reach out if you encounter any issues during the setup process.
## Getting Help
If you ever get stuck or get a burning question while contributing, simply shoot your queries our way via the related GitHub issue, or hop onto our [Discord](https://discord.gg/8Tpq4AcN9c) for a quick chat.
## Automated Agent Contributions
> [!NOTE]
> If you are an automated agent just add 🤖🤖🤖 to the end of the PR title to opt-in.

View File

@ -993,7 +993,7 @@ class ToolManager:
return {"background": "#252525", "content": "\ud83d\ude01"}
@classmethod
def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict | dict[str, str] | str:
def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict | str:
try:
with Session(db.engine) as session:
mcp_service = MCPToolManageService(session=session)
@ -1001,7 +1001,7 @@ class ToolManager:
mcp_provider = mcp_service.get_provider_entity(
provider_id=provider_id, tenant_id=tenant_id, by_server_id=True
)
return mcp_provider.provider_icon
return cast(EmojiIconDict | str, mcp_provider.provider_icon)
except ValueError:
raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found")
except Exception:
@ -1013,7 +1013,7 @@ class ToolManager:
tenant_id: str,
provider_type: ToolProviderType,
provider_id: str,
) -> str | EmojiIconDict | dict[str, str]:
) -> str | EmojiIconDict:
"""
get the tool icon

View File

@ -14,9 +14,15 @@ from __future__ import annotations
import logging
import threading
from typing import Any
from typing import TYPE_CHECKING, Any
import redis
from redis.cluster import RedisCluster
from redis.exceptions import LockNotOwnedError, RedisError
from redis.lock import Lock
if TYPE_CHECKING:
from extensions.ext_redis import RedisClientWrapper
logger = logging.getLogger(__name__)
@ -38,21 +44,21 @@ class DbMigrationAutoRenewLock:
primary error/exit code.
"""
_redis_client: Any
_redis_client: redis.Redis | RedisCluster | RedisClientWrapper
_name: str
_ttl_seconds: float
_renew_interval_seconds: float
_log_context: str | None
_logger: logging.Logger
_lock: Any
_lock: Lock | None
_stop_event: threading.Event | None
_thread: threading.Thread | None
_acquired: bool
def __init__(
self,
redis_client: Any,
redis_client: redis.Redis | RedisCluster | RedisClientWrapper,
name: str,
ttl_seconds: float = 60,
renew_interval_seconds: float | None = None,
@ -127,7 +133,7 @@ class DbMigrationAutoRenewLock:
)
self._thread.start()
def _heartbeat_loop(self, lock: Any, stop_event: threading.Event) -> None:
def _heartbeat_loop(self, lock: Lock, stop_event: threading.Event) -> None:
while not stop_event.wait(self._renew_interval_seconds):
try:
lock.reacquire()

View File

@ -24,6 +24,8 @@ class TypeBase(MappedAsDataclass, DeclarativeBase):
class DefaultFieldsMixin:
"""Mixin for models that inherit from Base (non-dataclass)."""
id: Mapped[str] = mapped_column(
StringUUID,
primary_key=True,
@ -53,6 +55,42 @@ class DefaultFieldsMixin:
return f"<{self.__class__.__name__}(id={self.id})>"
class DefaultFieldsDCMixin(MappedAsDataclass):
"""Mixin for models that inherit from TypeBase (MappedAsDataclass)."""
__abstract__ = True
id: Mapped[str] = mapped_column(
StringUUID,
primary_key=True,
insert_default=lambda: str(uuidv7()),
default_factory=lambda: str(uuidv7()),
init=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
insert_default=naive_utc_now,
default_factory=naive_utc_now,
init=False,
server_default=func.current_timestamp(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
insert_default=naive_utc_now,
default_factory=naive_utc_now,
init=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
)
def __repr__(self) -> str:
return f"<{self.__class__.__name__}(id={self.id})>"
def gen_uuidv4_string() -> str:
"""gen_uuidv4_string generate a UUIDv4 string.

View File

@ -2822,6 +2822,10 @@ class DocumentService:
knowledge_config.process_rule.rules.pre_processing_rules = list(unique_pre_processing_rule_dicts.values())
if knowledge_config.process_rule.mode == ProcessRuleMode.HIERARCHICAL:
if not knowledge_config.process_rule.rules.parent_mode:
knowledge_config.process_rule.rules.parent_mode = "paragraph"
if not knowledge_config.process_rule.rules.segmentation:
raise ValueError("Process rule segmentation is required")

View File

@ -1069,6 +1069,33 @@ class TestDocumentServiceCreateValidation:
assert len(knowledge_config.process_rule.rules.pre_processing_rules) == 1
assert knowledge_config.process_rule.rules.pre_processing_rules[0].enabled is False
def test_process_rule_args_validate_hierarchical_defaults_parent_mode_to_paragraph(self):
knowledge_config = KnowledgeConfig(
indexing_technique="economy",
data_source=DataSource(
info_list=InfoList(
data_source_type="upload_file",
file_info_list=FileInfo(file_ids=["file-1"]),
)
),
process_rule=ProcessRule(
mode="hierarchical",
rules=Rule(
pre_processing_rules=[
PreProcessingRule(id="remove_extra_spaces", enabled=True),
],
segmentation=Segmentation(separator="\n", max_tokens=1024),
subchunk_segmentation=Segmentation(separator="\n", max_tokens=512),
),
),
)
DocumentService.process_rule_args_validate(knowledge_config)
assert knowledge_config.process_rule is not None
assert knowledge_config.process_rule.rules is not None
assert knowledge_config.process_rule.rules.parent_mode == "paragraph"
class TestDocumentServiceSaveDocumentWithDatasetId:
"""Unit tests for non-SQL validation branches in save_document_with_dataset_id."""

View File

@ -165,3 +165,137 @@ Open the HTML report locally with:
```bash
open cucumber-report/report.html
```
## Writing new scenarios
### Workflow
1. Create a `.feature` file under `features/<capability>/`
2. Add step definitions under `features/step-definitions/<capability>/`
3. Reuse existing steps from `common/` and other definition files before writing new ones
4. Run with `pnpm -C e2e e2e -- --tags @your-tag` to verify
5. Run `pnpm -C e2e check` before committing
### Feature file conventions
Tag every feature with a capability tag and an auth tag:
```gherkin
@datasets @authenticated
Feature: Create dataset
Scenario: Create a new empty dataset
Given I am signed in as the default E2E admin
When I open the datasets page
...
```
- Capability tags (`@apps`, `@auth`, `@datasets`, …) group related scenarios for selective runs
- Auth tags control the `Before` hook behavior:
- `@authenticated` — injects the shared auth storageState into the BrowserContext
- `@unauthenticated` — uses a clean BrowserContext with no cookies or storage
- `@fresh` — only runs in `e2e:full` mode (requires uninitialized instance)
- `@skip` — excluded from all runs
Keep scenarios short and declarative. Each step should describe **what** the user does, not **how** the UI works.
### Step definition conventions
```typescript
import { When, Then } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { DifyWorld } from '../../support/world'
When('I open the datasets page', async function (this: DifyWorld) {
await this.getPage().goto('/datasets')
})
```
Rules:
- Always type `this` as `DifyWorld` for proper context access
- Use `async function` (not arrow functions — Cucumber binds `this`)
- One step = one user-visible action or one assertion
- Keep steps stateless across scenarios; use `DifyWorld` properties for in-scenario state
### Locator priority
Follow the Playwright recommended locator strategy, in order of preference:
| Priority | Locator | Example | When to use |
|---|---|---|---|
| 1 | `getByRole` | `getByRole('button', { name: 'Create' })` | Default choice — accessible and resilient |
| 2 | `getByLabel` | `getByLabel('App name')` | Form inputs with visible labels |
| 3 | `getByPlaceholder` | `getByPlaceholder('Enter name')` | Inputs without visible labels |
| 4 | `getByText` | `getByText('Welcome')` | Static text content |
| 5 | `getByTestId` | `getByTestId('workflow-canvas')` | Only when no semantic locator works |
Avoid raw CSS/XPath selectors. They break when the DOM structure changes.
### Assertions
Use `@playwright/test` `expect` — it auto-waits and retries until the condition is met or the timeout expires:
```typescript
// URL assertion
await expect(page).toHaveURL(/\/datasets\/[a-f0-9-]+\/documents/)
// Element visibility
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
// Element state
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled()
// Negation
await expect(page.getByText('Loading')).not.toBeVisible()
```
Do not use manual `waitForTimeout` or polling loops. If you need a longer wait for a specific assertion, pass `{ timeout: 30_000 }` to the assertion.
### Cucumber expressions
Use Cucumber expression parameter types to extract values from Gherkin steps:
| Type | Pattern | Example step |
|---|---|---|
| `{string}` | Quoted string | `I select the "Workflow" app type` |
| `{int}` | Integer | `I should see {int} items` |
| `{float}` | Decimal | `the progress is {float} percent` |
| `{word}` | Single word | `I click the {word} tab` |
Prefer `{string}` for UI labels, names, and text content — it maps naturally to Gherkin's quoted values.
### Scoping locators
When the page has multiple similar elements, scope locators to a container:
```typescript
When('I fill in the app name in the dialog', async function (this: DifyWorld) {
const dialog = this.getPage().getByRole('dialog')
await dialog.getByPlaceholder('Give your app a name').fill('My App')
})
```
### Failure diagnostics
The `After` hook automatically captures on failure:
- Full-page screenshot (PNG)
- Page HTML dump
- Console errors and page errors
Artifacts are saved to `cucumber-report/artifacts/` and attached to the HTML report. No extra code needed in step definitions.
## Reusing existing steps
Before writing a new step definition, check what already exists. Steps in `common/` are designed for broad reuse across all features.
List all registered step patterns:
```bash
grep -rn "Given\|When\|Then" e2e/features/step-definitions/ --include='*.ts' | grep -oP "'[^']+'"
```
Or browse the step definition files directly:
- `features/step-definitions/common/` — auth guards and navigation assertions shared by all features
- `features/step-definitions/<capability>/` — domain-specific steps scoped to a single feature area

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
</svg>

Before

Width:  |  Height:  |  Size: 563 B

View File

@ -1,11 +0,0 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return <Evaluation resourceType="apps" resourceId={appId} />
}
export default Page

View File

@ -7,8 +7,6 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@ -69,47 +67,40 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
return navConfig
}, [t])

View File

@ -1,209 +0,0 @@
import type { ReactNode } from 'react'
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetDetailLayout from '../layout-main'
let mockPathname = '/datasets/test-dataset-id/documents'
let mockDataset: DataSet | undefined
const mockSetAppSidebarExpand = vi.fn()
const mockMutateDatasetRes = vi.fn()
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceDatasetOperator: false,
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetDetail: () => ({
data: mockDataset,
error: null,
refetch: mockMutateDatasetRes,
}),
useDatasetRelatedApps: () => ({
data: [],
}),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
navigation,
children,
}: {
navigation: Array<{ name: string, href: string, disabled?: boolean }>
children?: ReactNode
}) => (
<div data-testid="app-sidebar">
{navigation.map(item => (
<button
key={item.href}
type="button"
disabled={item.disabled}
>
{item.name}
</button>
))}
{children}
</div>
),
}))
vi.mock('@/app/components/datasets/extra-info', () => ({
default: () => <div data-testid="dataset-extra-info" />,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div role="status">loading</div>,
}))
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'test-dataset-id',
name: 'Test Dataset',
indexing_status: 'completed',
icon_info: {
icon: 'book',
icon_background: '#fff',
icon_type: 'emoji',
icon_url: '',
},
description: '',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 0,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
word_count: 0,
provider: 'vendor',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
pipeline_id: 'pipeline-1',
is_published: true,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
describe('DatasetDetailLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/datasets/test-dataset-id/documents'
mockDataset = createDataset()
})
describe('Evaluation navigation', () => {
it('should hide the evaluation menu when the dataset is not a rag pipeline', () => {
mockDataset = createDataset({
runtime_mode: 'general',
is_published: false,
})
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.queryByRole('button', { name: 'common.datasetMenus.evaluation' })).not.toBeInTheDocument()
})
it('should disable the evaluation menu when the rag pipeline is unpublished', () => {
mockDataset = createDataset({
is_published: false,
})
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.getByRole('button', { name: 'common.datasetMenus.evaluation' })).toBeDisabled()
})
it('should enable the evaluation menu when the rag pipeline is published', () => {
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.getByRole('button', { name: 'common.datasetMenus.evaluation' })).toBeEnabled()
})
})
})

View File

@ -1,11 +0,0 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return <Evaluation resourceType="datasets" resourceId={datasetId} />
}
export default Page

View File

@ -6,8 +6,6 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@ -58,7 +56,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const { data: relatedApps } = useDatasetRelatedApps(datasetId)
const isRagPipelineDataset = datasetRes?.runtime_mode === 'rag_pipeline'
const isButtonDisabledWithPipeline = useMemo(() => {
if (!datasetRes)
@ -89,36 +86,24 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
...(isRagPipelineDataset
? [{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
disabled: isButtonDisabledWithPipeline,
}]
: []),
...baseNavigation,
]
baseNavigation.unshift({
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
}
return baseNavigation
}, [t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, datasetRes?.provider])
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' }))

View File

@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@ -1,11 +0,0 @@
import SnippetEvaluationPage from '@/app/components/snippets/snippet-evaluation-page'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetEvaluationPage snippetId={snippetId} />
}
export default Page

View File

@ -1,11 +0,0 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} />
}
export default Page

View File

@ -1,21 +0,0 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@ -1,11 +0,0 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@ -1,7 +0,0 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@ -165,21 +165,6 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@ -2,7 +2,7 @@ import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import AppInfoDetailPanel from '../app-info-detail-panel'
vi.mock('../../../base/app-icon', () => ({
@ -135,17 +135,6 @@ describe('AppInfoDetailPanel', () => {
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should not render CardView when app type is evaluation', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ type: AppTypeEnum.EVALUATION })}
/>,
)
expect(screen.queryByTestId('card-view')).not.toBeInTheDocument()
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')

View File

@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
<ContentDialog
show={show}
onClose={onClose}
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl p-0!"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
@ -109,14 +109,14 @@ const AppInfoDetailPanel = ({
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal wrap-break-word text-text-tertiary system-xs-regular">
{appDetail.description}
</div>
)}
@ -126,13 +126,11 @@ const AppInfoDetailPanel = ({
secondaryOperations={secondaryOperations}
/>
</div>
{appDetail.type !== AppTypeEnum.EVALUATION && (
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
)}
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
@ -142,7 +140,7 @@ const AppInfoDetailPanel = ({
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
</Button>
</div>
)}

View File

@ -27,16 +27,12 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@ -108,11 +104,10 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
{iconType === 'app' && (
<AppInfo expand={expand} />
)}
{!renderHeader && iconType !== 'app' && (
{iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@ -141,8 +136,7 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
{navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@ -262,20 +262,4 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -14,15 +14,13 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href?: string
href: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@ -31,8 +29,6 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@ -43,11 +39,8 @@ const NavLink = ({
return res
})()
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@ -77,32 +70,13 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={linkClassName}
className={cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@ -1,285 +0,0 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
icon?: AppIconSelection
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
icon: {
type: 'emoji',
icon: '✨',
background: '#FFFFFF',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
describe('Edit Snippet', () => {
it('should open the edit dialog and submit snippet updates', async () => {
const user = userEvent.setup()
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
icon_info: {
icon: '✨',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
describe('Delete Snippet', () => {
it('should confirm deletion and redirect to the snippets list', async () => {
const user = userEvent.setup()
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@ -1,62 +0,0 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@ -1,197 +0,0 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const FALLBACK_ICON: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
icon: snippet.icon
? {
type: 'emoji' as const,
icon: snippet.icon,
background: snippet.iconBackground || FALLBACK_ICON.background,
}
: FALLBACK_ICON,
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
setOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
name: string
description: string
icon: AppIconSelection
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}, [snippet.id, t, updateSnippetMutation])
const handleDeleteSnippet = React.useCallback(() => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
replace('/snippets')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
destructive
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-[400px]">
<div className="space-y-2 p-6">
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@ -1,55 +0,0 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
return (
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
<div className={cn('shrink-0', !expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && <SnippetInfoDropdown snippet={snippet} />}
</div>
{expand && (
<div className="min-w-0">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
{t('typeLabel')}
</div>
</div>
)}
{expand && snippet.description && (
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -2,7 +2,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AppPublisher from '../index'
@ -15,8 +15,6 @@ const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockConvertWorkflowType = vi.fn()
const mockRefetchEvaluationWorkflowAssociatedTargets = vi.fn()
const sectionProps = vi.hoisted(() => ({
summary: null as null | Record<string, any>,
@ -28,7 +26,6 @@ const ahooksMocks = vi.hoisted(() => ({
}))
let mockAppDetail: Record<string, any> | null = null
let mockEvaluationWorkflowAssociatedTargets: Record<string, any> | undefined
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@ -91,21 +88,6 @@ vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@/service/use-apps', () => ({
useConvertWorkflowTypeMutation: () => ({
mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args),
isPending: false,
}),
}))
vi.mock('@/service/use-evaluation', () => ({
useEvaluationWorkflowAssociatedTargets: () => ({
data: mockEvaluationWorkflowAssociatedTargets,
refetch: mockRefetchEvaluationWorkflowAssociatedTargets,
isFetching: false,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
@ -142,15 +124,15 @@ vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext value={open}>
<OpenContext.Provider value={open}>
<div>{children}</div>
</OpenContext>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = ReactModule.use(OpenContext)
const open = ReactModule.useContext(OpenContext)
return open ? <div>{children}</div> : null
},
}
@ -163,7 +145,6 @@ vi.mock('../sections', () => ({
<div>
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
<button onClick={() => void props.onWorkflowTypeSwitch()}>publisher-switch-workflow-type</button>
</div>
)
},
@ -194,7 +175,6 @@ describe('AppPublisher', () => {
name: 'Demo App',
mode: AppModeEnum.CHAT,
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
type: AppTypeEnum.WORKFLOW,
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
@ -207,12 +187,6 @@ describe('AppPublisher', () => {
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
mockConvertWorkflowType.mockResolvedValue({})
mockEvaluationWorkflowAssociatedTargets = { items: [] }
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValue({
data: { items: [] },
isError: false,
})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
await resolver()
})
@ -478,178 +452,4 @@ describe('AppPublisher', () => {
})
expect(screen.getByTestId('access-control')).toBeInTheDocument()
})
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
mockFetchAppDetailDirect.mockResolvedValueOnce({
id: 'app-1',
type: AppTypeEnum.EVALUATION,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.EVALUATION },
})
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
type: AppTypeEnum.EVALUATION,
})
})
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
it('should hide access and actions sections for evaluation workflow apps', () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument()
expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument()
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
targetType: AppTypeEnum.WORKFLOW,
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
})
})
it('should confirm before switching an evaluation workflow with associated targets to a standard workflow', async () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
mockEvaluationWorkflowAssociatedTargets = {
items: [
{
target_type: 'app',
target_id: 'dependent-app-1',
target_name: 'Dependent App',
},
{
target_type: 'knowledge_base',
target_id: 'knowledge-1',
target_name: 'Knowledge Base',
},
],
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: mockEvaluationWorkflowAssociatedTargets,
isError: false,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(screen.getByText('Dependent App')).toBeInTheDocument()
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.switchToStandardWorkflowConfirm.switch' }))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
})
})
it('should switch an evaluation workflow directly when there are no associated targets', async () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
})
expect(screen.queryByText('common.switchToStandardWorkflowConfirm.title')).not.toBeInTheDocument()
})
it('should block switching an evaluation workflow when associated targets fail to load', async () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: undefined,
isError: true,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToStandardWorkflowConfirm.loadFailed')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
})
it('should block switching to evaluation workflow when restricted nodes exist', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
hasHumanInputNode
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToEvaluationWorkflowDisabledTip')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('common.switchToEvaluationWorkflowDisabledTip')
})
})

View File

@ -45,14 +45,12 @@ describe('app-publisher sections', () => {
handleRestore={handleRestore}
isChatApp
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={Date.now()}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -85,14 +83,12 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -111,14 +107,12 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -137,85 +131,18 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument()
})
it('should render workflow type switch action and call switch handler', () => {
const onWorkflowTypeSwitch = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={onWorkflowTypeSwitch}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('common.publishAsEvaluationWorkflow'))
expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1)
})
it('should disable workflow type switch when a disabled reason is provided', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled
workflowTypeSwitchDisabledReason="common.switchToEvaluationWorkflowDisabledTip"
/>,
)
expect(screen.getByRole('button', { name: /common\.publishAsEvaluationWorkflow/i })).toBeDisabled()
})
it('should render loading access state and access mode labels when enabled', () => {
const { rerender } = render(
<PublisherAccessSection

View File

@ -1,158 +0,0 @@
'use client'
import type { EvaluationWorkflowAssociatedTarget, EvaluationWorkflowAssociatedTargetType } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import Link from '@/next/link'
import { cn } from '@/utils/classnames'
type EvaluationWorkflowSwitchConfirmDialogProps = {
open: boolean
targets: EvaluationWorkflowAssociatedTarget[]
loading?: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
const TARGET_TYPE_META: Record<EvaluationWorkflowAssociatedTargetType, {
icon: string
iconClassName: string
labelKey: I18nKeysWithPrefix<'workflow', 'common.switchToStandardWorkflowConfirm.targetTypes.'>
href: (targetId: string) => string
}> = {
app: {
icon: 'i-ri-flow-chart',
iconClassName: 'bg-components-icon-bg-teal-soft text-util-colors-teal-teal-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.app',
href: targetId => `/app/${targetId}/workflow`,
},
snippets: {
icon: 'i-ri-edit-2-line',
iconClassName: 'bg-components-icon-bg-violet-soft text-util-colors-violet-violet-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.snippets',
href: targetId => `/snippets/${targetId}/orchestrate`,
},
knowledge_base: {
icon: 'i-ri-book-2-line',
iconClassName: 'bg-components-icon-bg-indigo-soft text-util-colors-blue-blue-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.knowledge_base',
href: targetId => `/datasets/${targetId}/documents`,
},
}
const getTargetMeta = (targetType: EvaluationWorkflowAssociatedTargetType) => {
return TARGET_TYPE_META[targetType] ?? TARGET_TYPE_META.app
}
const DependentTargetItem = ({
target,
}: {
target: EvaluationWorkflowAssociatedTarget
}) => {
const { t } = useTranslation()
const meta = getTargetMeta(target.target_type)
const targetName = target.target_name || target.target_id
return (
<Link
href={meta.href(target.target_id)}
className="group flex w-full items-center gap-3 rounded-lg bg-background-section p-2 hover:bg-background-section-burn"
title={targetName}
target="_blank"
rel="noreferrer"
>
<span
aria-hidden="true"
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular',
meta.iconClassName,
)}
>
<span className={cn(meta.icon, 'size-5')} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 py-px">
<span className="truncate system-md-semibold text-text-secondary">
{targetName}
</span>
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t(meta.labelKey, { ns: 'workflow' })}
</span>
</span>
<span
aria-hidden="true"
className="i-ri-arrow-right-up-line size-3.5 shrink-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100"
/>
</Link>
)
}
const EvaluationWorkflowSwitchConfirmDialog = ({
open,
targets,
loading = false,
onOpenChange,
onConfirm,
}: EvaluationWorkflowSwitchConfirmDialogProps) => {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="w-[480px]">
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('common.switchToStandardWorkflowConfirm.title', { ns: 'workflow' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular text-text-secondary">
<span className="block">
{t('common.switchToStandardWorkflowConfirm.activeIn', { ns: 'workflow', count: targets.length })}
</span>
<span className="block">
{t('common.switchToStandardWorkflowConfirm.description', { ns: 'workflow' })}
</span>
</AlertDialogDescription>
</div>
<div className="flex flex-col gap-2 px-6 py-3">
<div className="flex items-center gap-2">
<span className="shrink-0 system-xs-medium-uppercase text-text-quaternary">
{t('common.switchToStandardWorkflowConfirm.dependentWorkflows', { ns: 'workflow' })}
</span>
<span className="h-px min-w-0 flex-1 bg-divider-subtle" />
</div>
<div className="flex max-h-[188px] flex-col gap-1 overflow-y-auto">
{targets.map(target => (
<DependentTargetItem
key={`${target.target_type}:${target.target_id}`}
target={target}
/>
))}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={loading}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={loading}
disabled={loading}
onClick={onConfirm}
>
{t('common.switchToStandardWorkflowConfirm.switch', { ns: 'workflow' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default EvaluationWorkflowSwitchConfirmDialog

View File

@ -1,8 +1,6 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { EvaluationWorkflowAssociatedTarget } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useKeyPress } from 'ahooks'
import {
memo,
@ -28,14 +26,11 @@ import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { toast } from '../../base/ui/toast'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import EvaluationWorkflowSwitchConfirmDialog from './evaluation-workflow-switch-confirm-dialog'
import {
PublisherAccessSection,
PublisherActionsSection,
@ -73,32 +68,6 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
const WORKFLOW_TYPE_SWITCH_CONFIG: Record<WorkflowTypeConversionTarget, {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}> = {
workflow: {
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
},
evaluation: {
targetType: 'workflow',
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
},
} as const
const isWorkflowTypeConversionTarget = (type?: AppTypeEnum): type is WorkflowTypeConversionTarget => {
return type === 'workflow' || type === 'evaluation'
}
const AppPublisher = ({
disabled = false,
publishDisabled = false,
@ -125,8 +94,6 @@ const AppPublisher = ({
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [showEvaluationWorkflowSwitchConfirm, setShowEvaluationWorkflowSwitchConfirm] = useState(false)
const [evaluationWorkflowSwitchTargets, setEvaluationWorkflowSwitchTargets] = useState<EvaluationWorkflowAssociatedTarget[]>([])
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
@ -135,27 +102,9 @@ const AppPublisher = ({
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
const workflowTypeSwitchConfig = isWorkflowTypeConversionTarget(appDetail?.type)
? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
: undefined
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
const {
refetch: refetchEvaluationWorkflowAssociatedTargets,
isFetching: isFetchingEvaluationWorkflowAssociatedTargets,
} = useEvaluationWorkflowAssociatedTargets(appDetail?.id, { enabled: false })
const workflowTypeSwitchDisabledReason = useMemo(() => {
if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION)
return undefined
if (!hasHumanInputNode && !hasTriggerNode)
return undefined
return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' })
}, [hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType])
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
@ -243,83 +192,6 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const performWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return false
try {
await convertWorkflowType({
params: {
appId: appDetail.id,
},
query: {
target_type: workflowTypeSwitchConfig.targetType,
},
})
if (!publishedAt)
await handlePublish()
const latestAppDetail = await fetchAppDetailDirect({
url: '/apps',
id: appDetail.id,
})
setAppDetail(latestAppDetail)
if (publishedAt)
setOpen(false)
setShowEvaluationWorkflowSwitchConfirm(false)
setEvaluationWorkflowSwitchTargets([])
return true
}
catch {
return false
}
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
const handleWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
if (workflowTypeSwitchDisabledReason) {
toast.error(workflowTypeSwitchDisabledReason)
return
}
if (appDetail.type === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) {
const associatedTargetsResult = await refetchEvaluationWorkflowAssociatedTargets()
if (associatedTargetsResult.isError) {
toast.error(t('common.switchToStandardWorkflowConfirm.loadFailed', { ns: 'workflow' }))
return
}
const associatedTargets = associatedTargetsResult.data?.items ?? []
if (associatedTargets.length > 0) {
setEvaluationWorkflowSwitchTargets(associatedTargets)
setShowEvaluationWorkflowSwitchConfirm(true)
return
}
}
await performWorkflowTypeSwitch()
}, [
appDetail?.id,
appDetail?.type,
performWorkflowTypeSwitch,
refetchEvaluationWorkflowAssociatedTargets,
t,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabledReason,
])
const handleEvaluationWorkflowSwitchConfirmOpenChange = useCallback((nextOpen: boolean) => {
setShowEvaluationWorkflowSwitchConfirm(nextOpen)
if (!nextOpen)
setEvaluationWorkflowSwitchTargets([])
}, [])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (publishDisabled || published)
@ -352,7 +224,7 @@ const AppPublisher = ({
<PortalToFollowElemTrigger onClick={handleTrigger}>
<Button
variant="primary"
className="py-2 pr-2 pl-3"
className="py-2 pl-3 pr-2"
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
@ -375,45 +247,37 @@ const AppPublisher = ({
publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType || isFetchingEvaluationWorkflowAssociatedTargets || Boolean(workflowTypeSwitchDisabledReason)}
workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason}
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
/>
{!isEvaluationWorkflowType && (
<>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => setShowAppAccessControl(true)}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
handleOpenInExplore={handleOpenInExplore}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
</>
)}
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => setShowAppAccessControl(true)}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
handleOpenInExplore={handleOpenInExplore}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
</div>
</PortalToFollowElemContent>
<EmbeddedModal
@ -425,13 +289,6 @@ const AppPublisher = ({
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</PortalToFollowElem>
<EvaluationWorkflowSwitchConfirmDialog
open={showEvaluationWorkflowSwitchConfirm}
targets={evaluationWorkflowSwitchTargets}
loading={isConvertingWorkflowType}
onOpenChange={handleEvaluationWorkflowSwitchConfirmOpenChange}
onConfirm={() => void performWorkflowTypeSwitch()}
/>
</>
)
}

View File

@ -1,11 +1,11 @@
import type { CSSProperties, ReactNode } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import Loading from '@/app/components/base/loading'
import {
Tooltip,
@ -21,8 +21,6 @@ import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
import { ACCESS_MODE_MAP } from './utils'
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
| 'draftUpdatedAt'
| 'multipleModelConfigs'
@ -33,18 +31,9 @@ type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
handleRestore: () => Promise<void>
isChatApp: boolean
onWorkflowTypeSwitch: () => Promise<void>
published: boolean
publishShortcut: string[]
upgradeHighlightStyle: CSSProperties
workflowTypeSwitchConfig?: {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}
workflowTypeSwitchDisabled: boolean
workflowTypeSwitchDisabledReason?: string
}
type AccessSectionProps = {
@ -101,28 +90,6 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherSummarySection = ({
debugWithMultipleModel = false,
draftUpdatedAt,
@ -131,16 +98,12 @@ export const PublisherSummarySection = ({
handleRestore,
isChatApp,
multipleModelConfigs = [],
onWorkflowTypeSwitch,
publishDisabled = false,
published,
publishedAt,
publishShortcut,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
workflowTypeSwitchDisabledReason,
}: SummarySectionProps) => {
const { t } = useTranslation()
@ -201,47 +164,6 @@ export const PublisherSummarySection = ({
</div>
)}
</Button>
{workflowTypeSwitchConfig && (
<ActionTooltip disabled={workflowTypeSwitchDisabled} tooltip={workflowTypeSwitchDisabledReason}>
<button
type="button"
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void onWorkflowTypeSwitch()}
disabled={workflowTypeSwitchDisabled}
>
<span className="px-0.5">
{t(
publishedAt
? workflowTypeSwitchConfig.switchLabelKey
: workflowTypeSwitchConfig.publishLabelKey,
{ ns: 'workflow' },
)}
</span>
<Tooltip>
<TooltipTrigger
render={(
<span
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent
placement="top"
popupClassName="w-[180px]"
>
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</button>
</ActionTooltip>
)}
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
@ -305,6 +227,28 @@ export const PublisherAccessSection = ({
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherActionsSection = ({
appDetail,
appURL,
@ -361,7 +305,7 @@ export const PublisherActionsSection = ({
<SuggestedAction
onClick={handleEmbed}
disabled={!publishedAt}
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
icon={<CodeBrowser className="h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>

View File

@ -62,7 +62,7 @@ const TryLabel: FC<{
}> = ({ Icon, text, onClick }) => {
return (
<div
className="mt-2 mr-1 flex h-7 shrink-0 cursor-pointer items-center rounded-lg bg-components-button-secondary-bg px-2"
className="mr-1 mt-2 flex h-7 shrink-0 cursor-pointer items-center rounded-lg bg-components-button-secondary-bg px-2"
onClick={onClick}
>
<Icon className="h-4 w-4 text-text-tertiary"></Icon>
@ -89,7 +89,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: {} as CompletionParams,
})
const {
@ -283,7 +283,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
<div className="flex h-[680px] flex-wrap">
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div>
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('generate.description', { ns: 'appDebug' })}</div>
</div>
<div>
@ -301,7 +301,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
{isBasicMode && (
<div className="mt-4">
<div className="flex items-center">
<div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{t('generate.tryIt', { ns: 'appDebug' })}</div>
<div className="mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary">{t('generate.tryIt', { ns: 'appDebug' })}</div>
<div
className="h-px grow"
style={{
@ -326,7 +326,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
{/* inputs */}
<div className="mt-4">
<div>
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div>
<div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div>
{isBasicMode
? (
<InstructionEditorInBasic

View File

@ -70,7 +70,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: defaultCompletionParams,
})
const {
@ -202,7 +202,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
<div className="relative flex h-[680px] flex-wrap">
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div>
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('codegen.description', { ns: 'appDebug' })}</div>
</div>
<div className="mb-4">
@ -219,7 +219,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
</div>
<div>
<div className="text-[0px]">
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<div className="mb-1.5 text-text-secondary system-sm-semibold-uppercase">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<InstructionEditor
editorKey={editorKey}
value={instruction}

View File

@ -1,4 +1,4 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
@ -15,13 +15,10 @@ vi.mock('@/next/navigation', () => ({
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
@ -39,7 +36,6 @@ const mockQueryState = {
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@ -49,7 +45,6 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@ -59,13 +54,11 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockFetchSnippetNextPage = vi.fn()
const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@ -107,7 +100,6 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
@ -120,57 +112,6 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
const mockSnippetServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
const defaultSnippetData = {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
author: '',
updatedAt: '2024-01-02 10:00',
usage: '19',
icon: '🪄',
iconBackground: '#E0EAFF',
status: undefined,
},
],
total: 1,
}],
}
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: () => ({
data: defaultSnippetData,
isLoading: mockSnippetServiceState.isLoading,
isFetching: mockSnippetServiceState.isFetching,
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
fetchNextPage: mockFetchSnippetNextPage,
hasNextPage: mockSnippetServiceState.hasNextPage,
error: mockSnippetServiceState.error,
}),
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useImportSnippetDSLMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
}))
@ -192,21 +133,13 @@ vi.mock('@/next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement(
'div',
{ 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
}
}
return () => null
},
}))
@ -255,8 +188,9 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
return renderWithNuqs(<List {...props} />, { searchParams })
// Render helper wrapping with shared nuqs testing helper.
const renderList = (searchParams = '') => {
return renderWithNuqs(<List />, { searchParams })
}
describe('List', () => {
@ -268,62 +202,284 @@ describe('List', () => {
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockSnippetServiceState.error = null
mockSnippetServiceState.hasNextPage = false
mockSnippetServiceState.isLoading = false
mockSnippetServiceState.isFetching = false
mockSnippetServiceState.isFetchingNextPage = false
intersectionCallback = null
localStorage.clear()
})
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should render search input', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should update the category query when selecting an app type from the dropdown', async () => {
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.types.workflow'))
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should keep the creators dropdown visual-only and not update app query state', async () => {
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
fireEvent.click(screen.getByText('app.types.all'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
fireEvent.click(screen.getByText('app.studio.filters.creators'))
fireEvent.click(await screen.findByText('Evan'))
expect(mockSetQuery).not.toHaveBeenCalled()
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render and close the DSL import modal when a file is dropped', () => {
it('should handle search input change', () => {
renderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(mockSetQuery).toHaveBeenCalled()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should handle checkbox change', () => {
renderList()
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Behavior', () => {
it('should not trigger redirect at component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
renderList()
expect(mockReplace).not.toHaveBeenCalled()
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = renderWithNuqs(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should update URL for each app type tab click', async () => {
const { onUrlUpdate } = renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
for (const { mode, text } of appTypeTexts) {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
@ -333,50 +489,98 @@ describe('List', () => {
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when onClose is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should close DSL modal and refetch when onSuccess is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
})
})
describe('Snippets Mode', () => {
it('should render the snippets create card and snippet card from the real query hook', () => {
renderList({ pageType: 'snippets' })
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
renderList()
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).toHaveBeenCalled()
})
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
mockSnippetServiceState.hasNextPage = true
renderList({ pageType: 'snippets' })
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
act(() => {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
it('should not fetch the next snippet page when no more data is available', () => {
renderList({ pageType: 'snippets' })
act(() => {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
})

View File

@ -1,16 +0,0 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
export type { AppListCategory }
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@ -1,72 +0,0 @@
'use client'
import type { AppListCategory } from './app-type-filter-shared'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
type AppTypeFilterProps = {
activeTab: AppListCategory
onChange: (value: AppListCategory) => void
}
const AppTypeFilter = ({
activeTab,
onChange,
}: AppTypeFilterProps) => {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === activeTab)
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AppTypeFilter

View File

@ -1,128 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
type CreatorOption = {
id: string
name: string
isYou?: boolean
avatarClassName: string
}
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
const creatorOptions: CreatorOption[] = [
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
]
const CreatorsFilter = () => {
const { t } = useTranslation()
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
const [keywords, setKeywords] = useState('')
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
}, [keywords])
const selectedCount = selectedCreatorIds.length
const triggerLabel = selectedCount > 0
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
: t('studio.filters.creators', { ns: 'app' })
const toggleCreator = useCallback((creatorId: string) => {
setSelectedCreatorIds((prev) => {
if (prev.includes(creatorId))
return prev.filter(id => id !== creatorId)
return [...prev, creatorId]
})
}, [])
const resetCreators = useCallback(() => {
setSelectedCreatorIds([])
setKeywords('')
}, [])
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-2 p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
<button
type="button"
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
</div>
<div className="px-1 pb-1">
<DropdownMenuCheckboxItem
checked={selectedCreatorIds.length === 0}
onCheckedChange={resetCreators}
>
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{filteredCreators.map(creator => (
<DropdownMenuCheckboxItem
key={creator.id}
checked={selectedCreatorIds.includes(creator.id)}
onCheckedChange={() => toggleCreator(creator.id)}
>
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
<span className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@ -12,24 +12,14 @@ import dynamic from '@/next/dynamic'
import { fetchAppDetail } from '@/service/explore'
import List from './list'
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const Apps = () => {
const { t } = useTranslation()
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@ -113,7 +103,7 @@ const Apps = ({
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} pageType={pageType} />
<List controlRefreshList={controlRefreshList} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@ -1,13 +1,13 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { App } from '@/types/app'
import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -16,21 +16,15 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { useInfiniteAppList } from '@/service/use-apps'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import { AppModeEnum, AppModes } from '@/types/app'
import { cn } from '@/utils/classnames'
import SnippetCard from '../snippets/components/snippet-card'
import SnippetCreateCard from '../snippets/components/snippet-create-card'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import NewAppCard from './new-app-card'
import StudioRouteSwitch from './studio-route-switch'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
@ -39,17 +33,25 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
type Props = {
controlRefreshList?: number
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { systemFeatures } = useGlobalPublicStore()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@ -59,22 +61,18 @@ const List: FC<Props> = ({
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
const [snippetKeywords, setSnippetKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const setKeywords = useCallback((nextKeywords: string) => {
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
}, [setQuery])
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@ -85,15 +83,15 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isAppsPage && isCurrentWorkspaceEditor,
enabled: isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
name: appKeywords,
name: searchKeywords,
tag_ids: tagIDs,
is_created_by_me: queryIsCreatedByMe,
is_created_by_me: isCreatedByMe,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@ -106,214 +104,159 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
const {
data: snippetData,
isLoading: isSnippetListLoading,
isFetching: isSnippetListFetching,
isFetchingNextPage: isSnippetListFetchingNextPage,
fetchNextPage: fetchSnippetNextPage,
hasNextPage: hasSnippetNextPage,
error: snippetError,
} = useInfiniteSnippetList({
page: 1,
limit: 30,
keyword: snippetKeywords || undefined,
}, {
enabled: !isAppsPage,
})
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
useEffect(() => {
if (isAppsPage && controlRefreshList > 0)
if (controlRefreshList > 0) {
refetch()
}, [controlRefreshList, isAppsPage, refetch])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
]
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [isAppsPage, refetch])
}, [refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
const currentError = isAppsPage ? error : snippetError
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (currentError) {
observer?.disconnect()
if (error) {
if (observer)
observer.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
if (isAppsPage)
fetchNextPage()
else
fetchSnippetNextPage()
}
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
setSnippetKeywords(value)
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
}, { wait: 500 })
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywordsInput(value)
handleSnippetSearch(value)
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = useCallback((value: string[]) => {
const handleTagsChange = (value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate(value)
}, [handleTagsUpdate])
handleTagsUpdate()
}
const appItems = useMemo<App[]>(() => {
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
}, [data?.pages])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const snippetItems = useMemo(() => {
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
}, [snippetData?.pages])
const showSkeleton = isAppsPage
? (isLoading || (isFetching && data?.pages?.length === 0))
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = snippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
{dragging && (
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
</div>
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
/>
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
isAppsPage && !hasAnyApp && 'overflow-hidden',
!hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: <SnippetCreateCard />
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
{showSkeleton && <AppCardSkeleton count={6} />}
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))
}
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
)}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
{!isAppsPage && isSnippetListFetchingNextPage && (
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isAppsPage && isCurrentWorkspaceEditor && (
{isCurrentWorkspaceEditor && (
<div
className={cn(
'flex items-center justify-center gap-2 py-4',
dragging ? 'text-text-accent' : 'text-text-quaternary',
)}
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
@ -321,18 +264,17 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
{isAppsPage && showTagManagementModal && (
{showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{isAppsPage && showCreateFromDSLModal && (
{showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

@ -1,44 +0,0 @@
'use client'
import type { StudioPageType } from '.'
import Link from '@/next/link'
import { cn } from '@/utils/classnames'
type Props = {
pageType: StudioPageType
appsLabel: string
snippetsLabel: string
}
const StudioRouteSwitch = ({
pageType,
appsLabel,
snippetsLabel,
}: Props) => {
return (
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
<Link
href="/apps"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'apps' && 'font-medium',
)}
>
{appsLabel}
</Link>
<Link
href="/snippets"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'snippets' && 'font-medium',
)}
>
{snippetsLabel}
</Link>
</div>
)
}
export default StudioRouteSwitch

View File

@ -1 +1,2 @@
export { default as BracketsX } from './BracketsX'
export { default as CodeBrowser } from './CodeBrowser'

View File

@ -1,486 +0,0 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import Evaluation from '..'
import ConditionsSection from '../components/conditions-section'
import { useEvaluationStore } from '../store'
const mockUpload = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseEvaluationConfig = vi.hoisted(() => vi.fn())
const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn())
const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn())
const mockUseStartEvaluationRunMutation = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
data: [{
provider: 'openai',
models: [{ model: 'gpt-4o-mini' }],
}],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({
defaultModel,
onSelect,
}: {
defaultModel?: { provider: string, model: string }
onSelect: (model: { provider: string, model: string }) => void
}) => (
<div>
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
<button
type="button"
onClick={() => onSelect({ provider: 'openai', model: 'gpt-4o-mini' })}
>
select-model
</button>
</div>
),
}))
vi.mock('@/service/base', () => ({
upload: (...args: unknown[]) => mockUpload(...args),
}))
vi.mock('@/service/use-evaluation', () => ({
useEvaluationConfig: (...args: unknown[]) => mockUseEvaluationConfig(...args),
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args),
useSaveEvaluationConfigMutation: (...args: unknown[]) => mockUseSaveEvaluationConfigMutation(...args),
useStartEvaluationRunMutation: (...args: unknown[]) => mockUseStartEvaluationRunMutation(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
},
isLoading: false,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: () => ({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
},
isLoading: false,
}),
}))
const renderWithQueryClient = (ui: ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
return render(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
})
}
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
vi.clearAllMocks()
mockUseEvaluationConfig.mockReturnValue({
data: null,
})
mockUseAvailableEvaluationMetrics.mockReturnValue({
data: {
metrics: ['answer-correctness', 'faithfulness', 'context-precision', 'context-recall', 'context-relevance'],
},
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
'faithfulness': [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
})
},
})
mockUseSaveEvaluationConfigMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
})
mockUseStartEvaluationRunMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
})
mockUpload.mockResolvedValue({
id: 'uploaded-file-id',
name: 'evaluation.csv',
})
})
it('should search, select metric nodes, and save evaluation config', () => {
const saveConfig = vi.fn()
mockUseSaveEvaluationConfigMutation.mockReturnValue({
isPending: false,
mutate: saveConfig,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'does-not-exist' },
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'faith' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness'))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
expect(screen.getAllByText('Retriever Node').length).toBeGreaterThan(0)
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: '' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-answer-correctness-node-answer'))
expect(screen.getAllByText('Answer Correctness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(saveConfig).toHaveBeenCalledWith({
params: {
targetType: 'apps',
targetId: 'app-1',
},
body: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [
{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
},
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
],
customized_metrics: null,
judgment_config: null,
},
}, {
onSuccess: expect.any(Function),
onError: expect.any(Function),
})
})
it('should hide the value row for empty operators', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
let conditionId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCondition(resourceType, resourceId)
const condition = useEvaluationStore.getState().resources['apps:app-2'].judgmentConfig.conditions[0]
conditionId = condition.id
store.updateConditionOperator(resourceType, resourceId, conditionId, '=')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = renderWithQueryClient(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByPlaceholderText('evaluation.conditions.valuePlaceholder')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, conditionId, 'is null')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByPlaceholderText('evaluation.conditions.valuePlaceholder')).not.toBeInTheDocument()
})
it('should add a condition from grouped metric dropdown items', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions-dropdown'
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: 'workflow-1',
workflowAppId: 'workflow-app-1',
workflowName: 'Review Workflow',
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
})
render(<ConditionsSection resourceType={resourceType} resourceId={resourceId} />)
fireEvent.click(screen.getByRole('combobox', { name: 'evaluation.conditions.addCondition' }))
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('Review Workflow')).toBeInTheDocument()
expect(screen.getByText('Retriever Node')).toBeInTheDocument()
expect(screen.getByText('reason')).toBeInTheDocument()
expect(screen.getByText('evaluation.conditions.valueTypes.number')).toBeInTheDocument()
expect(screen.getByText('evaluation.conditions.valueTypes.string')).toBeInTheDocument()
fireEvent.click(screen.getByRole('option', { name: /reason/i }))
const condition = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].judgmentConfig.conditions[0]
expect(condition.variableSelector).toEqual(['workflow-1', 'reason'])
expect(screen.getAllByText('Review Workflow').length).toBeGreaterThan(0)
})
it('should render the metric no-node empty state', () => {
mockUseAvailableEvaluationMetrics.mockReturnValue({
data: {
metrics: ['context-precision'],
},
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'context-precision': [],
})
},
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-3" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('evaluation.metrics.noNodesInWorkflow')).toBeInTheDocument()
})
it('should render the global empty state when no metrics are available', () => {
mockUseAvailableEvaluationMetrics.mockReturnValue({
data: {
metrics: [],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-4" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
})
it('should show more nodes when a metric has more than three nodes', () => {
mockUseAvailableEvaluationMetrics.mockReturnValue({
data: {
metrics: ['answer-correctness'],
},
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
{ node_id: 'node-3', title: 'LLM 3', type: 'llm' },
{ node_id: 'node-4', title: 'LLM 4', type: 'llm' },
],
})
},
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-5" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('LLM 3')).toBeInTheDocument()
expect(screen.queryByText('LLM 4')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.showMore' }))
expect(screen.getByText('LLM 4')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.showLess' })).toBeInTheDocument()
})
it('should render the pipeline-specific layout without auto-selecting a judge model', () => {
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('empty')
expect(screen.getByText('evaluation.history.columns.time')).toBeInTheDocument()
expect(screen.getByText('Context Precision')).toBeInTheDocument()
expect(screen.getByText('Context Recall')).toBeInTheDocument()
expect(screen.getByText('Context Relevance')).toBeInTheDocument()
expect(screen.getByText('evaluation.results.empty')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })).toBeDisabled()
})
it('should render selected pipeline metrics from config with the default threshold input', () => {
mockUseEvaluationConfig.mockReturnValue({
data: {
evaluation_model: null,
evaluation_model_provider: null,
default_metrics: [{
metric: 'context-precision',
}],
customized_metrics: null,
judgment_config: null,
},
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-2" />)
expect(screen.getByText('Context Precision')).toBeInTheDocument()
expect(screen.getByDisplayValue('0.85')).toBeInTheDocument()
})
it('should enable pipeline batch actions after selecting a judge model and metric', () => {
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-2" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
expect(screen.getByDisplayValue('0.85')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.batch.downloadTemplate' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })).toBeEnabled()
})
it('should upload and start a pipeline evaluation run', async () => {
const startRun = vi.fn()
mockUseStartEvaluationRunMutation.mockReturnValue({
isPending: false,
mutate: startRun,
})
mockUpload.mockResolvedValue({
id: 'file-1',
name: 'pipeline-evaluation.csv',
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-run" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
fireEvent.click(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' }))
expect(screen.getAllByText('query').length).toBeGreaterThan(0)
expect(screen.getAllByText('Expect Results').length).toBeGreaterThan(0)
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"][accept=".csv,.xlsx"]')
expect(fileInput).toBeInTheDocument()
fireEvent.change(fileInput!, {
target: {
files: [new File(['query,Expect Results'], 'pipeline-evaluation.csv', { type: 'text/csv' })],
},
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith({
xhr: expect.any(XMLHttpRequest),
data: expect.any(FormData),
})
})
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
await waitFor(() => {
expect(startRun).toHaveBeenCalledWith({
params: {
targetType: 'datasets',
targetId: 'dataset-run',
},
body: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'context-precision',
value_type: 'number',
node_info_list: [],
}],
customized_metrics: null,
judgment_config: null,
file_id: 'file-1',
},
}, {
onSuccess: expect.any(Function),
onError: expect.any(Function),
})
})
})
})

View File

@ -1,347 +0,0 @@
import type { EvaluationConfig } from '@/types/evaluation'
import { getEvaluationMockConfig } from '../mock'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../store-utils'
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'apps'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, {
workflowId: config.workflowOptions[0].id,
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
})
store.syncCustomMetricMappings(resourceType, resourceId, initialMetric!.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, initialMetric!.id, [{
id: 'score',
valueType: 'number',
}])
const syncedMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, syncedMetric!.customConfig!.mappings[0].id, {
outputVariableId: 'answer',
})
const configuredMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
expect(configuredMetric!.customConfig!.workflowAppId).toBe('custom-workflow-app-id')
expect(configuredMetric!.customConfig!.workflowName).toBe(config.workflowOptions[0].label)
expect(configuredMetric!.customConfig!.outputs).toEqual([{ id: 'score', valueType: 'number' }])
})
it('should only add one custom metric', () => {
const resourceType = 'apps'
const resourceId = 'app-custom-limit'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
expect(
useEvaluationStore
.getState()
.resources['apps:app-custom-limit']
.metrics
.filter(metric => metric.kind === 'custom-workflow'),
).toHaveLength(1)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
const addedMetric = useEvaluationStore.getState().resources['apps:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['apps:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should upsert builtin metric node selections and prune stale conditions', () => {
const resourceType = 'apps'
const resourceId = 'app-4'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const metricId = config.builtinMetrics[0].id
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-1', title: 'Answer Node', type: 'answer' },
])
store.addCondition(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
const state = useEvaluationStore.getState().resources['apps:app-4']
const metric = state.metrics.find(item => item.optionId === metricId)
expect(metric?.nodeInfoList).toEqual([
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
expect(state.metrics.filter(item => item.optionId === metricId)).toHaveLength(1)
expect(state.judgmentConfig.conditions).toHaveLength(0)
})
it('should build numeric conditions from selected metrics', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[0].id, [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
store.setConditionLogicalOperator(resourceType, resourceId, 'or')
store.addCondition(resourceType, resourceId)
const state = useEvaluationStore.getState().resources['apps:app-conditions']
const condition = state.judgmentConfig.conditions[0]
expect(state.judgmentConfig.logicalOperator).toBe('or')
expect(condition.variableSelector).toEqual(['node-answer', 'answer-correctness'])
expect(condition.comparisonOperator).toBe('=')
expect(getAllowedOperators(state.metrics, condition.variableSelector)).toEqual(['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null'])
})
it('should add a condition from the selected custom metric output', () => {
const resourceType = 'apps'
const resourceId = 'app-condition-selector'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-condition-selector'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: config.workflowOptions[0].id,
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId, [config.workflowOptions[0].id, 'reason'])
const condition = useEvaluationStore.getState().resources['apps:app-condition-selector'].judgmentConfig.conditions[0]
expect(condition.variableSelector).toEqual([config.workflowOptions[0].id, 'reason'])
expect(condition.comparisonOperator).toBe('contains')
expect(condition.value).toBeNull()
})
it('should clear values for operators without values', () => {
const resourceType = 'apps'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-3'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: config.workflowOptions[0].id,
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId)
const condition = useEvaluationStore.getState().resources['apps:app-3'].judgmentConfig.conditions[0]
store.updateConditionMetric(resourceType, resourceId, condition.id, [config.workflowOptions[0].id, 'reason'])
store.updateConditionValue(resourceType, resourceId, condition.id, 'needs follow-up')
store.updateConditionOperator(resourceType, resourceId, condition.id, 'empty')
const updatedCondition = useEvaluationStore.getState().resources['apps:app-3'].judgmentConfig.conditions[0]
expect(requiresConditionValue('empty')).toBe(false)
expect(updatedCondition.value).toBeNull()
})
it('should hydrate resource state from judgment_config', () => {
const resourceType = 'apps'
const resourceId = 'app-5'
const store = useEvaluationStore.getState()
const config: EvaluationConfig = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'faithfulness',
node_info_list: [
{ node_id: 'node-1', title: 'Retriever', type: 'retriever' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'workflow-precision-review',
input_fields: {
query: 'answer',
},
output_fields: [{
variable: 'reason',
value_type: 'string',
}],
},
judgment_config: {
logical_operator: 'or',
conditions: [{
variable_selector: ['node-1', 'faithfulness'],
comparison_operator: '≥',
value: '0.9',
}],
},
}
store.ensureResource(resourceType, resourceId)
store.setBatchTab(resourceType, resourceId, 'history')
store.setUploadedFileName(resourceType, resourceId, 'batch.csv')
useEvaluationStore.setState(state => ({
resources: {
...state.resources,
'apps:app-5': {
...state.resources['apps:app-5'],
batchRecords: [{
id: 'batch-1',
fileName: 'batch.csv',
status: 'success',
startedAt: '10:00:00',
summary: 'App evaluation batch',
}],
},
},
}))
store.hydrateResource(resourceType, resourceId, config)
const hydratedState = useEvaluationStore.getState().resources['apps:app-5']
expect(hydratedState.judgeModelId).toBe('openai::gpt-4o-mini')
expect(hydratedState.metrics).toHaveLength(2)
expect(hydratedState.metrics[0].optionId).toBe('faithfulness')
expect(hydratedState.metrics[0].threshold).toBe(0.85)
expect(hydratedState.metrics[0].nodeInfoList).toEqual([
{ node_id: 'node-1', title: 'Retriever', type: 'retriever' },
])
expect(hydratedState.metrics[1].kind).toBe('custom-workflow')
expect(hydratedState.metrics[1].customConfig?.workflowId).toBe('workflow-precision-review')
expect(hydratedState.metrics[1].customConfig?.mappings[0].inputVariableId).toBe('query')
expect(hydratedState.metrics[1].customConfig?.mappings[0].outputVariableId).toBe('answer')
expect(hydratedState.metrics[1].customConfig?.outputs).toEqual([{ id: 'reason', valueType: 'string' }])
expect(hydratedState.judgmentConfig.logicalOperator).toBe('or')
expect(hydratedState.judgmentConfig.conditions[0]).toMatchObject({
variableSelector: ['node-1', 'faithfulness'],
comparisonOperator: '≥',
value: '0.9',
})
expect(hydratedState.activeBatchTab).toBe('history')
expect(hydratedState.uploadedFileName).toBe('batch.csv')
expect(hydratedState.batchRecords).toHaveLength(1)
})
it('should build an evaluation config save payload from resource state', () => {
const resourceType = 'apps'
const resourceId = 'app-save-config'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-save-config'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: 'workflow-precision-review',
workflowAppId: 'evaluation-workflow-app-id',
workflowName: 'Precision Review',
})
store.syncCustomMetricMappings(resourceType, resourceId, customMetric.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'score',
valueType: 'number',
}])
const syncedMetric = useEvaluationStore.getState().resources['apps:app-save-config'].metrics.find(metric => metric.id === customMetric.id)!
store.updateCustomMetricMapping(resourceType, resourceId, customMetric.id, syncedMetric.customConfig!.mappings[0].id, {
outputVariableId: '{{#node-answer.output#}}',
})
store.addCondition(resourceType, resourceId, ['workflow-precision-review', 'score'])
const condition = useEvaluationStore.getState().resources['apps:app-save-config'].judgmentConfig.conditions[0]
store.updateConditionOperator(resourceType, resourceId, condition.id, '≥')
store.updateConditionValue(resourceType, resourceId, condition.id, '0.8')
const resource = useEvaluationStore.getState().resources['apps:app-save-config']
const expectedPayload = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'evaluation-workflow-app-id',
input_fields: {
query: '{{#node-answer.output#}}',
},
output_fields: [{
variable: 'score',
value_type: 'number',
}],
},
judgment_config: {
logical_operator: 'and',
conditions: [{
variable_selector: ['evaluation-workflow-app-id', 'score'],
comparison_operator: '≥',
value: '0.8',
}],
},
}
expect(buildEvaluationConfigPayload(resource)).toEqual(expectedPayload)
expect(buildEvaluationRunRequest(resource, 'file-1')).toEqual({
...expectedPayload,
file_id: 'file-1',
})
})
})

View File

@ -1,202 +0,0 @@
import type { EvaluationResourceProps } from '../../types'
import type { EvaluationLog, EvaluationLogFile } from '@/types/evaluation'
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { consoleClient, consoleQuery } from '@/service/client'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { useEvaluationResource, useEvaluationStore } from '../../store'
const PAGE_SIZE = 16
const LOADING_ROW_IDS = ['1', '2', '3', '4', '5', '6']
const formatCreatedAt = (createdAt: string) => {
if (!createdAt)
return '-'
return createdAt.includes('T') ? createdAt.slice(0, 10) : createdAt
}
const getLogRunId = (record: EvaluationLog) => {
return record.run_id ?? record.evaluation_run_id ?? record.id ?? null
}
const HistoryTab = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const [page, setPage] = useState(0)
const resource = useEvaluationResource(resourceType, resourceId)
const setSelectedRunId = useEvaluationStore(state => state.setSelectedRunId)
const logsQuery = useQuery({
...consoleQuery.evaluation.logs.queryOptions({
input: {
params: {
targetType: resourceType,
targetId: resourceId,
},
query: {
page: page + 1,
page_size: PAGE_SIZE,
},
},
refetchOnWindowFocus: false,
}),
placeholderData: keepPreviousData,
})
const fileDownloadMutation = useMutation({
mutationFn: async (file: EvaluationLogFile) => {
const fileInfo = await consoleClient.evaluation.file({
params: {
targetType: resourceType,
targetId: resourceId,
fileId: file.id,
},
})
downloadUrl({ url: fileInfo.download_url, fileName: file.name })
},
})
const records = useMemo(() => logsQuery.data?.data ?? [], [logsQuery.data?.data])
const total = logsQuery.data?.total ?? 0
const isInitialLoading = logsQuery.isLoading && !logsQuery.data
useEffect(() => {
if (resource.selectedRunId)
return
const firstRunId = records.map(getLogRunId).find((runId): runId is string => !!runId)
if (firstRunId)
setSelectedRunId(resourceType, resourceId, firstRunId)
}, [records, resource.selectedRunId, resourceId, resourceType, setSelectedRunId])
return (
<div className="flex min-h-full flex-col">
<div className="min-h-0 flex-1 overflow-hidden">
<table className="w-full table-fixed border-collapse overflow-hidden rounded-md">
<colgroup>
<col className="w-[120px]" />
<col className="w-[95px]" />
<col className="w-[80px]" />
<col className="w-[67px]" />
<col className="w-[40px]" />
</colgroup>
<thead>
<tr className="border-b border-divider-regular">
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">
<span className="inline-flex items-center gap-0.5">
{t('history.columns.time')}
<span aria-hidden="true" className="i-ri-arrow-down-line h-3.5 w-3.5" />
</span>
</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('history.columns.creator')}</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('history.columns.version')}</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('history.columns.status')}</th>
<th className="h-7 text-center text-text-tertiary">
<span aria-hidden="true" className="i-ri-download-2-line inline-block h-3.5 w-3.5" />
</th>
</tr>
</thead>
<tbody>
{isInitialLoading && LOADING_ROW_IDS.map(rowId => (
<tr key={rowId} className="border-b border-divider-subtle">
<td colSpan={5} className="h-10 px-3">
<div className="h-4 animate-pulse rounded bg-background-section" />
</td>
</tr>
))}
{!isInitialLoading && records.map(record => (
<tr
key={`${record.created_at}-${record.test_file.id}`}
className={cn(
'border-b border-divider-subtle',
getLogRunId(record) && 'cursor-pointer hover:bg-state-base-hover',
getLogRunId(record) === resource.selectedRunId && 'bg-background-default-subtle',
)}
onClick={() => {
const runId = getLogRunId(record)
if (runId)
setSelectedRunId(resourceType, resourceId, runId)
}}
>
<td className="h-10 truncate px-3 system-sm-regular text-text-secondary">{formatCreatedAt(record.created_at)}</td>
<td className="h-10 truncate px-3 system-sm-regular text-text-secondary">{record.created_by}</td>
<td className="h-10 truncate px-3 system-sm-regular text-text-secondary">{record.version || '-'}</td>
<td className="h-10 px-3 text-center">
{record.result_file
? <span aria-label={t('history.status.completed')} className="i-ri-checkbox-circle-fill inline-block h-4 w-4 text-util-colors-green-green-600" />
: <span aria-label={t('history.status.running')} className="i-ri-loader-4-line inline-block h-4 w-4 animate-spin text-text-accent" />}
</td>
<td className="h-10 text-center">
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('history.actions.open')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={event => event.stopPropagation()}
/>
)}
>
<span aria-hidden="true" className="i-ri-more-2-fill h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent popupClassName="w-[180px] rounded-lg border-[0.5px] border-components-panel-border py-1 shadow-lg">
<DropdownMenuItem
className="gap-2"
onClick={(event) => {
event.stopPropagation()
fileDownloadMutation.mutate(record.test_file)
}}
>
<span aria-hidden="true" className="i-ri-file-download-line h-4 w-4" />
{t('history.actions.downloadTestFile')}
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
disabled={!record.result_file}
onClick={(event) => {
event.stopPropagation()
if (record.result_file)
fileDownloadMutation.mutate(record.result_file)
}}
>
<span aria-hidden="true" className="i-ri-download-2-line h-4 w-4" />
{t('history.actions.downloadResultFile')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
{!isInitialLoading && records.length === 0 && (
<div className="rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center system-sm-regular text-text-tertiary">
{t('history.empty')}
</div>
)}
</div>
{total > PAGE_SIZE && (
<Pagination
className="px-0 py-3"
current={page}
limit={PAGE_SIZE}
total={total}
onChange={setPage}
/>
)}
</div>
)
}
export default HistoryTab

View File

@ -1,120 +0,0 @@
'use client'
import type { BatchTestTab, EvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { toast } from '@/app/components/base/ui/toast'
import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation'
import { cn } from '@/utils/classnames'
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../../store'
import { buildEvaluationConfigPayload } from '../../store-utils'
import { TAB_CLASS_NAME } from '../../utils'
import HistoryTab from './history-tab'
import InputFieldsTab from './input-fields-tab'
const BATCH_TABS: BatchTestTab[] = ['input-fields', 'history']
const BatchTestPanel = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const tabLabels: Record<BatchTestTab, string> = {
'input-fields': t('batch.tabs.input-fields'),
'history': t('batch.tabs.history'),
}
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const saveConfigMutation = useSaveEvaluationConfigMutation()
const isRunnable = isEvaluationRunnable(resource)
const isPanelReady = !!resource.judgeModelId && resource.metrics.length > 0
const handleSave = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
const body = buildEvaluationConfigPayload(resource)
if (!body) {
toast.warning(t('batch.validation'))
return
}
saveConfigMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: () => {
toast.success(tCommon('api.saved'))
},
onError: () => {
toast.error(t('config.saveFailed'))
},
})
}
return (
<div className="flex h-full min-h-0 flex-col bg-background-default">
<div className="px-6 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="system-xl-semibold text-text-primary">{t('batch.title')}</div>
<div className="mt-1 system-sm-regular text-text-tertiary">{t('batch.description')}</div>
</div>
<Button
className="shrink-0"
variant="primary"
disabled={!isRunnable}
loading={saveConfigMutation.isPending}
onClick={handleSave}
>
{tCommon('operation.save')}
</Button>
</div>
<div className="mt-4 rounded-xl border border-divider-subtle bg-components-card-bg p-3">
<div className="flex items-start gap-3">
<span aria-hidden="true" className="mt-0.5 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-tertiary">{t('batch.noticeDescription')}</div>
</div>
</div>
</div>
<div className="border-b border-divider-subtle px-6">
<div className="flex gap-4">
{BATCH_TABS.map(tab => (
<button
key={tab}
type="button"
className={cn(
TAB_CLASS_NAME,
'flex-none rounded-none border-b-2 border-transparent px-0 pt-2 pb-2.5 uppercase',
resource.activeBatchTab === tab ? 'border-text-accent-secondary text-text-primary' : 'text-text-tertiary',
)}
onClick={() => setBatchTab(resourceType, resourceId, tab)}
>
{tabLabels[tab]}
</button>
))}
</div>
</div>
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', !isPanelReady && 'opacity-50')}>
{resource.activeBatchTab === 'input-fields' && (
<InputFieldsTab
resourceType={resourceType}
resourceId={resourceId}
isPanelReady={isPanelReady}
isRunnable={isRunnable}
/>
)}
{resource.activeBatchTab === 'history' && <HistoryTab resourceType={resourceType} resourceId={resourceId} />}
</div>
</div>
)
}
export default BatchTestPanel

View File

@ -1,71 +0,0 @@
import type { EvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { getEvaluationMockConfig } from '../../mock'
import InputFieldsRequirements from './input-fields/input-fields-requirements'
import UploadRunPopover from './input-fields/upload-run-popover'
import { useInputFieldsActions } from './input-fields/use-input-fields-actions'
import { usePublishedInputFields } from './input-fields/use-published-input-fields'
type InputFieldsTabProps = EvaluationResourceProps & {
isPanelReady: boolean
isRunnable: boolean
}
const InputFieldsTab = ({
resourceType,
resourceId,
isPanelReady,
isRunnable,
}: InputFieldsTabProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const { inputFields, isInputFieldsLoading } = usePublishedInputFields(resourceType, resourceId)
const actions = useInputFieldsActions({
resourceType,
resourceId,
inputFields,
isInputFieldsLoading,
isPanelReady,
isRunnable,
templateFileName: config.templateFileName,
})
return (
<div className="space-y-5">
<InputFieldsRequirements
inputFields={inputFields}
isLoading={isInputFieldsLoading}
/>
<div className="space-y-3">
<Button variant="secondary" className="w-full justify-center" disabled={!actions.canDownloadTemplate} onClick={actions.handleDownloadTemplate}>
<span aria-hidden="true" className="mr-1 i-ri-download-line h-4 w-4" />
{t('batch.downloadTemplate')}
</Button>
<UploadRunPopover
open={actions.isUploadPopoverOpen}
onOpenChange={actions.setIsUploadPopoverOpen}
triggerDisabled={actions.uploadButtonDisabled}
inputFields={inputFields}
currentFileName={actions.currentFileName}
currentFileExtension={actions.currentFileExtension}
currentFileSize={actions.currentFileSize}
isFileUploading={actions.isFileUploading}
isRunDisabled={actions.isRunDisabled}
isRunning={actions.isRunning}
onUploadFile={actions.handleUploadFile}
onClearUploadedFile={actions.handleClearUploadedFile}
onDownloadTemplate={actions.handleDownloadTemplate}
onRun={actions.handleRun}
/>
</div>
{!isRunnable && (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary">
{t('batch.validation')}
</div>
)}
</div>
)
}
export default InputFieldsTab

View File

@ -1,45 +0,0 @@
import type { InputField } from './input-fields-utils'
import { useTranslation } from 'react-i18next'
type InputFieldsRequirementsProps = {
inputFields: InputField[]
isLoading: boolean
}
const InputFieldsRequirements = ({
inputFields,
isLoading,
}: InputFieldsRequirementsProps) => {
const { t } = useTranslation('evaluation')
return (
<div>
<div className="system-md-semibold text-text-primary">{t('batch.requirementsTitle')}</div>
<div className="mt-1 system-xs-regular text-text-tertiary">{t('batch.requirementsDescription')}</div>
<div className="mt-3 rounded-xl bg-background-section p-3">
{isLoading && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{t('batch.loadingInputFields')}
</div>
)}
{!isLoading && inputFields.length === 0 && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{t('batch.noInputFields')}
</div>
)}
{!isLoading && inputFields.map(field => (
<div key={field.name} className="flex items-center py-1">
<div className="rounded px-1 py-0.5 system-xs-medium text-text-tertiary">
{field.name}
</div>
<div className="text-[10px] leading-3 text-text-quaternary">
{field.type}
</div>
</div>
))}
</div>
</div>
)
}
export default InputFieldsRequirements

View File

@ -1,54 +0,0 @@
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { InputVar, Node } from '@/app/components/workflow/types'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
export type InputField = {
name: string
type: string
}
export const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
export const getStartNodeInputFields = (nodes?: Node[]): InputField[] => {
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
const variables = startNode?.data.variables
if (!Array.isArray(variables))
return []
return variables
.filter((variable): variable is InputVar => typeof variable.variable === 'string' && !!variable.variable)
.map(variable => ({
name: variable.variable,
type: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
}))
}
const escapeCsvCell = (value: string) => {
if (!/[",\n\r]/.test(value))
return value
return `"${value.replace(/"/g, '""')}"`
}
export const buildTemplateCsvContent = (inputFields: InputField[]) => {
return `${inputFields.map(field => escapeCsvCell(field.name)).join(',')}\n`
}
export const getFileExtension = (fileName: string) => {
const extension = fileName.split('.').pop()
return extension && extension !== fileName ? extension.toUpperCase() : ''
}
export const getExampleValue = (field: InputField, booleanLabel: string) => {
if (field.type === 'number')
return '0.7'
if (field.type === 'boolean')
return booleanLabel
return field.name
}

View File

@ -1,189 +0,0 @@
import type { ChangeEvent, DragEvent } from 'react'
import type { InputField } from './input-fields-utils'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { cn } from '@/utils/classnames'
import { getExampleValue } from './input-fields-utils'
type UploadRunPopoverProps = {
open: boolean
onOpenChange: (open: boolean) => void
triggerDisabled: boolean
triggerLabel?: string
inputFields: InputField[]
currentFileName: string | null | undefined
currentFileExtension: string
currentFileSize: string | number
isFileUploading: boolean
isRunDisabled: boolean
isRunning: boolean
onUploadFile: (file: File | undefined) => void
onClearUploadedFile: () => void
onDownloadTemplate: () => void
onRun: () => void
}
const UploadRunPopover = ({
open,
onOpenChange,
triggerDisabled,
triggerLabel,
inputFields,
currentFileName,
currentFileExtension,
currentFileSize,
isFileUploading,
isRunDisabled,
isRunning,
onUploadFile,
onClearUploadedFile,
onDownloadTemplate,
onRun,
}: UploadRunPopoverProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const fileInputRef = useRef<HTMLInputElement>(null)
const previewFields = inputFields.slice(0, 3)
const booleanExampleValue = t('conditions.boolean.true')
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
onUploadFile(event.target.files?.[0])
event.target.value = ''
}
const handleDropFile = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
onUploadFile(event.dataTransfer.files?.[0])
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={(
<Button className="w-full justify-center" variant="primary" disabled={triggerDisabled}>
{triggerLabel ?? t('batch.uploadAndRun')}
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={8}
popupClassName="w-[402px] overflow-hidden rounded-lg border border-components-panel-border p-0 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]"
>
<div className="flex flex-col bg-components-panel-bg">
<div className="flex flex-col gap-4 p-4">
<input
ref={fileInputRef}
hidden
type="file"
accept=".csv,.xlsx"
onChange={handleFileChange}
/>
{currentFileName
? (
<div className="flex h-20 items-center gap-3 rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3">
<div className="flex p-3">
<span aria-hidden="true" className="i-ri-file-excel-fill h-6 w-6 text-util-colors-green-green-600" />
</div>
<div className="min-w-0 flex-1 py-1 pr-2">
<div className="truncate system-xs-medium text-text-secondary">
{currentFileName}
</div>
<div className="mt-0.5 flex h-3 items-center gap-1 system-2xs-medium text-text-tertiary">
{!!currentFileExtension && <span className="uppercase">{currentFileExtension}</span>}
{!!currentFileExtension && !!currentFileSize && <span className="text-text-quaternary">·</span>}
{!!currentFileSize && <span>{currentFileSize}</span>}
</div>
</div>
<div className="flex items-center gap-1 pr-3">
{isFileUploading && (
<span aria-hidden="true" className="i-ri-loader-4-line h-4 w-4 animate-spin text-text-accent" />
)}
<button
type="button"
className="rounded-md p-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={onClearUploadedFile}
aria-label={t('batch.removeUploadedFile')}
>
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
</button>
</div>
</div>
)
: (
<div
className="flex h-20 w-full items-center justify-center gap-3 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg p-3 text-left hover:border-components-button-secondary-border"
onDragOver={event => event.preventDefault()}
onDrop={handleDropFile}
>
<button
type="button"
className="flex shrink-0 p-3"
onClick={() => fileInputRef.current?.click()}
>
<span aria-hidden="true" className="i-ri-file-upload-line h-6 w-6 text-text-tertiary" />
<span className="sr-only">{t('batch.uploadTitle')}</span>
</button>
<div className="min-w-0 flex-1 text-left">
<div className="system-md-regular text-text-secondary">
{t('batch.uploadDropzonePrefix')}
{' '}
<span className="system-md-semibold">{t('batch.uploadDropzoneEmphasis')}</span>
{' '}
{t('batch.uploadDropzoneSuffix')}
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{t('batch.uploadDropzoneDownloadPrefix')}
{' '}
<button
type="button"
className="text-text-accent hover:underline"
onClick={onDownloadTemplate}
>
{t('batch.uploadDropzoneDownloadLink')}
</button>
</div>
</div>
</div>
)}
{!!previewFields.length && (
<div className="space-y-1">
<div className="system-md-semibold text-text-secondary">{t('batch.example')}</div>
<div className="flex overflow-hidden rounded-lg border border-divider-regular">
{previewFields.map((field, index) => (
<div key={field.name} className={cn('min-w-0 flex-1', index < previewFields.length - 1 && 'border-r border-divider-subtle')}>
<div className="min-h-8 border-b border-divider-regular px-3 py-2 system-xs-medium-uppercase text-text-tertiary">
{field.name}
</div>
<div className="min-h-8 px-3 py-2 system-sm-regular text-text-secondary">
{getExampleValue(field, booleanExampleValue)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex items-end justify-end gap-2 border-t border-components-panel-border px-4 py-4">
<Button variant="secondary" className="rounded-lg" onClick={() => onOpenChange(false)}>
{tCommon('operation.cancel')}
</Button>
<Button className="flex-1 justify-center rounded-lg" variant="primary" disabled={isRunDisabled} loading={isRunning} onClick={onRun}>
<span aria-hidden="true" className="mr-1 i-ri-play-fill h-5 w-5" />
{t('batch.run')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export default UploadRunPopover

View File

@ -1,167 +0,0 @@
import type { EvaluationResourceProps } from '../../../types'
import type { InputField } from './input-fields-utils'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { upload } from '@/service/base'
import { useStartEvaluationRunMutation } from '@/service/use-evaluation'
import { formatFileSize } from '@/utils/format'
import { useEvaluationResource, useEvaluationStore } from '../../../store'
import { buildEvaluationRunRequest } from '../../../store-utils'
import { buildTemplateCsvContent, getFileExtension } from './input-fields-utils'
type UploadedFileMeta = {
name: string
size: number
}
type UseInputFieldsActionsParams = EvaluationResourceProps & {
inputFields: InputField[]
isInputFieldsLoading: boolean
isPanelReady: boolean
isRunnable: boolean
templateFileName: string
}
export const useInputFieldsActions = ({
resourceType,
resourceId,
inputFields,
isInputFieldsLoading,
isPanelReady,
isRunnable,
templateFileName,
}: UseInputFieldsActionsParams) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const setSelectedRunId = useEvaluationStore(state => state.setSelectedRunId)
const setUploadedFile = useEvaluationStore(state => state.setUploadedFile)
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
const startRunMutation = useStartEvaluationRunMutation()
const [isUploadPopoverOpen, setIsUploadPopoverOpen] = useState(false)
const [uploadedFileMeta, setUploadedFileMeta] = useState<UploadedFileMeta | null>(null)
const uploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return upload({
xhr: new XMLHttpRequest(),
data: formData,
})
},
onSuccess: (uploadedFile, file) => {
setUploadedFile(resourceType, resourceId, {
id: uploadedFile.id,
name: typeof uploadedFile.name === 'string' ? uploadedFile.name : file.name,
})
},
onError: () => {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
toast.error(t('batch.uploadError'))
},
})
const isFileUploading = uploadMutation.isPending
const isRunning = startRunMutation.isPending
const uploadedFileId = resource.uploadedFileId
const currentFileName = uploadedFileMeta?.name ?? resource.uploadedFileName
const canDownloadTemplate = isPanelReady && !isInputFieldsLoading && inputFields.length > 0
const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning
const uploadButtonDisabled = !isPanelReady || isInputFieldsLoading || isRunning
const handleDownloadTemplate = () => {
if (!inputFields.length) {
toast.warning(t('batch.noInputFields'))
return
}
const content = buildTemplateCsvContent(inputFields)
const link = document.createElement('a')
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
link.download = templateFileName
link.click()
}
const handleRun = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
if (isFileUploading) {
toast.warning(t('batch.uploading'))
return
}
if (!uploadedFileId) {
toast.warning(t('batch.fileRequired'))
return
}
const body = buildEvaluationRunRequest(resource, uploadedFileId)
if (!body) {
toast.warning(t('batch.validation'))
return
}
startRunMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: (run) => {
toast.success(t('batch.runStarted'))
setSelectedRunId(resourceType, resourceId, run.id)
setIsUploadPopoverOpen(false)
setBatchTab(resourceType, resourceId, 'history')
},
onError: () => {
toast.error(t('batch.runFailed'))
},
})
}
const handleUploadFile = (file: File | undefined) => {
if (!file) {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
return
}
setUploadedFileMeta({
name: file.name,
size: file.size,
})
setUploadedFileName(resourceType, resourceId, file.name)
uploadMutation.mutate(file)
}
const handleClearUploadedFile = () => {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
}
return {
canDownloadTemplate,
currentFileExtension: currentFileName ? getFileExtension(currentFileName) : '',
currentFileName,
currentFileSize: uploadedFileMeta ? formatFileSize(uploadedFileMeta.size) : '',
handleClearUploadedFile,
handleDownloadTemplate,
handleRun,
handleUploadFile,
isFileUploading,
isRunning,
isRunDisabled,
isUploadPopoverOpen,
setIsUploadPopoverOpen,
uploadButtonDisabled,
}
}

View File

@ -1,29 +0,0 @@
import type { EvaluationResourceType } from '../../../types'
import { useMemo } from 'react'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { getGraphNodes, getStartNodeInputFields } from './input-fields-utils'
export const usePublishedInputFields = (
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const { data: currentAppWorkflow, isLoading: isAppWorkflowLoading } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
const { data: currentSnippetWorkflow, isLoading: isSnippetWorkflowLoading } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
const inputFields = useMemo(() => {
if (resourceType === 'apps')
return getStartNodeInputFields(currentAppWorkflow?.graph.nodes)
if (resourceType === 'snippets')
return getStartNodeInputFields(getGraphNodes(currentSnippetWorkflow?.graph))
return []
}, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.graph, resourceType])
return {
inputFields,
isInputFieldsLoading: (resourceType === 'apps' && isAppWorkflowLoading)
|| (resourceType === 'snippets' && isSnippetWorkflowLoading),
}
}

View File

@ -1,75 +0,0 @@
'use client'
import type { ConditionMetricOptionGroup, EvaluationResourceProps } from '../../types'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectTrigger,
} from '@/app/components/base/ui/select'
import { cn } from '@/utils/classnames'
import { useEvaluationStore } from '../../store'
import { getConditionMetricValueTypeTranslationKey } from '../../utils'
type AddConditionSelectProps = EvaluationResourceProps & {
metricOptionGroups: ConditionMetricOptionGroup[]
disabled: boolean
}
const AddConditionSelect = ({
resourceType,
resourceId,
metricOptionGroups,
disabled,
}: AddConditionSelectProps) => {
const { t } = useTranslation('evaluation')
const addCondition = useEvaluationStore(state => state.addCondition)
const [selectKey, setSelectKey] = useState(0)
return (
<Select key={selectKey}>
<SelectTrigger
aria-label={t('conditions.addCondition')}
className={cn(
'inline-flex w-auto min-w-0 border-none bg-transparent px-0 py-0 text-text-accent shadow-none hover:bg-transparent focus-visible:bg-transparent',
disabled && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
)}
disabled={disabled}
>
<span aria-hidden="true" className="i-ri-add-line h-4 w-4" />
{t('conditions.addCondition')}
</SelectTrigger>
<SelectContent placement="bottom-start" popupClassName="w-[320px]">
{metricOptionGroups.map(group => (
<SelectGroup key={group.label}>
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectGroupLabel>
{group.options.map(option => (
<SelectItem
key={option.id}
value={option.id}
className="h-auto gap-0 px-3 py-2"
onClick={() => {
addCondition(resourceType, resourceId, option.variableSelector)
setSelectKey(current => current + 1)
}}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="truncate system-sm-medium text-text-secondary">{option.itemLabel}</span>
<span className="ml-auto shrink-0 system-xs-medium text-text-tertiary">
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)
}
export default AddConditionSelect

View File

@ -1,302 +0,0 @@
'use client'
import type {
ComparisonOperator,
ConditionMetricOption,
EvaluationResourceProps,
JudgmentConditionItem,
} from '../../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import { cn } from '@/utils/classnames'
import { getAllowedOperators, requiresConditionValue, useEvaluationResource, useEvaluationStore } from '../../store'
import {
buildConditionMetricOptions,
getComparisonOperatorLabel,
getConditionMetricValueTypeTranslationKey,
groupConditionMetricOptions,
isSelectorEqual,
serializeVariableSelector,
} from '../../utils'
type ConditionMetricLabelProps = {
metric?: ConditionMetricOption
placeholder: string
}
type ConditionMetricSelectProps = {
metric?: ConditionMetricOption
metricOptions: ConditionMetricOption[]
placeholder: string
onChange: (variableSelector: [string, string]) => void
}
type ConditionOperatorSelectProps = {
operator: ComparisonOperator
operators: ComparisonOperator[]
onChange: (operator: ComparisonOperator) => void
}
type ConditionValueInputProps = {
metric?: ConditionMetricOption
condition: JudgmentConditionItem
onChange: (value: string | string[] | boolean | null) => void
}
type ConditionGroupProps = EvaluationResourceProps
const getMetricValueTypeIconClassName = (valueType: ConditionMetricOption['valueType']) => {
if (valueType === 'number')
return 'i-ri-hashtag'
if (valueType === 'boolean')
return 'i-ri-checkbox-circle-line'
return 'i-ri-bar-chart-box-line'
}
const ConditionMetricLabel = ({
metric,
placeholder,
}: ConditionMetricLabelProps) => {
if (!metric)
return <span className="px-1 system-sm-regular text-components-input-text-placeholder">{placeholder}</span>
return (
<div className="flex min-w-0 items-center gap-2 px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-1 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] shadow-xs">
<span className={cn(getMetricValueTypeIconClassName(metric.valueType), 'h-3 w-3 shrink-0 text-text-secondary')} />
<span className="truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
</div>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.groupLabel}</span>
</div>
)
}
const ConditionMetricSelect = ({
metric,
metricOptions,
placeholder,
onChange,
}: ConditionMetricSelectProps) => {
const { t } = useTranslation('evaluation')
const groupedMetricOptions = useMemo(() => {
return groupConditionMetricOptions(metricOptions)
}, [metricOptions])
return (
<Select
value={serializeVariableSelector(metric?.variableSelector)}
onValueChange={(value) => {
const nextMetric = metricOptions.find(option => serializeVariableSelector(option.variableSelector) === value)
if (nextMetric)
onChange(nextMetric.variableSelector)
}}
>
<SelectTrigger className="h-auto bg-transparent px-1 py-1 hover:bg-transparent focus-visible:bg-transparent">
<ConditionMetricLabel metric={metric} placeholder={placeholder} />
</SelectTrigger>
<SelectContent popupClassName="w-[360px]">
{groupedMetricOptions.map(group => (
<SelectGroup key={group.label}>
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectGroupLabel>
{group.options.map(option => (
<SelectItem key={option.id} value={serializeVariableSelector(option.variableSelector)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn(getMetricValueTypeIconClassName(option.valueType), 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
<span className="truncate">{option.itemLabel}</span>
<span className="ml-auto shrink-0 system-xs-medium text-text-quaternary">
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)
}
const ConditionOperatorSelect = ({
operator,
operators,
onChange,
}: ConditionOperatorSelectProps) => {
const { t } = useTranslation()
return (
<Select value={operator} onValueChange={value => value && onChange(value as ComparisonOperator)}>
<SelectTrigger className="h-8 w-auto min-w-[88px] gap-1 rounded-md bg-transparent px-1.5 py-0 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<span className="truncate system-xs-medium text-text-secondary">{getComparisonOperatorLabel(operator, t)}</span>
</SelectTrigger>
<SelectContent className="z-[1002]" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
{operators.map(nextOperator => (
<SelectItem key={nextOperator} value={nextOperator}>
{getComparisonOperatorLabel(nextOperator, t)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const ConditionValueInput = ({
metric,
condition,
onChange,
}: ConditionValueInputProps) => {
const { t } = useTranslation('evaluation')
if (!metric || !requiresConditionValue(condition.comparisonOperator))
return null
if (metric.valueType === 'boolean') {
return (
<div className="px-2 py-1.5">
<Select value={condition.value === null ? '' : String(condition.value)} onValueChange={nextValue => onChange(nextValue === 'true')}>
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<SelectValue placeholder={t('conditions.selectValue')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">{t('conditions.boolean.true')}</SelectItem>
<SelectItem value="false">{t('conditions.boolean.false')}</SelectItem>
</SelectContent>
</Select>
</div>
)
}
const isMultiValue = condition.comparisonOperator === 'in' || condition.comparisonOperator === 'not in'
const inputValue = Array.isArray(condition.value)
? condition.value.join(', ')
: typeof condition.value === 'boolean'
? ''
: condition.value ?? ''
return (
<div className="px-2 py-1.5">
<Input
type={metric.valueType === 'number' && !isMultiValue ? 'number' : 'text'}
value={inputValue}
className="border-none bg-transparent shadow-none hover:border-none hover:bg-state-base-hover-alt focus:border-none focus:bg-state-base-hover-alt focus:shadow-none"
placeholder={t('conditions.valuePlaceholder')}
onChange={(e) => {
if (isMultiValue) {
onChange(e.target.value.split(',').map(item => item.trim()).filter(Boolean))
return
}
onChange(e.target.value === '' ? null : e.target.value)
}}
/>
</div>
)
}
const ConditionGroup = ({
resourceType,
resourceId,
}: ConditionGroupProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const metricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const logicalLabels = {
and: t('conditions.logical.and'),
or: t('conditions.logical.or'),
}
const setConditionLogicalOperator = useEvaluationStore(state => state.setConditionLogicalOperator)
const removeCondition = useEvaluationStore(state => state.removeCondition)
const updateConditionMetric = useEvaluationStore(state => state.updateConditionMetric)
const updateConditionOperator = useEvaluationStore(state => state.updateConditionOperator)
const updateConditionValue = useEvaluationStore(state => state.updateConditionValue)
return (
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-divider-subtle bg-background-default-subtle p-1">
{(['and', 'or'] as const).map(operator => (
<button
key={operator}
type="button"
className={cn(
'rounded-md px-3 py-1.5 system-xs-medium-uppercase',
resource.judgmentConfig.logicalOperator === operator
? 'bg-components-card-bg text-text-primary shadow-xs'
: 'text-text-tertiary',
)}
onClick={() => setConditionLogicalOperator(resourceType, resourceId, operator)}
>
{logicalLabels[operator]}
</button>
))}
</div>
</div>
</div>
<div className="space-y-3">
{resource.judgmentConfig.conditions.map((condition) => {
const metric = metricOptions.find(option => isSelectorEqual(option.variableSelector, condition.variableSelector))
const allowedOperators = getAllowedOperators(resource.metrics, condition.variableSelector)
const showValue = !!metric && requiresConditionValue(condition.comparisonOperator)
return (
<div key={condition.id} className="flex items-start overflow-hidden rounded-lg">
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
<div className="flex items-center gap-0 pr-1">
<div className="min-w-0 flex-1 py-1">
<ConditionMetricSelect
metric={metric}
metricOptions={metricOptions}
placeholder={t('conditions.fieldPlaceholder')}
onChange={value => updateConditionMetric(resourceType, resourceId, condition.id, value)}
/>
</div>
<div className="h-3 w-px bg-divider-regular" />
<ConditionOperatorSelect
operator={condition.comparisonOperator}
operators={allowedOperators}
onChange={value => updateConditionOperator(resourceType, resourceId, condition.id, value)}
/>
</div>
{showValue && (
<div className="border-t border-divider-subtle">
<ConditionValueInput
metric={metric}
condition={condition}
onChange={value => updateConditionValue(resourceType, resourceId, condition.id, value)}
/>
</div>
)}
</div>
<div className="pt-1 pl-1">
<Button
size="small"
variant="ghost"
aria-label={t('conditions.removeCondition')}
onClick={() => removeCondition(resourceType, resourceId, condition.id)}
>
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}
export default ConditionGroup

View File

@ -1,51 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useEvaluationResource } from '../../store'
import { buildConditionMetricOptions, groupConditionMetricOptions } from '../../utils'
import { InlineSectionHeader } from '../section-header'
import AddConditionSelect from './add-condition-select'
import ConditionGroup from './condition-group'
const ConditionsSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const conditionMetricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const groupedConditionMetricOptions = useMemo(() => groupConditionMetricOptions(conditionMetricOptions), [conditionMetricOptions])
const canAddCondition = conditionMetricOptions.length > 0
return (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('conditions.title')}
tooltip={t('conditions.description')}
/>
<div className="mt-2 space-y-4">
{resource.judgmentConfig.conditions.length === 0 && (
<div className="rounded-xl bg-background-section px-3 py-3 system-xs-regular text-text-tertiary">
{t('conditions.emptyDescription')}
</div>
)}
{resource.judgmentConfig.conditions.length > 0 && (
<ConditionGroup
resourceType={resourceType}
resourceId={resourceId}
/>
)}
<AddConditionSelect
resourceType={resourceType}
resourceId={resourceId}
metricOptionGroups={groupedConditionMetricOptions}
disabled={!canAddCondition}
/>
</div>
</section>
)
}
export default ConditionsSection

View File

@ -1,364 +0,0 @@
import type { EvaluationMetric } from '../../../types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Node } from '@/app/components/workflow/types'
import type { SnippetWorkflow } from '@/types/snippet'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import CustomMetricEditorCard from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAppWorkflow = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
const mockPublishedGraphVariablePicker = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (...args: unknown[]) => mockUseAppWorkflow(...args),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
}))
vi.mock('ahooks', () => ({
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}))
vi.mock('../published-graph-variable-picker', () => ({
default: (props: Record<string, unknown>) => {
mockPublishedGraphVariablePicker(props)
return <div data-testid="published-graph-variable-picker" />
},
}))
const createStartNode = (): Node<StartNodeType> => ({
id: 'start-node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Start',
desc: '',
variables: [
{
variable: 'user_question',
label: 'User Question',
type: InputVarType.textInput,
required: true,
},
{
variable: 'retrieved_context',
label: 'Retrieved Context',
type: InputVarType.textInput,
required: true,
},
],
},
})
const createEndNode = (
outputs: EndNodeType['outputs'],
): Node<EndNodeType> => ({
id: 'end-node',
type: 'custom',
position: { x: 100, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
outputs,
},
})
const createCodeNode = (
id: string,
title: string,
outputs: Record<string, { type: VarType }>,
): Node<CodeNodeType> => ({
id,
type: 'custom',
position: { x: 100, y: 0 },
data: {
type: BlockEnum.Code,
title,
desc: '',
code: '',
code_language: CodeLanguage.python3,
outputs: Object.fromEntries(
Object.entries(outputs).map(([key, value]) => [
key,
{
type: value.type,
children: null,
},
]),
),
variables: [],
},
})
const createWorkflow = (
nodes: Node[],
): FetchWorkflowDraftResponse => ({
id: 'workflow-1',
graph: {
nodes,
edges: [],
},
features: {},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'User One',
email: 'user-one@example.com',
},
hash: 'hash-1',
updated_at: 1710000001,
updated_by: {
id: 'user-2',
name: 'User Two',
email: 'user-two@example.com',
},
tool_published: true,
environment_variables: [],
conversation_variables: [],
version: '1',
marked_name: 'Evaluation Workflow',
marked_comment: 'Published',
})
const createSnippetWorkflow = (
nodes: Node[],
): SnippetWorkflow => ({
id: 'snippet-workflow-1',
graph: {
nodes,
edges: [],
},
features: {},
hash: 'snippet-hash-1',
created_at: 1710000000,
updated_at: 1710000001,
})
const createMetric = (): EvaluationMetric => ({
id: 'metric-1',
optionId: 'custom-1',
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
valueType: 'number',
customConfig: {
workflowId: 'workflow-1',
workflowAppId: 'workflow-app-1',
workflowName: 'Evaluation Workflow',
mappings: [{
id: 'mapping-1',
inputVariableId: 'user_question',
outputVariableId: 'current-node.answer',
}, {
id: 'mapping-2',
inputVariableId: 'retrieved_context',
outputVariableId: 'current-node.score',
}],
outputs: [],
},
})
describe('CustomMetricEditorCard', () => {
beforeEach(() => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockPublishedGraphVariablePicker.mockReset()
mockUseInfiniteScroll.mockImplementation(() => undefined)
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{
items: [],
page: 1,
limit: 20,
has_more: false,
}],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
})
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
})
// Verify the selected evaluation workflow still drives the output summary section.
describe('Outputs', () => {
it('should render the selected workflow outputs from the end node', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('evaluation.metrics.custom.outputTitle')).toBeInTheDocument()
expect(screen.getAllByText('answer_score').length).toBeGreaterThan(0)
expect(screen.getAllByText('number').length).toBeGreaterThan(0)
expect(screen.getAllByText('reason').length).toBeGreaterThan(0)
expect(screen.getAllByText('string').length).toBeGreaterThan(0)
})
it('should hide the output section when the selected workflow has no end outputs', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument()
})
})
// Verify mapping rows use workflow start variables on the left and current published graph variables on the right.
describe('Variable Mapping', () => {
it('should pass the current app published graph and saved selector values to the picker', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createStartNode(),
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('user_question')).toBeInTheDocument()
expect(screen.getByText('retrieved_context')).toBeInTheDocument()
expect(screen.getAllByText('string')).toHaveLength(3)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.answer',
})
expect(mockPublishedGraphVariablePicker.mock.calls[1][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.score',
})
})
it('should use the current snippet published graph when editing a snippet evaluation', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentSnippetWorkflow = createSnippetWorkflow([
createCodeNode('snippet-node', 'Snippet Node', {
result: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
return { data: undefined }
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: currentSnippetWorkflow,
})
render(
<CustomMetricEditorCard
resourceType="snippets"
resourceId="snippet-under-test"
metric={createMetric()}
/>,
)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentSnippetWorkflow.graph.nodes,
edges: currentSnippetWorkflow.graph.edges,
})
})
})
})

View File

@ -1,158 +0,0 @@
import type { ComponentProps } from 'react'
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import WorkflowSelector from '../workflow-selector'
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
let loadMoreHandler: (() => Promise<{ list: unknown[] }>) | null = null
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
}))
vi.mock('ahooks', () => ({
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}))
const createWorkflow = (
overrides: Partial<AvailableEvaluationWorkflow> = {},
): AvailableEvaluationWorkflow => ({
id: 'workflow-1',
app_id: 'app-1',
app_name: 'Review Workflow App',
type: 'evaluation',
version: '1',
marked_name: 'Review Workflow',
marked_comment: 'Production release',
hash: 'hash-1',
created_by: {
id: 'user-1',
name: 'User One',
email: 'user-one@example.com',
},
created_at: 1710000000,
updated_by: null,
updated_at: 1710000000,
...overrides,
})
const setupWorkflowQueryMock = (overrides?: {
workflows?: AvailableEvaluationWorkflow[]
hasNextPage?: boolean
isFetchingNextPage?: boolean
}) => {
const fetchNextPage = vi.fn()
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{
items: overrides?.workflows ?? [createWorkflow()],
page: 1,
limit: 20,
has_more: overrides?.hasNextPage ?? false,
}],
},
fetchNextPage,
hasNextPage: overrides?.hasNextPage ?? false,
isFetching: false,
isFetchingNextPage: overrides?.isFetchingNextPage ?? false,
isLoading: false,
})
return { fetchNextPage }
}
const renderWorkflowSelector = (props?: Partial<ComponentProps<typeof WorkflowSelector>>) => {
return render(
<WorkflowSelector
value={null}
onSelect={vi.fn()}
{...props}
/>,
)
}
describe('WorkflowSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
loadMoreHandler = null
setupWorkflowQueryMock()
mockUseInfiniteScroll.mockImplementation((handler) => {
loadMoreHandler = handler as () => Promise<{ list: unknown[] }>
})
})
// Cover trigger rendering and selected label fallback.
describe('Rendering', () => {
it('should render the workflow placeholder when value is empty', () => {
renderWorkflowSelector()
expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' })).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
})
it('should render the selected workflow name from props when value is set', () => {
setupWorkflowQueryMock({ workflows: [] })
renderWorkflowSelector({
value: 'workflow-1',
selectedWorkflowName: 'Saved Review Workflow',
})
expect(screen.getByText('Saved Review Workflow')).toBeInTheDocument()
})
})
// Cover opening the popover and choosing one workflow option.
describe('Interactions', () => {
it('should call onSelect with the clicked workflow', async () => {
const onSelect = vi.fn()
renderWorkflowSelector({ onSelect })
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' }))
const option = await screen.findByRole('option', { name: 'Review Workflow' })
fireEvent.click(option)
expect(onSelect).toHaveBeenCalledWith(createWorkflow())
})
})
// Cover the infinite-scroll callback used by the ScrollArea viewport.
describe('Pagination', () => {
it('should fetch the next page when the load-more callback runs and more pages exist', async () => {
const { fetchNextPage } = setupWorkflowQueryMock({ hasNextPage: true })
renderWorkflowSelector()
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
await act(async () => {
await loadMoreHandler?.()
})
expect(fetchNextPage).toHaveBeenCalledTimes(1)
})
it('should not fetch the next page when the current request is already fetching', async () => {
const { fetchNextPage } = setupWorkflowQueryMock({
hasNextPage: true,
isFetchingNextPage: true,
})
renderWorkflowSelector()
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
await act(async () => {
await loadMoreHandler?.()
})
expect(fetchNextPage).not.toHaveBeenCalled()
})
})
})

View File

@ -1,217 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Edge, InputVar, Node } from '@/app/components/workflow/types'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import MappingRow from './mapping-row'
import WorkflowSelector from './workflow-selector'
type CustomMetricEditorCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
const getWorkflowInputVariables = (
nodes?: Array<Node>,
) => {
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
if (!startNode || !Array.isArray(startNode.data.variables))
return []
return startNode.data.variables.map((variable: InputVar) => ({
id: variable.variable,
valueType: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
}))
}
const getWorkflowOutputs = (nodes?: Array<Node>) => {
return (nodes ?? [])
.filter(node => node.data.type === BlockEnum.End)
.flatMap((node) => {
const endNode = node as Node<EndNodeType>
if (!Array.isArray(endNode.data.outputs))
return []
return endNode.data.outputs
.filter(output => typeof output.variable === 'string' && !!output.variable)
.map(output => ({
id: output.variable,
valueType: typeof output.value_type === 'string' ? output.value_type : null,
nodeTitle: typeof endNode.data.title === 'string' && endNode.data.title ? endNode.data.title : 'End',
}))
})
}
const getWorkflowName = (workflow: {
marked_name?: string
app_name?: string
id: string
}) => {
return workflow.marked_name || workflow.app_name || workflow.id
}
const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
const getGraphEdges = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.edges) ? graph.edges as Edge[] : []
}
const CustomMetricEditorCard = ({
resourceType,
resourceId,
metric,
}: CustomMetricEditorCardProps) => {
const { t } = useTranslation('evaluation')
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
const syncCustomMetricMappings = useEvaluationStore(state => state.syncCustomMetricMappings)
const syncCustomMetricOutputs = useEvaluationStore(state => state.syncCustomMetricOutputs)
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
const { data: selectedWorkflow } = useAppWorkflow(metric.customConfig?.workflowAppId ?? '')
const { data: currentAppWorkflow } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
const { data: currentSnippetWorkflow } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
const inputVariables = useMemo(() => {
return getWorkflowInputVariables(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const workflowOutputs = useMemo(() => {
return getWorkflowOutputs(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const publishedGraph = useMemo(() => {
if (resourceType === 'apps') {
return {
nodes: currentAppWorkflow?.graph.nodes ?? [],
edges: currentAppWorkflow?.graph.edges ?? [],
environmentVariables: currentAppWorkflow?.environment_variables ?? [],
conversationVariables: currentAppWorkflow?.conversation_variables ?? [],
}
}
return {
nodes: getGraphNodes(currentSnippetWorkflow?.graph),
edges: getGraphEdges(currentSnippetWorkflow?.graph),
environmentVariables: [],
conversationVariables: [],
}
}, [
currentAppWorkflow?.conversation_variables,
currentAppWorkflow?.environment_variables,
currentAppWorkflow?.graph.edges,
currentAppWorkflow?.graph.nodes,
currentSnippetWorkflow?.graph,
resourceType,
])
const inputVariableIds = useMemo(() => inputVariables.map(variable => variable.id), [inputVariables])
const isConfigured = isCustomMetricConfigured(metric)
useEffect(() => {
if (!metric.customConfig?.workflowId)
return
const currentInputVariableIds = metric.customConfig.mappings
.map(mapping => mapping.inputVariableId)
.filter((value): value is string => !!value)
if (currentInputVariableIds.length === inputVariableIds.length
&& currentInputVariableIds.every((value, index) => value === inputVariableIds[index])) {
return
}
syncCustomMetricMappings(resourceType, resourceId, metric.id, inputVariableIds)
}, [inputVariableIds, metric.customConfig?.mappings, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricMappings])
useEffect(() => {
if (!metric.customConfig?.workflowId)
return
const currentOutputs = metric.customConfig.outputs
if (
currentOutputs.length === workflowOutputs.length
&& currentOutputs.every((output, index) =>
output.id === workflowOutputs[index]?.id && output.valueType === workflowOutputs[index]?.valueType,
)
) {
return
}
syncCustomMetricOutputs(resourceType, resourceId, metric.id, workflowOutputs)
}, [metric.customConfig?.outputs, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricOutputs, workflowOutputs])
if (!metric.customConfig)
return null
return (
<div className="px-3 pt-1 pb-3">
<WorkflowSelector
value={metric.customConfig.workflowId}
selectedWorkflowName={metric.customConfig.workflowName ?? (selectedWorkflow ? getWorkflowName(selectedWorkflow) : null)}
onSelect={workflow => setCustomMetricWorkflow(resourceType, resourceId, metric.id, {
workflowId: workflow.id,
workflowAppId: workflow.app_id,
workflowName: getWorkflowName(workflow),
})}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="system-xs-medium-uppercase text-text-secondary">{t('metrics.custom.mappingTitle')}</div>
</div>
<div className="space-y-2">
{inputVariables.map((inputVariable) => {
const mapping = metric.customConfig?.mappings.find(item => item.inputVariableId === inputVariable.id)
return (
<MappingRow
key={inputVariable.id}
inputVariable={inputVariable}
publishedGraph={publishedGraph}
value={mapping?.outputVariableId ?? null}
onUpdate={(outputVariableId) => {
if (!mapping)
return
updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, { outputVariableId })
}}
/>
)
})}
</div>
{!isConfigured && (
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
{t('metrics.custom.mappingWarning')}
</div>
)}
</div>
{!!workflowOutputs.length && (
<div className="mt-4 py-1">
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
{t('metrics.custom.outputTitle')}
</div>
<div className="flex flex-wrap items-center gap-y-1 px-2 py-2 system-xs-regular text-text-tertiary">
{workflowOutputs.map((output, index) => (
<div key={`${output.nodeTitle}-${output.id}-${index}`} className="flex items-center">
<span className="px-1 system-xs-medium text-text-secondary">{output.id}</span>
{output.valueType && (
<span>{output.valueType}</span>
)}
{index < workflowOutputs.length - 1 && (
<span className="pl-0.5">,</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
export default CustomMetricEditorCard

View File

@ -1,64 +0,0 @@
'use client'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
} from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import PublishedGraphVariablePicker from './published-graph-variable-picker'
type MappingRowProps = {
inputVariable: {
id: string
valueType: string
}
publishedGraph: {
nodes: Node[]
edges: Edge[]
environmentVariables: EnvironmentVariable[]
conversationVariables: ConversationVariable[]
}
value: string | null
onUpdate: (outputVariableId: string | null) => void
}
const MappingRow = ({
inputVariable,
publishedGraph,
value,
onUpdate,
}: MappingRowProps) => {
const { t } = useTranslation('evaluation')
return (
<div className="flex items-center">
<div className="flex h-8 w-[200px] items-center rounded-md px-2">
<div className="flex min-w-0 items-center gap-0.5 px-1">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="truncate system-xs-medium text-text-secondary">{inputVariable.id}</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{inputVariable.valueType}</div>
</div>
</div>
<div className="flex h-8 w-9 items-center justify-center px-3 system-xs-medium text-text-tertiary">
<span aria-hidden="true"></span>
</div>
<PublishedGraphVariablePicker
className="grow"
nodes={publishedGraph.nodes}
edges={publishedGraph.edges}
environmentVariables={publishedGraph.environmentVariables}
conversationVariables={publishedGraph.conversationVariables}
value={value}
placeholder={t('metrics.custom.outputPlaceholder')}
onChange={onUpdate}
/>
</div>
)
}
export default MappingRow

View File

@ -1,118 +0,0 @@
'use client'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
ValueSelector,
} from '@/app/components/workflow/types'
import { useMemo } from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createHooksStore, HooksStoreContext } from '@/app/components/workflow/hooks-store'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils/variable'
type PublishedGraphVariablePickerProps = {
className?: string
nodes: Node[]
edges: Edge[]
environmentVariables?: EnvironmentVariable[]
conversationVariables?: ConversationVariable[]
placeholder: string
value: string | null
onChange: (value: string | null) => void
}
const PICKER_NODE_ID = '__evaluation-variable-picker__'
const createPickerNode = (): Node<EndNodeType> => ({
id: PICKER_NODE_ID,
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
outputs: [],
},
})
const PublishedGraphVariablePicker = ({
className,
nodes,
edges,
environmentVariables = [],
conversationVariables = [],
placeholder,
value,
onChange,
}: PublishedGraphVariablePickerProps) => {
const workflowStore = useMemo(() => {
const store = createWorkflowStore({})
store.setState({
isWorkflowDataLoaded: true,
environmentVariables,
conversationVariables,
ragPipelineVariables: [],
dataSourceList: [],
})
return store
}, [conversationVariables, environmentVariables])
const hooksStore = useMemo(() => createHooksStore({}), [])
const pickerNodes = useMemo(() => {
return [...nodes, createPickerNode()]
}, [nodes])
const pickerValue = useMemo<ValueSelector>(() => {
if (!value)
return []
return variableTransformer(value) as ValueSelector
}, [value])
return (
<WorkflowContext.Provider value={workflowStore}>
<HooksStoreContext.Provider value={hooksStore}>
<div id="workflow-container" className={className}>
<ReactFlowProvider>
<div
aria-hidden="true"
className="pointer-events-none absolute h-px w-px overflow-hidden opacity-0"
>
<div style={{ width: 800, height: 600 }}>
<ReactFlow nodes={pickerNodes} edges={edges} fitView />
</div>
</div>
<VarReferencePicker
className="grow"
nodeId={PICKER_NODE_ID}
readonly={!nodes.length}
isShowNodeName
value={pickerValue}
onChange={(nextValue) => {
if (!Array.isArray(nextValue) || !nextValue.length) {
onChange(null)
return
}
onChange(nextValue.join('.'))
}}
availableNodes={nodes}
placeholder={placeholder}
/>
</ReactFlowProvider>
</div>
</HooksStoreContext.Provider>
</WorkflowContext.Provider>
)
}
export default PublishedGraphVariablePicker

View File

@ -1,213 +0,0 @@
'use client'
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
import { useInfiniteScroll } from 'ahooks'
import * as React from 'react'
import { useDeferredValue, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@/app/components/base/ui/scroll-area'
import { useAvailableEvaluationWorkflows } from '@/service/use-evaluation'
import { cn } from '@/utils/classnames'
type WorkflowSelectorProps = {
value: string | null
selectedWorkflowName?: string | null
onSelect: (workflow: AvailableEvaluationWorkflow) => void
}
const PAGE_SIZE = 20
const getWorkflowName = (workflow: AvailableEvaluationWorkflow) => {
return workflow.marked_name || workflow.app_name || workflow.id
}
const WorkflowSelector = ({
value,
selectedWorkflowName,
onSelect,
}: WorkflowSelectorProps) => {
const { t } = useTranslation('evaluation')
const [isOpen, setIsOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const deferredSearchText = useDeferredValue(searchText)
const viewportRef = useRef<HTMLDivElement>(null)
const keyword = deferredSearchText.trim() || undefined
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
} = useAvailableEvaluationWorkflows(
{
page: 1,
limit: PAGE_SIZE,
keyword,
},
{ enabled: isOpen },
)
const workflows = useMemo(() => {
return (data?.pages ?? []).flatMap(page => page.items)
}, [data?.pages])
const currentWorkflowName = useMemo(() => {
if (!value)
return null
const selectedWorkflow = workflows.find(workflow => workflow.id === value)
if (selectedWorkflow)
return getWorkflowName(selectedWorkflow)
return selectedWorkflowName ?? null
}, [selectedWorkflowName, value, workflows])
const isNoMore = hasNextPage === false
useInfiniteScroll(
async () => {
if (!hasNextPage || isFetchingNextPage)
return { list: [] }
await fetchNextPage()
return { list: [] }
},
{
target: viewportRef,
isNoMore: () => isNoMore,
reloadDeps: [isFetchingNextPage, isNoMore, keyword],
},
)
const handleOpenChange = (nextOpen: boolean) => {
setIsOpen(nextOpen)
if (!nextOpen)
setSearchText('')
}
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<button
type="button"
className="group flex w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 text-left outline-hidden hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"
aria-label={t('metrics.custom.workflowLabel')}
>
<div className="flex min-w-0 grow items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
<div className="min-w-0 flex-1 px-1 py-1 text-left">
<div className={cn(
'truncate system-sm-regular',
currentWorkflowName ? 'text-text-secondary' : 'text-components-input-text-placeholder',
)}>
{currentWorkflowName ?? t('metrics.custom.workflowPlaceholder')}
</div>
</div>
</div>
<span className="shrink-0 px-1 text-text-quaternary transition-colors group-hover:text-text-secondary">
<span aria-hidden="true" className="i-ri-arrow-down-s-line h-4 w-4" />
</span>
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[360px] overflow-hidden p-0"
>
<div className="bg-components-panel-bg">
<div className="p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={searchText}
onChange={event => setSearchText(event.target.value)}
onClear={() => setSearchText('')}
/>
</div>
{(isLoading || (isFetching && workflows.length === 0))
? (
<div className="flex h-[120px] items-center justify-center">
<Loading type="area" />
</div>
)
: !workflows.length
? (
<div className="flex h-[120px] items-center justify-center text-text-tertiary system-sm-regular">
{t('noData', { ns: 'common' })}
</div>
)
: (
<ScrollAreaRoot className="relative max-h-[240px] overflow-hidden">
<ScrollAreaViewport ref={viewportRef}>
<ScrollAreaContent className="p-1" role="listbox" aria-label={t('metrics.custom.workflowLabel')}>
{workflows.map(workflow => (
<button
key={workflow.id}
type="button"
role="option"
aria-selected={workflow.id === value}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left hover:bg-state-base-hover"
onClick={() => {
onSelect(workflow)
setIsOpen(false)
setSearchText('')
}}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
<div className="min-w-0 flex-1 truncate px-1 py-1 text-text-secondary system-sm-medium">
{getWorkflowName(workflow)}
</div>
{workflow.id === value && (
<span aria-hidden="true" className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</button>
))}
{isFetchingNextPage && (
<div className="flex justify-center px-3 py-2">
<Loading />
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)}
</div>
</PopoverContent>
</Popover>
)
}
export default React.memo(WorkflowSelector)

View File

@ -1,48 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { useEffect } from 'react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useEvaluationResource, useEvaluationStore } from '../store'
import { decodeModelSelection, encodeModelSelection } from '../utils'
type JudgeModelSelectorProps = EvaluationResourceProps & {
autoSelectFirst?: boolean
}
const JudgeModelSelector = ({
resourceType,
resourceId,
autoSelectFirst = true,
}: JudgeModelSelectorProps) => {
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
const resource = useEvaluationResource(resourceType, resourceId)
const setJudgeModel = useEvaluationStore(state => state.setJudgeModel)
const selectedModel = decodeModelSelection(resource.judgeModelId)
useEffect(() => {
if (!autoSelectFirst || resource.judgeModelId || !modelList.length)
return
const firstProvider = modelList[0]
const firstModel = firstProvider.models[0]
if (!firstProvider || !firstModel)
return
setJudgeModel(resourceType, resourceId, encodeModelSelection(firstProvider.provider, firstModel.model))
}, [autoSelectFirst, modelList, resource.judgeModelId, resourceId, resourceType, setJudgeModel])
return (
<ModelSelector
defaultModel={selectedModel}
modelList={modelList}
onSelect={model => setJudgeModel(resourceType, resourceId, encodeModelSelection(model.provider, model.model))}
showDeprecatedWarnIcon
triggerClassName="h-8 w-full rounded-lg"
/>
)
}
export default JudgeModelSelector

View File

@ -1,62 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import BatchTestPanel from '../batch-test-panel'
import ConditionsSection from '../conditions-section'
import JudgeModelSelector from '../judge-model-selector'
import MetricSection from '../metric-section'
import SectionHeader, { InlineSectionHeader } from '../section-header'
const NonPipelineEvaluation = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
return (
<div className="flex h-full min-h-0 flex-col bg-background-default xl:flex-row">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex min-h-full max-w-[748px] flex-col px-6 py-4">
<SectionHeader
title={t('title')}
description={(
<>
{t('description')}
{' '}
<a
className="text-text-accent"
href={docLink()}
target="_blank"
rel="noopener noreferrer"
>
{tCommon('operation.learnMore')}
</a>
</>
)}
descriptionClassName="max-w-[700px]"
/>
<section className="max-w-[700px] py-4">
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />
<div className="mt-1.5">
<JudgeModelSelector resourceType={resourceType} resourceId={resourceId} />
</div>
</section>
<div className="max-w-[700px] border-b border-divider-subtle" />
<MetricSection resourceType={resourceType} resourceId={resourceId} />
<div className="max-w-[700px] border-b border-divider-subtle" />
<ConditionsSection resourceType={resourceType} resourceId={resourceId} />
</div>
</div>
<div className="h-[420px] shrink-0 border-t border-divider-subtle xl:h-auto xl:w-[450px] xl:border-t-0 xl:border-l">
<BatchTestPanel resourceType={resourceType} resourceId={resourceId} />
</div>
</div>
)
}
export default NonPipelineEvaluation

View File

@ -1,96 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { useEvaluationStore } from '../../store'
import HistoryTab from '../batch-test-panel/history-tab'
import JudgeModelSelector from '../judge-model-selector'
import PipelineBatchActions from '../pipeline/pipeline-batch-actions'
import PipelineMetricsSection from '../pipeline/pipeline-metrics-section'
import PipelineResultsPanel from '../pipeline/pipeline-results-panel'
import SectionHeader, { InlineSectionHeader } from '../section-header'
const PipelineEvaluation = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
const ensureResource = useEvaluationStore(state => state.ensureResource)
useEffect(() => {
ensureResource(resourceType, resourceId)
}, [ensureResource, resourceId, resourceType])
return (
<div className="flex h-full min-h-0 flex-col bg-background-default xl:flex-row">
<div className="flex min-h-0 flex-col border-b border-divider-subtle bg-background-default xl:w-[450px] xl:shrink-0 xl:border-r xl:border-b-0">
<div className="px-6 pt-4 pb-2">
<SectionHeader
title={t('title')}
description={(
<>
{t('description')}
{' '}
<a
className="text-text-accent"
href={docLink()}
target="_blank"
rel="noopener noreferrer"
>
{tCommon('operation.learnMore')}
</a>
</>
)}
/>
</div>
<div className="px-6 pt-3 pb-4">
<div className="space-y-3">
<section>
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />
<div className="mt-1">
<JudgeModelSelector
resourceType={resourceType}
resourceId={resourceId}
autoSelectFirst={false}
/>
</div>
</section>
<PipelineMetricsSection
resourceType={resourceType}
resourceId={resourceId}
/>
<PipelineBatchActions
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
<div className="border-t border-divider-subtle" />
<div className="min-h-0 flex-1 px-6 py-4">
<HistoryTab
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
<div className="min-h-0 flex-1 bg-background-default">
<PipelineResultsPanel
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
)
}
export default PipelineEvaluation

View File

@ -1,229 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
import MetricSection from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args),
}))
const resourceType = 'apps' as const
const resourceId = 'metric-section-resource'
const renderMetricSection = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<MetricSection resourceType={resourceType} resourceId={resourceId} />
</QueryClientProvider>,
)
}
describe('MetricSection', () => {
beforeEach(() => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockUseAvailableEvaluationMetrics.mockReturnValue({
data: {
metrics: ['answer-correctness'],
},
isLoading: false,
})
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{ items: [], page: 1, limit: 20, has_more: false }],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
})
},
})
})
// Verify the empty state block extracted from MetricSection.
describe('Empty State', () => {
it('should render the metric empty state when no metrics are selected', () => {
renderMetricSection()
expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.add' })).toBeInTheDocument()
})
})
// Verify the extracted builtin metric card presentation and removal flow.
describe('Builtin Metric Card', () => {
it('should render node badges for a builtin metric and remove it when delete is clicked', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('Answer Correctness')).toBeInTheDocument()
expect(screen.getByText('Answer Node')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.remove' }))
expect(screen.queryByText('Answer Correctness')).not.toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument()
})
it('should render the all-nodes label when a builtin metric has no node selection', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
})
it('should collapse and expand the node section when the metric header is clicked', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
const toggleButton = screen.getByRole('button', { name: 'evaluation.metrics.collapseNodes' })
fireEvent.click(toggleButton)
// Assert
expect(screen.queryByText('Answer Node')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' }))
expect(screen.getByText('Answer Node')).toBeInTheDocument()
})
it('should show only unselected nodes in the add-node dropdown and append the selected node', () => {
// Arrange
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
})
},
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
])
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.addNode' }))
// Assert
expect(screen.queryByRole('menuitem', { name: 'LLM 1' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'LLM 2' }))
expect(screen.getByText('LLM 2')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
it('should hide the add-node button when the builtin metric already targets all nodes', () => {
// Arrange
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
})
},
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
})
// Verify the extracted custom metric editor card renders inside the metric card.
describe('Custom Metric Card', () => {
it('should render the custom metric editor card when a custom metric is added', () => {
act(() => {
useEvaluationStore.getState().addCustomMetric(resourceType, resourceId)
})
renderMetricSection()
expect(screen.getByText('Custom Evaluator')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.warningBadge')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.mappingTitle')).toBeInTheDocument()
})
it('should disable adding another custom metric when one already exists', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addCustomMetric(resourceType, resourceId)
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
// Assert
expect(screen.getByRole('button', { name: /evaluation.metrics.custom.footerTitle/i })).toBeDisabled()
expect(screen.getByText('evaluation.metrics.custom.limitDescription')).toBeInTheDocument()
})
})
})

View File

@ -1,161 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
import { useEvaluationStore } from '../../store'
import { dedupeNodeInfoList, getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils'
type BuiltinMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const BuiltinMetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList = [],
}: BuiltinMetricCardProps) => {
const { t } = useTranslation('evaluation')
const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const [isExpanded, setIsExpanded] = useState(true)
const metricVisual = getMetricVisual(metric.optionId)
const metricToneClasses = getToneClasses(metricVisual.tone)
const selectedNodeInfoList = metric.nodeInfoList ?? []
const selectedNodeIdSet = new Set(selectedNodeInfoList.map(nodeInfo => nodeInfo.node_id))
const selectableNodeInfoList = selectedNodeInfoList.length > 0
? availableNodeInfoList.filter(nodeInfo => !selectedNodeIdSet.has(nodeInfo.node_id))
: []
const shouldShowAddNode = selectableNodeInfoList.length > 0
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className="flex items-center justify-between gap-3 px-3 pb-1 pt-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left"
aria-expanded={isExpanded}
aria-label={isExpanded ? t('metrics.collapseNodes') : t('metrics.expandNodes')}
onClick={() => setIsExpanded(current => !current)}
>
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
</div>
<div className="flex min-w-0 items-center gap-0.5">
<div className="truncate text-text-secondary system-md-medium">{metric.label}</div>
<span
aria-hidden="true"
className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
/>
</div>
</button>
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity hover:text-text-secondary focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
{isExpanded && (
<div className="flex flex-wrap gap-1 px-3 pb-3 pt-1">
{selectedNodeInfoList.length
? selectedNodeInfoList.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
return (
<div
key={nodeInfo.node_id}
className="inline-flex min-w-[18px] items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1.5 shadow-xs"
>
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<span className="px-1 text-text-primary system-xs-regular">{nodeInfo.title}</span>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-sm text-text-quaternary transition-colors hover:text-text-secondary"
aria-label={nodeInfo.title}
onClick={() => updateBuiltinMetric(
resourceType,
resourceId,
metric.optionId,
selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id),
)}
>
<span aria-hidden="true" className="i-ri-close-line h-3.5 w-3.5" />
</button>
</div>
)
})
: (
<span className="px-1 text-text-tertiary system-xs-regular">{t('metrics.nodesAll')}</span>
)}
{shouldShowAddNode && (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('metrics.addNode')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-background-default-hover text-text-tertiary transition-colors hover:bg-state-base-hover"
/>
)}
>
<span aria-hidden="true" className="i-ri-add-line h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
popupClassName="w-[252px] rounded-md border-[0.5px] border-components-panel-border py-1 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
{selectableNodeInfoList.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
return (
<DropdownMenuItem
key={nodeInfo.node_id}
className="h-auto gap-0 rounded-md px-3 py-1.5"
onClick={() => updateBuiltinMetric(
resourceType,
resourceId,
metric.optionId,
dedupeNodeInfoList([...selectedNodeInfoList, nodeInfo]),
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<span className="truncate text-text-secondary system-sm-medium">{nodeInfo.title}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
)
}
export default BuiltinMetricCard

View File

@ -1,63 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import CustomMetricEditorCard from '../custom-metric-editor'
import { getToneClasses } from '../metric-selector/utils'
type CustomMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
const CustomMetricCard = ({
resourceType,
resourceId,
metric,
}: CustomMetricCardProps) => {
const { t } = useTranslation('evaluation')
const removeMetric = useEvaluationStore(state => state.removeMetric)
const isCustomMetricInvalid = !isCustomMetricConfigured(metric)
const metricToneClasses = getToneClasses('indigo')
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
<div className="flex min-w-0 flex-1 items-center gap-2 px-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5" />
</div>
<div className="truncate system-md-medium text-text-secondary">{metric.label}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{isCustomMetricInvalid && (
<Badge className="badge-warning">
{t('metrics.custom.warningBadge')}
</Badge>
)}
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100 hover:text-text-secondary focus-visible:opacity-100"
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
</div>
<CustomMetricEditorCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
</div>
)
}
export default CustomMetricCard

View File

@ -1,95 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
import { useEvaluationResource } from '../../store'
import MetricSelector from '../metric-selector'
import { toEvaluationTargetType } from '../metric-selector/utils'
import { InlineSectionHeader } from '../section-header'
import MetricCard from './metric-card'
import MetricSectionEmptyState from './metric-section-empty-state'
const MetricSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const [nodeInfoMap, setNodeInfoMap] = useState<Record<string, NodeInfo[]>>({})
const hasMetrics = resource.metrics.length > 0
const hasBuiltinMetrics = resource.metrics.some(metric => metric.kind === 'builtin')
const shouldLoadNodeInfo = resourceType !== 'datasets' && !!resourceId && hasBuiltinMetrics
const { data: availableMetricsData } = useAvailableEvaluationMetrics(shouldLoadNodeInfo)
const { mutate: loadNodeInfo } = useEvaluationNodeInfoMutation()
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
const availableMetricIdsKey = availableMetricIds.join(',')
const resolvedNodeInfoMap = shouldLoadNodeInfo ? nodeInfoMap : {}
useEffect(() => {
if (!shouldLoadNodeInfo || availableMetricIds.length === 0)
return
let isActive = true
loadNodeInfo(
{
params: {
targetType: toEvaluationTargetType(resourceType),
targetId: resourceId,
},
body: {
metrics: availableMetricIds,
},
},
{
onSuccess: (data) => {
if (!isActive)
return
setNodeInfoMap(data)
},
onError: () => {
if (!isActive)
return
setNodeInfoMap({})
},
},
)
return () => {
isActive = false
}
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, resourceId, resourceType, shouldLoadNodeInfo])
return (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('metrics.title')}
tooltip={t('metrics.description')}
/>
<div className="mt-1 space-y-1">
{!hasMetrics && <MetricSectionEmptyState description={t('metrics.description')} />}
{resource.metrics.map(metric => (
<MetricCard
key={metric.id}
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={metric.kind === 'builtin' ? (resolvedNodeInfoMap[metric.optionId] ?? []) : undefined}
/>
))}
<MetricSelector
resourceType={resourceType}
resourceId={resourceId}
triggerClassName="rounded-md px-3 py-2"
/>
</div>
</section>
)
}
export default MetricSection

View File

@ -1,39 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import BuiltinMetricCard from './builtin-metric-card'
import CustomMetricCard from './custom-metric-card'
type MetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const MetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList,
}: MetricCardProps) => {
if (metric.kind === 'custom-workflow') {
return (
<CustomMetricCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
)
}
return (
<BuiltinMetricCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={availableNodeInfoList}
/>
)
}
export default MetricCard

View File

@ -1,18 +0,0 @@
type MetricSectionEmptyStateProps = {
description: string
}
const MetricSectionEmptyState = ({ description }: MetricSectionEmptyStateProps) => {
return (
<div className="flex items-center gap-5 rounded-xl bg-background-section p-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-md">
<span aria-hidden="true" className="i-ri-bar-chart-horizontal-line h-6 w-6 text-text-primary" />
</div>
<div className="min-w-0 flex-1 text-text-tertiary system-xs-regular">
{description}
</div>
</div>
)
}
export default MetricSectionEmptyState

View File

@ -1,152 +0,0 @@
'use client'
import type { ChangeEvent } from 'react'
import type { MetricSelectorProps } from './types'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { cn } from '@/utils/classnames'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import SelectorEmptyState from './selector-empty-state'
import SelectorFooter from './selector-footer'
import SelectorMetricSection from './selector-metric-section'
import { useMetricSelectorData } from './use-metric-selector-data'
const MetricSelector = ({
resourceType,
resourceId,
triggerVariant = 'ghost-accent',
triggerClassName,
triggerStyle = 'button',
}: MetricSelectorProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const addCustomMetric = useEvaluationStore(state => state.addCustomMetric)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [nodeInfoMap, setNodeInfoMap] = useState<Record<string, Array<{ node_id: string, title: string, type: string }>>>({})
const [collapsedMetricMap, setCollapsedMetricMap] = useState<Record<string, boolean>>({})
const [expandedMetricNodesMap, setExpandedMetricNodesMap] = useState<Record<string, boolean>>({})
const hasCustomMetric = resource.metrics.some(metric => metric.kind === 'custom-workflow')
const {
builtinMetricMap,
filteredSections,
isRemoteLoading,
toggleNodeSelection,
} = useMetricSelectorData({
open,
query,
resourceType,
resourceId,
nodeInfoMap,
setNodeInfoMap,
})
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (nextOpen) {
setQuery('')
setCollapsedMetricMap({})
setExpandedMetricNodesMap({})
}
}
const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
triggerStyle === 'text'
? (
<button type="button" className={cn('inline-flex items-center text-text-accent system-sm-medium', triggerClassName)}>
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
{t('metrics.add')}
</button>
)
: (
<Button variant={triggerVariant} className={triggerClassName}>
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
{t('metrics.add')}
</Button>
)
)}
/>
<PopoverContent popupClassName="w-[360px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]">
<div className="flex min-h-[560px] flex-col bg-components-panel-bg">
<div className="border-b border-divider-subtle bg-background-section-burn px-2 py-2">
<Input
value={query}
showLeftIcon
placeholder={t('metrics.searchNodeOrMetrics')}
onChange={handleQueryChange}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{isRemoteLoading && (
<div className="space-y-3 px-3 py-4" data-testid="evaluation-metric-loading">
{['metric-skeleton-1', 'metric-skeleton-2', 'metric-skeleton-3'].map(key => (
<div key={key} className="h-20 animate-pulse rounded-xl bg-background-default-subtle" />
))}
</div>
)}
{!isRemoteLoading && filteredSections.length === 0 && (
<SelectorEmptyState message={t('metrics.noResults')} />
)}
{!isRemoteLoading && filteredSections.map((section, index) => {
const { metric } = section
const isExpanded = collapsedMetricMap[metric.id] !== true
const isShowingAllNodes = expandedMetricNodesMap[metric.id] === true
return (
<SelectorMetricSection
key={metric.id}
section={section}
index={index}
addedMetric={builtinMetricMap.get(metric.id)}
isExpanded={isExpanded}
isShowingAllNodes={isShowingAllNodes}
onToggleExpanded={() => setCollapsedMetricMap(current => ({
...current,
[metric.id]: isExpanded,
}))}
onToggleNodeSelection={toggleNodeSelection}
onToggleShowAllNodes={() => setExpandedMetricNodesMap(current => ({
...current,
[metric.id]: !isShowingAllNodes,
}))}
t={t}
/>
)
})}
</div>
<SelectorFooter
title={t('metrics.custom.footerTitle')}
description={hasCustomMetric ? t('metrics.custom.limitDescription') : t('metrics.custom.footerDescription')}
disabled={hasCustomMetric}
onClick={() => {
addCustomMetric(resourceType, resourceId)
setOpen(false)
}}
/>
</div>
</PopoverContent>
</Popover>
)
}
export default MetricSelector

View File

@ -1,26 +0,0 @@
type SelectorEmptyStateProps = {
message: string
}
const EmptySearchStateIcon = () => {
return (
<div className="relative h-8 w-8 text-text-quaternary">
<span aria-hidden="true" className="i-ri-search-line absolute bottom-0 right-0 h-6 w-6" />
<span aria-hidden="true" className="absolute left-0 top-[9px] h-[2px] w-[7px] rounded-full bg-current opacity-80" />
<span aria-hidden="true" className="absolute left-0 top-[16px] h-[2px] w-[4px] rounded-full bg-current opacity-80" />
</div>
)
}
const SelectorEmptyState = ({
message,
}: SelectorEmptyStateProps) => {
return (
<div className="flex h-full min-h-[524px] flex-col items-center justify-center gap-2 px-4 pb-20 text-center">
<EmptySearchStateIcon />
<div className="text-text-secondary system-sm-regular">{message}</div>
</div>
)
}
export default SelectorEmptyState

View File

@ -1,33 +0,0 @@
type SelectorFooterProps = {
title: string
description: string
disabled?: boolean
onClick: () => void
}
const SelectorFooter = ({
title,
description,
disabled = false,
onClick,
}: SelectorFooterProps) => {
return (
<button
type="button"
disabled={disabled}
className="relative flex items-center gap-3 overflow-hidden border-t border-divider-subtle bg-background-default-subtle px-4 py-5 text-left enabled:hover:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-60"
onClick={onClick}
>
<div className="absolute -left-6 -top-6 h-28 w-28 rounded-full bg-util-colors-indigo-indigo-100 opacity-50 blur-2xl" />
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]">
<span aria-hidden="true" className="i-ri-add-line h-[18px] w-[18px] text-text-tertiary" />
</div>
<div className="relative min-w-0">
<div className="text-text-secondary system-sm-semibold">{title}</div>
<div className="mt-0.5 text-text-tertiary system-xs-regular">{description}</div>
</div>
</button>
)
}
export default SelectorFooter

View File

@ -1,135 +0,0 @@
import type { TFunction } from 'i18next'
import type { EvaluationMetric } from '../../types'
import type { MetricSelectorSection } from './types'
import { cn } from '@/utils/classnames'
import { getMetricVisual, getNodeVisual, getToneClasses } from './utils'
type SelectorMetricSectionProps = {
section: MetricSelectorSection
index: number
addedMetric?: EvaluationMetric
isExpanded: boolean
isShowingAllNodes: boolean
onToggleExpanded: () => void
onToggleShowAllNodes: () => void
onToggleNodeSelection: (metricId: string, nodeInfo: MetricSelectorSection['visibleNodes'][number]) => void
t: TFunction<'evaluation'>
}
const SelectorMetricSection = ({
section,
index,
addedMetric,
isExpanded,
isShowingAllNodes,
onToggleExpanded,
onToggleShowAllNodes,
onToggleNodeSelection,
t,
}: SelectorMetricSectionProps) => {
const { metric, visibleNodes, hasNoNodeInfo } = section
const selectedNodeIds = new Set(
addedMetric?.nodeInfoList?.length
? addedMetric.nodeInfoList.map(nodeInfo => nodeInfo.node_id)
: [],
)
const metricVisual = getMetricVisual(metric.id)
const toneClasses = getToneClasses(metricVisual.tone)
const hasMoreNodes = visibleNodes.length > 3
const shownNodes = hasMoreNodes && !isShowingAllNodes ? visibleNodes.slice(0, 3) : visibleNodes
return (
<div data-testid={`evaluation-metric-option-${metric.id}`}>
{index > 0 && (
<div className="px-3 pt-1">
<div className="h-px w-full bg-divider-subtle" />
</div>
)}
<div className="flex items-center justify-between px-4 pb-1 pt-3">
<button
type="button"
className="flex min-w-0 items-center gap-2"
onClick={onToggleExpanded}
>
<div className={cn('flex h-[18px] w-[18px] items-center justify-center rounded-md', toneClasses.soft)}>
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
</div>
<div className="flex items-center gap-1">
<span className="truncate text-text-secondary system-xs-medium-uppercase">{metric.label}</span>
<span
aria-hidden="true"
className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
/>
</div>
</button>
<button type="button" className="p-px text-text-quaternary">
<span aria-hidden="true" className="i-ri-question-line h-[14px] w-[14px]" />
</button>
</div>
{isExpanded && (
<div className="px-1 py-1">
{hasNoNodeInfo && (
<div className="px-3 pb-2 pt-0.5 text-text-tertiary system-sm-regular">
{t('metrics.noNodesInWorkflow')}
</div>
)}
{shownNodes.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
const isAdded = addedMetric
? addedMetric.nodeInfoList?.length
? selectedNodeIds.has(nodeInfo.node_id)
: true
: false
return (
<button
key={nodeInfo.node_id}
data-testid={`evaluation-metric-node-${metric.id}-${nodeInfo.node_id}`}
type="button"
className={cn(
'flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-state-base-hover-alt',
isAdded && 'opacity-50',
)}
onClick={() => onToggleNodeSelection(metric.id, nodeInfo)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<span className="truncate text-[13px] font-medium leading-4 text-text-secondary">
{nodeInfo.title}
</span>
</div>
{isAdded && (
<span className="shrink-0 px-1 text-text-quaternary system-xs-regular">{t('metrics.added')}</span>
)}
</button>
)
})}
{hasMoreNodes && (
<button
type="button"
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left hover:bg-state-base-hover-alt"
onClick={onToggleShowAllNodes}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-1">
<div className="flex items-center px-1 text-text-tertiary">
<span aria-hidden="true" className={cn(isShowingAllNodes ? 'i-ri-subtract-line' : 'i-ri-more-line', 'h-4 w-4')} />
</div>
<span className="truncate text-text-tertiary system-xs-regular">
{isShowingAllNodes ? t('metrics.showLess') : t('metrics.showMore')}
</span>
</div>
</button>
)}
</div>
)}
</div>
)
}
export default SelectorMetricSection

View File

@ -1,18 +0,0 @@
import type { EvaluationMetric, EvaluationResourceProps, MetricOption } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
export type MetricSelectorProps = EvaluationResourceProps & {
triggerVariant?: 'primary' | 'warning' | 'secondary' | 'secondary-accent' | 'ghost' | 'ghost-accent' | 'tertiary'
triggerClassName?: string
triggerStyle?: 'button' | 'text'
}
export type MetricVisualTone = 'indigo' | 'green'
export type MetricSelectorSection = {
metric: MetricOption
hasNoNodeInfo: boolean
visibleNodes: NodeInfo[]
}
export type BuiltinMetricMap = Map<string, EvaluationMetric>

View File

@ -1,166 +0,0 @@
import type { BuiltinMetricMap, MetricSelectorSection } from './types'
import type { NodeInfo } from '@/types/evaluation'
import { useEffect, useMemo } from 'react'
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
import { getEvaluationMockConfig } from '../../mock'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import {
buildMetricOption,
dedupeNodeInfoList,
toEvaluationTargetType,
} from './utils'
type UseMetricSelectorDataOptions = {
open: boolean
query: string
resourceType: 'apps' | 'datasets' | 'snippets'
resourceId: string
nodeInfoMap: Record<string, NodeInfo[]>
setNodeInfoMap: (value: Record<string, NodeInfo[]>) => void
}
type UseMetricSelectorDataResult = {
builtinMetricMap: BuiltinMetricMap
filteredSections: MetricSelectorSection[]
isRemoteLoading: boolean
toggleNodeSelection: (metricId: string, nodeInfo: NodeInfo) => void
}
export const useMetricSelectorData = ({
open,
query,
resourceType,
resourceId,
nodeInfoMap,
setNodeInfoMap,
}: UseMetricSelectorDataOptions): UseMetricSelectorDataResult => {
const config = getEvaluationMockConfig(resourceType)
const metrics = useEvaluationResource(resourceType, resourceId).metrics
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const { data: availableMetricsData, isLoading: isAvailableMetricsLoading } = useAvailableEvaluationMetrics(open)
const { mutate: loadNodeInfo, isPending: isNodeInfoLoading } = useEvaluationNodeInfoMutation()
const builtinMetrics = useMemo(() => {
return metrics.filter(metric => metric.kind === 'builtin')
}, [metrics])
const builtinMetricMap = useMemo(() => {
return new Map(builtinMetrics.map(metric => [metric.optionId, metric] as const))
}, [builtinMetrics])
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
const availableMetricIdsKey = availableMetricIds.join(',')
const resolvedMetrics = useMemo(() => {
const metricsMap = new Map(config.builtinMetrics.map(metric => [metric.id, metric] as const))
return availableMetricIds.map(metricId => metricsMap.get(metricId) ?? buildMetricOption(metricId))
}, [availableMetricIds, config.builtinMetrics])
useEffect(() => {
if (!open)
return
if (resourceType === 'datasets' || !resourceId || availableMetricIds.length === 0)
return
let isActive = true
loadNodeInfo(
{
params: {
targetType: toEvaluationTargetType(resourceType),
targetId: resourceId,
},
body: {
metrics: availableMetricIds,
},
},
{
onSuccess: (data) => {
if (!isActive)
return
setNodeInfoMap(data)
},
onError: () => {
if (!isActive)
return
setNodeInfoMap({})
},
},
)
return () => {
isActive = false
}
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, open, resourceId, resourceType, setNodeInfoMap])
const filteredSections = useMemo(() => {
const keyword = query.trim().toLowerCase()
return resolvedMetrics.map((metric) => {
const metricMatches = !keyword
|| metric.label.toLowerCase().includes(keyword)
|| metric.description.toLowerCase().includes(keyword)
const metricNodes = nodeInfoMap[metric.id] ?? []
const supportsNodeSelection = resourceType !== 'datasets'
const hasNoNodeInfo = supportsNodeSelection && metricNodes.length === 0
if (hasNoNodeInfo) {
if (!metricMatches)
return null
return {
metric,
hasNoNodeInfo: true,
visibleNodes: [] as NodeInfo[],
}
}
const visibleNodes = metricMatches
? metricNodes
: metricNodes.filter((nodeInfo) => {
return nodeInfo.title.toLowerCase().includes(keyword)
|| nodeInfo.type.toLowerCase().includes(keyword)
|| nodeInfo.node_id.toLowerCase().includes(keyword)
})
if (!metricMatches && visibleNodes.length === 0)
return null
return {
metric,
hasNoNodeInfo: false,
visibleNodes,
}
}).filter(section => !!section)
}, [nodeInfoMap, query, resolvedMetrics, resourceType])
const toggleNodeSelection = (metricId: string, nodeInfo: NodeInfo) => {
const addedMetric = builtinMetricMap.get(metricId)
const currentSelectedNodes = addedMetric?.nodeInfoList ?? []
const nextSelectedNodes = addedMetric && currentSelectedNodes.length === 0
? [nodeInfo]
: currentSelectedNodes.some(item => item.node_id === nodeInfo.node_id)
? currentSelectedNodes.filter(item => item.node_id !== nodeInfo.node_id)
: dedupeNodeInfoList([...currentSelectedNodes, nodeInfo])
if (addedMetric && nextSelectedNodes.length === 0) {
removeMetric(resourceType, resourceId, addedMetric.id)
return
}
addBuiltinMetric(resourceType, resourceId, metricId, nextSelectedNodes)
}
return {
builtinMetricMap,
filteredSections,
isRemoteLoading: isAvailableMetricsLoading || isNodeInfoLoading,
toggleNodeSelection,
}
}

View File

@ -1,76 +0,0 @@
import type { MetricOption } from '../../types'
import type { MetricVisualTone } from './types'
import type { EvaluationTargetType, NodeInfo } from '@/types/evaluation'
export const toEvaluationTargetType = (resourceType: 'apps' | 'snippets'): EvaluationTargetType => {
return resourceType === 'snippets' ? 'snippets' : 'apps'
}
const humanizeMetricId = (metricId: string) => {
return metricId
.split(/[-_]/g)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export const buildMetricOption = (metricId: string): MetricOption => ({
id: metricId,
label: humanizeMetricId(metricId),
description: '',
valueType: 'number',
})
export const getMetricVisual = (metricId: string): { icon: string, tone: MetricVisualTone } => {
if (['context-precision', 'context-recall'].includes(metricId)) {
return {
icon: metricId === 'context-recall' ? 'i-ri-arrow-go-back-line' : 'i-ri-focus-2-line',
tone: 'green',
}
}
if (metricId === 'faithfulness')
return { icon: 'i-ri-anchor-line', tone: 'indigo' }
if (metricId === 'tool-correctness')
return { icon: 'i-ri-tools-line', tone: 'indigo' }
if (metricId === 'task-completion')
return { icon: 'i-ri-task-line', tone: 'indigo' }
if (metricId === 'argument-correctness')
return { icon: 'i-ri-scales-3-line', tone: 'indigo' }
return { icon: 'i-ri-checkbox-circle-line', tone: 'indigo' }
}
export const getNodeVisual = (nodeInfo: NodeInfo): { icon: string, tone: MetricVisualTone } => {
const normalizedType = nodeInfo.type.toLowerCase()
const normalizedTitle = nodeInfo.title.toLowerCase()
if (normalizedType.includes('retriev') || normalizedTitle.includes('retriev') || normalizedTitle.includes('knowledge'))
return { icon: 'i-ri-book-open-line', tone: 'green' }
if (normalizedType.includes('agent') || normalizedTitle.includes('agent'))
return { icon: 'i-ri-user-star-line', tone: 'indigo' }
return { icon: 'i-ri-ai-generate-2', tone: 'indigo' }
}
export const getToneClasses = (tone: MetricVisualTone) => {
if (tone === 'green') {
return {
soft: 'bg-util-colors-green-green-50 text-util-colors-green-green-500',
solid: 'bg-util-colors-green-green-500 text-white',
}
}
return {
soft: 'bg-util-colors-indigo-indigo-50 text-util-colors-indigo-indigo-500',
solid: 'bg-util-colors-indigo-indigo-500 text-white',
}
}
export const dedupeNodeInfoList = (nodeInfoList: NodeInfo[]) => {
return Array.from(new Map(nodeInfoList.map(nodeInfo => [nodeInfo.node_id, nodeInfo])).values())
}

View File

@ -1,70 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import type { InputField } from '../batch-test-panel/input-fields/input-fields-utils'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { getEvaluationMockConfig } from '../../mock'
import { isEvaluationRunnable, useEvaluationResource } from '../../store'
import UploadRunPopover from '../batch-test-panel/input-fields/upload-run-popover'
import { useInputFieldsActions } from '../batch-test-panel/input-fields/use-input-fields-actions'
const PIPELINE_INPUT_FIELDS: InputField[] = [
{ name: 'query', type: 'string' },
{ name: 'Expect Results', type: 'string' },
]
const PipelineBatchActions = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const config = getEvaluationMockConfig(resourceType)
const isConfigReady = !!resource.judgeModelId && resource.metrics.some(metric => metric.kind === 'builtin')
const isRunnable = isEvaluationRunnable(resource)
const actions = useInputFieldsActions({
resourceType,
resourceId,
inputFields: PIPELINE_INPUT_FIELDS,
isInputFieldsLoading: false,
isPanelReady: isConfigReady,
isRunnable,
templateFileName: config.templateFileName,
})
return (
<div className="flex gap-2 pt-2">
<Button
className="flex-1 justify-center"
variant="secondary"
disabled={!actions.canDownloadTemplate}
onClick={actions.handleDownloadTemplate}
>
<span aria-hidden="true" className="mr-1 i-ri-file-excel-2-line h-4 w-4" />
{t('batch.downloadTemplate')}
</Button>
<div className="flex-1">
<UploadRunPopover
open={actions.isUploadPopoverOpen}
onOpenChange={actions.setIsUploadPopoverOpen}
triggerDisabled={actions.uploadButtonDisabled}
triggerLabel={t('pipeline.uploadAndRun')}
inputFields={PIPELINE_INPUT_FIELDS}
currentFileName={actions.currentFileName}
currentFileExtension={actions.currentFileExtension}
currentFileSize={actions.currentFileSize}
isFileUploading={actions.isFileUploading}
isRunDisabled={actions.isRunDisabled}
isRunning={actions.isRunning}
onUploadFile={actions.handleUploadFile}
onClearUploadedFile={actions.handleClearUploadedFile}
onDownloadTemplate={actions.handleDownloadTemplate}
onRun={actions.handleRun}
/>
</div>
</div>
)
}
export default PipelineBatchActions

View File

@ -1,89 +0,0 @@
'use client'
import type { MetricOption } from '../../types'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import { DEFAULT_PIPELINE_METRIC_THRESHOLD } from '../../store-utils'
type PipelineMetricItemProps = {
metric: MetricOption
selected: boolean
onToggle: () => void
disabledCondition: boolean
threshold?: number
onThresholdChange: (value: number) => void
}
const PipelineMetricItem = ({
metric,
selected,
onToggle,
disabledCondition,
threshold = DEFAULT_PIPELINE_METRIC_THRESHOLD,
onThresholdChange,
}: PipelineMetricItemProps) => {
const { t } = useTranslation('evaluation')
return (
<div className="flex items-center justify-between gap-3 px-1 py-1">
<button
type="button"
className="flex min-w-0 items-center gap-2 text-left"
onClick={onToggle}
>
<Checkbox checked={selected} />
<span className="truncate system-sm-medium text-text-secondary">{metric.label}</span>
<Tooltip>
<TooltipTrigger
render={(
<span className="flex h-4 w-4 items-center justify-center text-text-quaternary">
<span aria-hidden="true" className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent>
{metric.description}
</TooltipContent>
</Tooltip>
</button>
{selected
? (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-accent">{t('pipeline.passIf')}</span>
<div className="w-[52px]">
<Input
value={String(threshold)}
type="number"
min={0}
max={1}
step={0.01}
onChange={(event) => {
const parsedValue = Number(event.target.value)
if (!Number.isNaN(parsedValue))
onThresholdChange(parsedValue)
}}
/>
</div>
</div>
)
: (
<button
type="button"
disabled={disabledCondition}
className={cn(
'system-xs-medium text-text-tertiary',
disabledCondition && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
)}
>
+ Condition
</button>
)}
</div>
)
}
export default PipelineMetricItem

View File

@ -1,69 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAvailableEvaluationMetrics } from '@/service/use-evaluation'
import { getEvaluationMockConfig } from '../../mock'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import { InlineSectionHeader } from '../section-header'
import PipelineMetricItem from './pipeline-metric-item'
const PipelineMetricsSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const updateMetricThreshold = useEvaluationStore(state => state.updateMetricThreshold)
const { data: availableMetricsData } = useAvailableEvaluationMetrics()
const resource = useEvaluationResource(resourceType, resourceId)
const config = getEvaluationMockConfig(resourceType)
const builtinMetricMap = useMemo(() => new Map(
resource.metrics
.filter(metric => metric.kind === 'builtin')
.map(metric => [metric.optionId, metric]),
), [resource.metrics])
const availableMetricIds = useMemo(() => new Set(availableMetricsData?.metrics ?? []), [availableMetricsData?.metrics])
const availableBuiltinMetrics = useMemo(() => {
return config.builtinMetrics.filter(metric =>
availableMetricIds.has(metric.id) || builtinMetricMap.has(metric.id),
)
}, [availableMetricIds, builtinMetricMap, config.builtinMetrics])
const handleToggleMetric = (metricId: string) => {
const selectedMetric = builtinMetricMap.get(metricId)
if (selectedMetric) {
removeMetric(resourceType, resourceId, selectedMetric.id)
return
}
addBuiltinMetric(resourceType, resourceId, metricId)
}
return (
<section>
<InlineSectionHeader title={t('metrics.title')} tooltip={t('metrics.description')} />
<div className="mt-1 space-y-0.5">
{availableBuiltinMetrics.map((metric) => {
const selectedMetric = builtinMetricMap.get(metric.id)
return (
<PipelineMetricItem
key={metric.id}
metric={metric}
selected={!!selectedMetric}
threshold={selectedMetric?.threshold}
disabledCondition
onToggle={() => handleToggleMetric(metric.id)}
onThresholdChange={value => updateMetricThreshold(resourceType, resourceId, selectedMetric?.id ?? '', value)}
/>
)
})}
</div>
</section>
)
}
export default PipelineMetricsSection

View File

@ -1,134 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { consoleClient, consoleQuery } from '@/service/client'
import { downloadUrl } from '@/utils/download'
import { useEvaluationResource } from '../../store'
import { decodeModelSelection } from '../../utils'
import PipelineResultsTable from './pipeline-results-table'
import { getMetricColumns, getRunDate } from './pipeline-results-utils'
const PAGE_SIZE = 100
const PipelineResultsPanel = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const selectedModel = decodeModelSelection(resource.judgeModelId)
const selectedRunId = resource.selectedRunId
const runDetailQuery = useQuery(consoleQuery.evaluation.runDetail.queryOptions({
input: selectedRunId
? {
params: {
targetType: resourceType,
targetId: resourceId,
runId: selectedRunId,
},
query: {
page: 1,
page_size: PAGE_SIZE,
},
}
: skipToken,
refetchOnWindowFocus: false,
}))
const resultFileDownloadMutation = useMutation({
mutationFn: async (fileId: string) => {
const fileInfo = await consoleClient.evaluation.file({
params: {
targetType: resourceType,
targetId: resourceId,
fileId,
},
})
downloadUrl({ url: fileInfo.download_url, fileName: fileInfo.name })
},
})
const runDetail = runDetailQuery.data
const items = runDetail?.items.data ?? []
const metricColumns = getMetricColumns(resource, items)
const thresholdColumns = metricColumns.filter(column => column.threshold !== undefined)
const isEmpty = !selectedRunId || (!runDetailQuery.isLoading && items.length === 0)
if (isEmpty) {
return (
<div className="flex min-h-[360px] flex-1 items-center justify-center xl:min-h-0">
<div className="flex flex-col items-center gap-4 px-4 text-center">
<span aria-hidden="true" className="i-ri-file-list-3-line h-12 w-12 text-text-quaternary" />
<div className="system-md-medium text-text-quaternary">{t('results.empty')}</div>
</div>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col border-l border-divider-subtle bg-background-default">
<div className="shrink-0 px-6 pt-4 pb-2">
<h2 className="system-xl-semibold text-text-primary">{t('results.title')}</h2>
</div>
{runDetailQuery.isError && (
<div className="px-6 py-4 system-sm-regular text-text-destructive">{t('results.loadFailed')}</div>
)}
{!runDetailQuery.isError && (
<div className="flex min-h-0 flex-1 flex-col px-6 py-1">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 py-1">
<div className="flex min-w-0 flex-wrap items-center gap-2 system-xs-regular text-text-secondary">
<span>{getRunDate(runDetail?.run.started_at ?? runDetail?.run.created_at ?? null)}</span>
<span aria-hidden="true">·</span>
<span>{t('results.queryCount', { count: runDetail?.run.total_items ?? runDetail?.items.total ?? items.length })}</span>
{selectedModel && (
<>
<span aria-hidden="true">·</span>
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-lg bg-background-section-burn px-2 py-1">
<span aria-hidden="true" className="i-ri-robot-2-line h-4 w-4 shrink-0 text-text-accent" />
<span className="truncate">{selectedModel.model}</span>
</span>
</>
)}
{thresholdColumns.length > 0 && (
<>
<span aria-hidden="true">·</span>
<span className="flex min-w-0 flex-wrap items-center gap-1">
{thresholdColumns.map(column => (
<span
key={column.id}
className="rounded-lg border-[0.5px] border-divider-subtle bg-background-section px-2 py-1 text-text-tertiary"
>
{t('results.metricThreshold', { metric: column.label, threshold: column.threshold })}
</span>
))}
</span>
</>
)}
</div>
<button
type="button"
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 system-xs-medium text-components-button-secondary-text shadow-xs disabled:cursor-not-allowed disabled:opacity-50"
disabled={!runDetail?.run.result_file_id || resultFileDownloadMutation.isPending}
onClick={() => {
if (runDetail?.run.result_file_id)
resultFileDownloadMutation.mutate(runDetail.run.result_file_id)
}}
>
<span aria-hidden="true" className="i-ri-download-2-line h-3.5 w-3.5" />
{t('results.export')}
</button>
</div>
<PipelineResultsTable
items={items}
metricColumns={metricColumns}
isLoading={runDetailQuery.isLoading}
/>
</div>
)}
</div>
)
}
export default PipelineResultsPanel

View File

@ -1,118 +0,0 @@
import type { MetricColumn } from './pipeline-results-utils'
import type { EvaluationRunItem } from '@/types/evaluation'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import {
formatValue,
getIsItemPassed,
getMetricTextClassName,
getMetricValue,
getQueryContent,
} from './pipeline-results-utils'
const LOADING_ROW_IDS = ['1', '2', '3', '4', '5', '6']
type PipelineResultsTableProps = {
items: EvaluationRunItem[]
metricColumns: MetricColumn[]
isLoading: boolean
}
const PipelineResultsTable = ({
items,
metricColumns,
isLoading,
}: PipelineResultsTableProps) => {
const { t } = useTranslation('evaluation')
return (
<div className="min-h-0 flex-1 overflow-auto py-2">
<table className="min-w-full table-fixed border-collapse overflow-hidden rounded-lg">
<colgroup>
<col className="w-10" />
<col className="w-[220px]" />
<col className="w-[190px]" />
<col className="w-[220px]" />
{metricColumns.map(column => <col key={column.id} className="w-24" />)}
</colgroup>
<thead>
<tr className="bg-background-section">
<th className="h-7 rounded-l-lg" />
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('results.columns.query')}</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('results.columns.expected')}</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('results.columns.actual')}</th>
{metricColumns.map((column, index) => (
<th
key={column.id}
className={cn(
'h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary',
index === metricColumns.length - 1 && 'rounded-r-lg',
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading && LOADING_ROW_IDS.map(rowId => (
<tr key={rowId} className="border-b border-divider-subtle">
<td colSpan={4 + metricColumns.length} className="h-10 px-3">
<div className="h-4 animate-pulse rounded bg-background-section" />
</td>
</tr>
))}
{!isLoading && items.map((item) => {
const isPassed = getIsItemPassed(item, metricColumns)
const actualOutput = item.error ?? item.actual_output
return (
<tr key={item.id} className="border-b border-divider-subtle even:bg-background-default-subtle">
<td className="h-10 px-3 align-top">
<span
aria-label={isPassed ? t('results.status.passed') : t('results.status.failed')}
className={cn(
'mt-3 inline-block h-4 w-4',
isPassed
? 'i-ri-check-line text-util-colors-green-green-600'
: 'i-ri-close-line text-util-colors-red-red-600',
)}
/>
</td>
<td className="h-10 px-3 py-3 align-top system-sm-regular text-text-secondary">
<div className="line-clamp-2 break-words">{getQueryContent(item)}</div>
</td>
<td className="h-10 px-3 py-3 align-top system-sm-regular text-text-secondary">
<div className="line-clamp-2 break-words">{formatValue(item.expected_output)}</div>
</td>
<td className={cn(
'h-10 px-3 py-3 align-top system-sm-regular',
actualOutput ? 'text-text-secondary' : 'text-text-destructive',
)}
>
<div className="line-clamp-2 break-words">
{actualOutput ? formatValue(actualOutput) : t('results.noResult')}
</div>
</td>
{metricColumns.map((column) => {
const metricValue = getMetricValue(item.metrics, column)
return (
<td
key={column.id}
className={cn('h-10 px-3 py-3 align-top system-sm-regular', getMetricTextClassName(metricValue, column))}
>
{formatValue(metricValue)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default PipelineResultsTable

View File

@ -1,179 +0,0 @@
import type { EvaluationResourceState } from '../../types'
import type { EvaluationRunItem, EvaluationRunMetric } from '@/types/evaluation'
import { formatTime } from '@/utils/time'
const PREFERRED_QUERY_INPUT_KEYS = ['query', 'question', 'input']
export type MetricColumn = {
id: string
label: string
threshold?: number
}
const normalizeMetricKey = (value: string) => value.toLowerCase().replace(/[\s_-]/g, '')
const humanizeMetricName = (name: string) => {
return name
.split(/[-_]/g)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export const formatValue = (value: unknown) => {
if (value === null || value === undefined || value === '')
return '-'
if (typeof value === 'string')
return value
if (typeof value === 'number') {
return Number.isInteger(value)
? String(value)
: value.toLocaleString(undefined, { maximumFractionDigits: 3 })
}
if (typeof value === 'boolean')
return value ? 'true' : 'false'
return JSON.stringify(value)
}
export const getQueryContent = (item: EvaluationRunItem) => {
for (const key of PREFERRED_QUERY_INPUT_KEYS) {
const value = item.inputs[key]
if (value !== undefined)
return formatValue(value)
}
const firstValue = Object.values(item.inputs).find(value => value !== undefined && value !== null && value !== '')
return formatValue(firstValue)
}
export const getMetricValue = (metrics: EvaluationRunMetric[], column: MetricColumn) => {
const normalizedColumnId = normalizeMetricKey(column.id)
const normalizedColumnLabel = normalizeMetricKey(column.label)
const metric = metrics.find((item) => {
if (!item.name)
return false
const normalizedMetricName = normalizeMetricKey(item.name)
return normalizedMetricName === normalizedColumnId || normalizedMetricName === normalizedColumnLabel
})
return metric?.value
}
const getNumericMetricValue = (metrics: EvaluationRunMetric[], column: MetricColumn) => {
const value = getMetricValue(metrics, column)
if (typeof value === 'number')
return value
if (typeof value === 'string' && value.trim() !== '') {
const numericValue = Number(value)
return Number.isNaN(numericValue) ? null : numericValue
}
return null
}
export const getMetricTextClassName = (value: unknown, column: MetricColumn) => {
const numericValue = typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: null
if (numericValue === null || Number.isNaN(numericValue))
return 'text-text-secondary'
if (column.threshold === undefined)
return 'text-text-secondary'
if (numericValue >= column.threshold)
return 'text-util-colors-green-green-600'
if (numericValue === 0)
return 'text-util-colors-red-red-600'
return 'text-util-colors-warning-warning-600'
}
const getJudgmentResult = (judgment: Record<string, unknown>) => {
for (const key of ['passed', 'pass', 'success', 'result']) {
const value = judgment[key]
if (typeof value === 'boolean')
return value
if (typeof value === 'string') {
const normalizedValue = value.toLowerCase()
if (['passed', 'pass', 'success', 'succeeded', 'true'].includes(normalizedValue))
return true
if (['failed', 'fail', 'failure', 'false'].includes(normalizedValue))
return false
}
}
return null
}
export const getIsItemPassed = (item: EvaluationRunItem, metricColumns: MetricColumn[]) => {
if (item.error)
return false
const judgmentResult = getJudgmentResult(item.judgment)
if (judgmentResult !== null)
return judgmentResult
const thresholdColumns = metricColumns.filter(column => column.threshold !== undefined)
if (thresholdColumns.length > 0) {
return thresholdColumns.every((column) => {
const metricValue = getNumericMetricValue(item.metrics, column)
const threshold = column.threshold
return threshold !== undefined && metricValue !== null && metricValue >= threshold
})
}
return item.overall_score === null ? true : item.overall_score > 0
}
export const getMetricColumns = (
resource: EvaluationResourceState,
items: EvaluationRunItem[],
) => {
const columns = new Map<string, MetricColumn>()
resource.metrics.forEach((metric) => {
columns.set(normalizeMetricKey(metric.optionId), {
id: metric.optionId,
label: metric.label,
threshold: metric.threshold,
})
})
items.forEach((item) => {
item.metrics.forEach((metric) => {
if (!metric.name)
return
const normalizedName = normalizeMetricKey(metric.name)
if (!columns.has(normalizedName)) {
columns.set(normalizedName, {
id: metric.name,
label: humanizeMetricName(metric.name),
})
}
})
})
return Array.from(columns.values())
}
export const getRunDate = (timestamp: number | null) => {
if (!timestamp)
return '-'
const milliseconds = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
return formatTime({ date: milliseconds, dateFormat: 'YYYY-MM-DD HH:mm' })
}

View File

@ -1,76 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
type SectionHeaderProps = {
title: string
description?: ReactNode
action?: ReactNode
className?: string
titleClassName?: string
descriptionClassName?: string
}
type InlineSectionHeaderProps = {
title: string
tooltip?: ReactNode
action?: ReactNode
className?: string
}
const SectionHeader = ({
title,
description,
action,
className,
titleClassName,
descriptionClassName,
}: SectionHeaderProps) => {
return (
<div className={cn('flex flex-wrap items-start justify-between gap-3', className)}>
<div>
<div className={cn('text-text-primary system-xl-semibold', titleClassName)}>{title}</div>
{description && <div className={cn('mt-1 text-text-tertiary system-sm-regular', descriptionClassName)}>{description}</div>}
</div>
{action}
</div>
)
}
export const InlineSectionHeader = ({
title,
tooltip,
action,
className,
}: InlineSectionHeaderProps) => {
return (
<div className={cn('flex flex-wrap items-center justify-between gap-3', className)}>
<div className="flex min-h-6 items-center gap-1">
<div className="text-text-primary system-md-semibold">{title}</div>
{tooltip && (
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="flex h-4 w-4 items-center justify-center text-text-quaternary transition-colors hover:text-text-tertiary"
aria-label={title}
>
<span aria-hidden="true" className="i-ri-question-line h-3.5 w-3.5" />
</button>
)}
/>
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)}
</div>
{action}
</div>
)
}
export default SectionHeader

View File

@ -1,46 +0,0 @@
'use client'
import type { EvaluationResourceProps } from './types'
import { useEffect } from 'react'
import { useEvaluationConfig } from '@/service/use-evaluation'
import NonPipelineEvaluation from './components/layout/non-pipeline-evaluation'
import PipelineEvaluation from './components/layout/pipeline-evaluation'
import { useEvaluationStore } from './store'
const Evaluation = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { data: config } = useEvaluationConfig(resourceType, resourceId)
const ensureResource = useEvaluationStore(state => state.ensureResource)
const hydrateResource = useEvaluationStore(state => state.hydrateResource)
useEffect(() => {
ensureResource(resourceType, resourceId)
}, [ensureResource, resourceId, resourceType])
useEffect(() => {
if (!config)
return
hydrateResource(resourceType, resourceId, config)
}, [config, hydrateResource, resourceId, resourceType])
if (resourceType === 'datasets') {
return (
<PipelineEvaluation
resourceType={resourceType}
resourceId={resourceId}
/>
)
}
return (
<NonPipelineEvaluation
resourceType={resourceType}
resourceId={resourceId}
/>
)
}
export default Evaluation

View File

@ -1,178 +0,0 @@
import type {
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
MetricOption,
} from './types'
const judgeModels = [
{
id: 'gpt-4.1-mini',
label: 'GPT-4.1 mini',
provider: 'OpenAI',
},
{
id: 'claude-3-7-sonnet',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
},
{
id: 'gemini-2.0-flash',
label: 'Gemini 2.0 Flash',
provider: 'Google',
},
]
const builtinMetrics: MetricOption[] = [
{
id: 'answer-correctness',
label: 'Answer Correctness',
description: 'Compares the response with the expected answer and scores factual alignment.',
valueType: 'number',
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
valueType: 'number',
},
{
id: 'relevance',
label: 'Relevance',
description: 'Evaluates how directly the answer addresses the original request.',
valueType: 'number',
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
valueType: 'number',
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
valueType: 'number',
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
valueType: 'number',
},
]
const pipelineBuiltinMetrics: MetricOption[] = [
{
id: 'context-precision',
label: 'Context Precision',
description: 'Measures whether retrieved chunks stay tightly aligned to the request.',
valueType: 'number',
},
{
id: 'context-recall',
label: 'Context Recall',
description: 'Checks whether the retrieval result includes the evidence needed to answer.',
valueType: 'number',
},
{
id: 'context-relevance',
label: 'Context Relevance',
description: 'Scores how useful the retrieved context is for downstream generation.',
valueType: 'number',
},
]
const workflowOptions = [
{
id: 'workflow-precision-review',
label: 'Precision Review Workflow',
description: 'Custom evaluator for nuanced quality review.',
targetVariables: [
{ id: 'query', label: 'query' },
{ id: 'answer', label: 'answer' },
{ id: 'reference', label: 'reference' },
],
},
{
id: 'workflow-risk-review',
label: 'Risk Review Workflow',
description: 'Custom evaluator for policy and escalation checks.',
targetVariables: [
{ id: 'input', label: 'input' },
{ id: 'output', label: 'output' },
],
},
]
const workflowFields: EvaluationFieldOption[] = [
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
]
const pipelineFields: EvaluationFieldOption[] = [
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
]
const snippetFields: EvaluationFieldOption[] = [
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'datasets') {
return {
judgeModels,
builtinMetrics: pipelineBuiltinMetrics,
workflowOptions,
fieldOptions: pipelineFields,
templateFileName: 'pipeline-evaluation-template.csv',
batchRequirements: [
'Include one row per retrieval scenario.',
'Provide the expected source or target chunk for each case.',
'Keep numeric metrics in plain number format.',
],
historySummaryLabel: 'Pipeline evaluation batch',
}
}
if (resourceType === 'snippets') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: snippetFields,
templateFileName: 'snippet-evaluation-template.csv',
batchRequirements: [
'Include one row per snippet execution case.',
'Provide the expected final content or acceptance rule.',
'Keep optional fields empty when not used.',
],
historySummaryLabel: 'Snippet evaluation batch',
}
}
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: workflowFields,
templateFileName: 'workflow-evaluation-template.csv',
batchRequirements: [
'Include one row per workflow test case.',
'Provide both user input and expected answer when available.',
'Keep boolean columns as true or false.',
],
historySummaryLabel: 'Workflow evaluation batch',
}
}

View File

@ -1,605 +0,0 @@
import type {
BatchTestRecord,
ComparisonOperator,
CustomMetricMapping,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionItem,
JudgmentConfig,
MetricOption,
} from './types'
import type {
EvaluationConfig,
EvaluationConfigData,
EvaluationCustomizedMetric,
EvaluationDefaultMetric,
EvaluationJudgmentCondition,
EvaluationJudgmentConditionValue,
EvaluationJudgmentConfig,
EvaluationRunRequest,
NodeInfo,
} from '@/types/evaluation'
import { getEvaluationMockConfig } from './mock'
import {
buildConditionMetricOptions,
decodeModelSelection,
encodeModelSelection,
getComparisonOperators,
getDefaultComparisonOperator,
requiresComparisonValue,
} from './utils'
type EvaluationStoreResources = Record<string, EvaluationResourceState>
export const DEFAULT_PIPELINE_METRIC_THRESHOLD = 0.85
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
const humanizeMetricId = (metricId: string) => {
return metricId
.split(/[-_]/g)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
const resolveMetricOption = (resourceType: EvaluationResourceType, metricId: string): MetricOption => {
const config = getEvaluationMockConfig(resourceType)
return config.builtinMetrics.find(metric => metric.id === metricId) ?? {
id: metricId,
label: humanizeMetricId(metricId),
description: '',
valueType: 'number',
}
}
const normalizeNodeInfoList = (value: NodeInfo[] | undefined): NodeInfo[] => {
if (!value?.length)
return []
return value
.map((item) => {
const nodeId = typeof item.node_id === 'string' ? item.node_id : ''
const title = typeof item.title === 'string' ? item.title : nodeId
const type = typeof item.type === 'string' ? item.type : ''
if (!nodeId)
return null
return {
node_id: nodeId,
title,
type,
}
})
.filter((item): item is NodeInfo => !!item)
}
const normalizeDefaultMetrics = (
resourceType: EvaluationResourceType,
value: EvaluationDefaultMetric[] | null | undefined,
): EvaluationMetric[] => {
if (!value?.length)
return []
return value
.map((item) => {
const metricId = typeof item.metric === 'string' ? item.metric : ''
if (!metricId)
return null
const metricOption = resolveMetricOption(resourceType, metricId)
return createBuiltinMetric(metricOption, normalizeNodeInfoList(item.node_info_list ?? []))
})
.filter((item): item is EvaluationMetric => !!item)
}
const normalizeCustomMetricMappings = (
value: EvaluationCustomizedMetric['input_fields'],
): CustomMetricMapping[] => {
if (!value)
return []
return Object.entries(value)
.filter((entry): entry is [string, string] => {
const [, outputVariableId] = entry
return typeof outputVariableId === 'string' && !!outputVariableId
})
.map(([inputVariableId, outputVariableId]) => createCustomMetricMapping(inputVariableId, outputVariableId))
}
const normalizeCustomMetricOutputs = (
value: EvaluationCustomizedMetric['output_fields'],
) => {
if (!value)
return []
return value
.map((output) => {
const id = typeof output.variable === 'string' ? output.variable : ''
if (!id)
return null
return {
id,
valueType: typeof output.value_type === 'string' ? output.value_type : null,
}
})
.filter((output): output is { id: string, valueType: string | null } => !!output)
}
const normalizeCustomMetric = (
value: EvaluationCustomizedMetric | null | undefined,
): EvaluationMetric[] => {
if (!value)
return []
const workflowId = typeof value.evaluation_workflow_id === 'string' ? value.evaluation_workflow_id : null
if (!workflowId)
return []
const customMetric = createCustomMetric()
return [{
...customMetric,
customConfig: customMetric.customConfig
? {
...customMetric.customConfig,
workflowId,
mappings: normalizeCustomMetricMappings(value.input_fields),
outputs: normalizeCustomMetricOutputs(value.output_fields),
}
: customMetric.customConfig,
}]
}
const normalizeVariableSelector = (value: string[] | undefined): [string, string] | null => {
if (!Array.isArray(value) || value.length < 2)
return null
const [scope, metricName] = value
return typeof scope === 'string' && !!scope && typeof metricName === 'string' && !!metricName
? [scope, metricName]
: null
}
const getNormalizedConditionValue = (
operator: ComparisonOperator,
previousValue: EvaluationJudgmentConditionValue | string | number | boolean | null | undefined,
) => {
if (!requiresComparisonValue(operator))
return null
if (Array.isArray(previousValue))
return previousValue.filter((item): item is string => typeof item === 'string' && !!item)
if (typeof previousValue === 'boolean')
return previousValue
if (typeof previousValue === 'number')
return String(previousValue)
return typeof previousValue === 'string' ? previousValue : null
}
const normalizeConditionItem = (
value: EvaluationJudgmentCondition,
metrics: EvaluationMetric[],
): JudgmentConditionItem | null => {
const variableSelector = normalizeVariableSelector(value.variable_selector)
if (!variableSelector)
return null
const metricOption = buildConditionMetricOptions(metrics).find(option =>
option.variableSelector[0] === variableSelector[0] && option.variableSelector[1] === variableSelector[1],
)
if (!metricOption)
return null
const allowedOperators = getComparisonOperators(metricOption.valueType)
const rawOperator = typeof value.comparison_operator === 'string' ? value.comparison_operator : ''
const comparisonOperator = allowedOperators.includes(rawOperator as ComparisonOperator)
? rawOperator as ComparisonOperator
: getDefaultComparisonOperator(metricOption.valueType)
return {
id: createId('condition'),
variableSelector,
comparisonOperator,
value: getConditionValue(metricOption.valueType, comparisonOperator, value.value),
}
}
const createEmptyJudgmentConfig = (): JudgmentConfig => {
return {
logicalOperator: 'and',
conditions: [],
}
}
const normalizeJudgmentConfig = (
config: EvaluationConfig,
metrics: EvaluationMetric[],
): JudgmentConfig => {
const rawJudgmentConfig: EvaluationJudgmentConfig | null | undefined = config.judgment_config
if (!rawJudgmentConfig)
return createEmptyJudgmentConfig()
const conditions = (rawJudgmentConfig.conditions ?? [])
.map(condition => normalizeConditionItem(condition, metrics))
.filter((condition): condition is JudgmentConditionItem => !!condition)
return {
logicalOperator: rawJudgmentConfig.logical_operator === 'or' ? 'or' : 'and',
conditions,
}
}
export const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
export const requiresConditionValue = (operator: ComparisonOperator) => {
return requiresComparisonValue(operator)
}
export function getConditionValue(
valueType: EvaluationMetric['valueType'] | undefined,
operator: ComparisonOperator,
previousValue?: EvaluationJudgmentConditionValue | string | number | boolean | null,
) {
if (!valueType || !requiresConditionValue(operator))
return null
if (valueType === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (operator === 'in' || operator === 'not in') {
if (Array.isArray(previousValue))
return previousValue.filter((item): item is string => typeof item === 'string' && !!item)
return typeof previousValue === 'string' && previousValue
? previousValue.split(',').map(item => item.trim()).filter(Boolean)
: []
}
return getNormalizedConditionValue(operator, previousValue)
}
export function createBuiltinMetric(
metric: MetricOption,
nodeInfoList: NodeInfo[] = [],
threshold = DEFAULT_PIPELINE_METRIC_THRESHOLD,
): EvaluationMetric {
return {
id: createId('metric'),
optionId: metric.id,
kind: 'builtin',
label: metric.label,
description: metric.description,
valueType: metric.valueType,
threshold,
nodeInfoList,
}
}
function createCustomMetricMapping(
inputVariableId: string | null = null,
outputVariableId: string | null = null,
): CustomMetricMapping {
return {
id: createId('mapping'),
inputVariableId,
outputVariableId,
}
}
export const syncCustomMetricMappings = (
mappings: CustomMetricMapping[],
inputVariableIds: string[],
) => {
const mappingByInputVariableId = new Map(
mappings
.filter(mapping => !!mapping.inputVariableId)
.map(mapping => [mapping.inputVariableId, mapping]),
)
return inputVariableIds.map((inputVariableId) => {
const existingMapping = mappingByInputVariableId.get(inputVariableId)
return existingMapping
? {
...existingMapping,
inputVariableId,
}
: createCustomMetricMapping(inputVariableId, null)
})
}
export function createCustomMetric(): EvaluationMetric {
return {
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
valueType: 'number',
customConfig: {
workflowId: null,
workflowAppId: null,
workflowName: null,
mappings: [],
outputs: [],
},
}
}
export const buildConditionItem = (
metrics: EvaluationMetric[],
variableSelector?: [string, string] | null,
): JudgmentConditionItem => {
const metricOptions = buildConditionMetricOptions(metrics)
const metricOption = variableSelector
? metricOptions.find(option =>
option.variableSelector[0] === variableSelector[0]
&& option.variableSelector[1] === variableSelector[1],
) ?? metricOptions[0]
: metricOptions[0]
const comparisonOperator = metricOption ? getDefaultComparisonOperator(metricOption.valueType) : 'is'
return {
id: createId('condition'),
variableSelector: metricOption?.variableSelector ?? null,
comparisonOperator,
value: getConditionValue(metricOption?.valueType, comparisonOperator),
}
}
export const syncJudgmentConfigWithMetrics = (
judgmentConfig: JudgmentConfig,
metrics: EvaluationMetric[],
): JudgmentConfig => {
const metricOptions = buildConditionMetricOptions(metrics)
return {
logicalOperator: judgmentConfig.logicalOperator,
conditions: judgmentConfig.conditions
.map((condition) => {
const metricOption = metricOptions.find(option =>
option.variableSelector[0] === condition.variableSelector?.[0]
&& option.variableSelector[1] === condition.variableSelector?.[1],
)
if (!metricOption)
return null
const allowedOperators = getComparisonOperators(metricOption.valueType)
const comparisonOperator = allowedOperators.includes(condition.comparisonOperator)
? condition.comparisonOperator
: getDefaultComparisonOperator(metricOption.valueType)
return {
...condition,
comparisonOperator,
value: getConditionValue(metricOption.valueType, comparisonOperator, condition.value),
}
})
.filter((condition): condition is JudgmentConditionItem => !!condition),
}
}
export const buildInitialState = (_resourceType: EvaluationResourceType): EvaluationResourceState => {
return {
judgeModelId: null,
metrics: [],
judgmentConfig: createEmptyJudgmentConfig(),
activeBatchTab: 'input-fields',
uploadedFileId: null,
uploadedFileName: null,
selectedRunId: null,
batchRecords: [],
}
}
export const buildStateFromEvaluationConfig = (
resourceType: EvaluationResourceType,
config: EvaluationConfig,
): EvaluationResourceState => {
const defaultMetrics = normalizeDefaultMetrics(resourceType, config.default_metrics)
const customMetrics = normalizeCustomMetric(config.customized_metrics)
const metrics = [...defaultMetrics, ...customMetrics]
return {
...buildInitialState(resourceType),
judgeModelId: config.evaluation_model && config.evaluation_model_provider
? encodeModelSelection(config.evaluation_model_provider, config.evaluation_model)
: null,
metrics,
judgmentConfig: normalizeJudgmentConfig(config, metrics),
}
}
const getApiComparisonOperator = (operator: ComparisonOperator) => {
if (operator === 'is null')
return 'null'
if (operator === 'is not null')
return 'not null'
return operator
}
const getCustomMetricScopeId = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return null
return metric.customConfig?.workflowAppId ?? metric.customConfig?.workflowId ?? null
}
const buildCustomizedMetricsPayload = (metrics: EvaluationMetric[]): EvaluationConfigData['customized_metrics'] => {
const customMetric = metrics.find(metric => metric.kind === 'custom-workflow')
const customConfig = customMetric?.customConfig
const evaluationWorkflowId = customMetric ? getCustomMetricScopeId(customMetric) : null
if (!customConfig || !evaluationWorkflowId)
return null
return {
evaluation_workflow_id: evaluationWorkflowId,
input_fields: Object.fromEntries(
customConfig.mappings
.filter((mapping): mapping is CustomMetricMapping & { inputVariableId: string, outputVariableId: string } =>
!!mapping.inputVariableId && !!mapping.outputVariableId,
)
.map(mapping => [mapping.inputVariableId, mapping.outputVariableId]),
),
output_fields: customConfig.outputs.map(output => ({
variable: output.id,
value_type: output.valueType ?? undefined,
})),
}
}
const buildJudgmentConfigPayload = (resource: EvaluationResourceState): EvaluationConfigData['judgment_config'] => {
const conditions = resource.judgmentConfig.conditions
.filter(condition => !!condition.variableSelector)
.map((condition) => {
const [scope, metricName] = condition.variableSelector!
const customMetric = resource.metrics.find(metric =>
metric.kind === 'custom-workflow'
&& metric.customConfig?.workflowId === scope,
)
const customScopeId = customMetric ? getCustomMetricScopeId(customMetric) : null
return {
variable_selector: [customScopeId ?? scope, metricName],
comparison_operator: getApiComparisonOperator(condition.comparisonOperator),
...(requiresComparisonValue(condition.comparisonOperator) ? { value: condition.value ?? undefined } : {}),
}
})
if (!conditions.length)
return null
return {
logical_operator: resource.judgmentConfig.logicalOperator,
conditions,
}
}
export const buildEvaluationConfigPayload = (
resource: EvaluationResourceState,
): EvaluationConfigData | null => {
const selectedModel = decodeModelSelection(resource.judgeModelId)
if (!selectedModel)
return null
return {
evaluation_model: selectedModel.model,
evaluation_model_provider: selectedModel.provider,
default_metrics: resource.metrics
.filter(metric => metric.kind === 'builtin')
.map(metric => ({
metric: metric.optionId,
value_type: metric.valueType,
node_info_list: metric.nodeInfoList ?? [],
})),
customized_metrics: buildCustomizedMetricsPayload(resource.metrics),
judgment_config: buildJudgmentConfigPayload(resource),
}
}
export const buildEvaluationRunRequest = (
resource: EvaluationResourceState,
fileId: string,
): EvaluationRunRequest | null => {
const configPayload = buildEvaluationConfigPayload(resource)
if (!configPayload)
return null
return {
...configPayload,
file_id: fileId,
}
}
const getResourceState = (
resources: EvaluationStoreResources,
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
export const updateResourceState = (
resources: EvaluationStoreResources,
resourceType: EvaluationResourceType,
resourceId: string,
updater: (resource: EvaluationResourceState) => EvaluationResourceState,
) => {
const { resource, resourceKey } = getResourceState(resources, resourceType, resourceId)
return {
...resources,
[resourceKey]: updater(resource),
}
}
export const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
export const createBatchTestRecord = (
resourceType: EvaluationResourceType,
uploadedFileName: string | null | undefined,
): BatchTestRecord => {
const config = getEvaluationMockConfig(resourceType)
return {
id: createId('batch'),
fileName: uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
}
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.inputVariableId && !!mapping.outputVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
}
export const getAllowedOperators = (
metrics: EvaluationMetric[],
variableSelector: [string, string] | null,
) => {
const metricOption = buildConditionMetricOptions(metrics).find(option =>
option.variableSelector[0] === variableSelector?.[0]
&& option.variableSelector[1] === variableSelector?.[1],
)
if (!metricOption)
return ['is'] as ComparisonOperator[]
return getComparisonOperators(metricOption.valueType)
}

View File

@ -1,456 +0,0 @@
import type {
ComparisonOperator,
EvaluationResourceState,
EvaluationResourceType,
} from './types'
import type { EvaluationConfig, NodeInfo } from '@/types/evaluation'
import { create } from 'zustand'
import { getEvaluationMockConfig } from './mock'
import {
buildConditionItem,
buildInitialState,
buildResourceKey,
buildStateFromEvaluationConfig,
createBatchTestRecord,
createBuiltinMetric,
createCustomMetric,
getAllowedOperators as getAllowedOperatorsFromUtils,
getConditionValue,
isCustomMetricConfigured as isCustomMetricConfiguredFromUtils,
isEvaluationRunnable as isEvaluationRunnableFromUtils,
requiresConditionValue as requiresConditionValueFromUtils,
syncCustomMetricMappings as syncCustomMetricMappingsFromUtils,
syncJudgmentConfigWithMetrics,
updateMetric,
updateResourceState,
} from './store-utils'
import { buildConditionMetricOptions } from './utils'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
hydrateResource: (resourceType: EvaluationResourceType, resourceId: string, config: EvaluationConfig) => void
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string, nodeInfoList?: NodeInfo[]) => void
updateMetricThreshold: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, threshold: number) => void
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
setCustomMetricWorkflow: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
workflow: { workflowId: string, workflowAppId: string, workflowName: string },
) => void
syncCustomMetricMappings: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
inputVariableIds: string[],
) => void
syncCustomMetricOutputs: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
outputs: Array<{ id: string, valueType: string | null }>,
) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
mappingId: string,
patch: { inputVariableId?: string | null, outputVariableId?: string | null },
) => void
setConditionLogicalOperator: (resourceType: EvaluationResourceType, resourceId: string, logicalOperator: 'and' | 'or') => void
addCondition: (
resourceType: EvaluationResourceType,
resourceId: string,
variableSelector?: [string, string] | null,
) => void
removeCondition: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string) => void
updateConditionMetric: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, variableSelector: [string, string]) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, operator: ComparisonOperator) => void
updateConditionValue: (
resourceType: EvaluationResourceType,
resourceId: string,
conditionId: string,
value: string | string[] | boolean | null,
) => void
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
setUploadedFile: (
resourceType: EvaluationResourceType,
resourceId: string,
uploadedFile: { id: string, name: string } | null,
) => void
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
setSelectedRunId: (resourceType: EvaluationResourceType, resourceId: string, runId: string | null) => void
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
}
const initialResourceCache: Record<string, EvaluationResourceState> = {}
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
ensureResource: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
if (get().resources[resourceKey])
return
set(state => ({
resources: {
...state.resources,
[resourceKey]: buildInitialState(resourceType),
},
}))
},
hydrateResource: (resourceType, resourceId, config) => {
set(state => ({
resources: {
...state.resources,
[buildResourceKey(resourceType, resourceId)]: {
...buildStateFromEvaluationConfig(resourceType, config),
activeBatchTab: state.resources[buildResourceKey(resourceType, resourceId)]?.activeBatchTab ?? 'input-fields',
uploadedFileId: state.resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileId ?? null,
uploadedFileName: state.resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? null,
selectedRunId: state.resources[buildResourceKey(resourceType, resourceId)]?.selectedRunId ?? null,
batchRecords: state.resources[buildResourceKey(resourceType, resourceId)]?.batchRecords ?? [],
},
},
}))
},
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
judgeModelId,
})),
}))
},
addBuiltinMetric: (resourceType, resourceId, optionId, nodeInfoList = []) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
if (!option)
return
set((state) => {
return {
resources: updateResourceState(state.resources, resourceType, resourceId, (currentResource) => {
const metrics = currentResource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin')
? currentResource.metrics.map(metric => metric.optionId === optionId && metric.kind === 'builtin'
? {
...metric,
nodeInfoList,
}
: metric)
: [...currentResource.metrics, createBuiltinMetric(option, nodeInfoList)]
return {
...currentResource,
metrics,
judgmentConfig: syncJudgmentConfigWithMetrics(currentResource.judgmentConfig, metrics),
}
}),
}
})
},
updateMetricThreshold: (resourceType, resourceId, metricId, threshold) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
threshold,
})),
})),
}))
},
addCustomMetric: (resourceType, resourceId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = resource.metrics.some(metric => metric.kind === 'custom-workflow')
? resource.metrics
: [...resource.metrics, createCustomMetric()]
return {
...resource,
metrics,
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
}
}),
}))
},
removeMetric: (resourceType, resourceId, metricId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = resource.metrics.filter(metric => metric.id !== metricId)
return {
...resource,
metrics,
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
}
}),
}))
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflow) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId: workflow.workflowId,
workflowAppId: workflow.workflowAppId,
workflowName: workflow.workflowName,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
outputVariableId: null,
})),
outputs: [],
}
: metric.customConfig,
}))
return {
...resource,
metrics,
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
}
}),
}))
},
syncCustomMetricMappings: (resourceType, resourceId, metricId, inputVariableIds) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: syncCustomMetricMappingsFromUtils(metric.customConfig.mappings, inputVariableIds),
}
: metric.customConfig,
})),
})),
}))
},
syncCustomMetricOutputs: (resourceType, resourceId, metricId, outputs) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
outputs,
}
: metric.customConfig,
}))
return {
...resource,
metrics,
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
}
}),
}))
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
})),
}))
},
setConditionLogicalOperator: (resourceType, resourceId, logicalOperator) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
judgmentConfig: {
...resource.judgmentConfig,
logicalOperator,
},
})),
}))
},
addCondition: (resourceType, resourceId, variableSelector) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
judgmentConfig: {
...resource.judgmentConfig,
conditions: [...resource.judgmentConfig.conditions, buildConditionItem(resource.metrics, variableSelector)],
},
})),
}))
},
removeCondition: (resourceType, resourceId, conditionId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
judgmentConfig: {
...resource.judgmentConfig,
conditions: resource.judgmentConfig.conditions.filter(condition => condition.id !== conditionId),
},
})),
}))
},
updateConditionMetric: (resourceType, resourceId, conditionId, variableSelector) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const allowedOperators = getAllowedOperatorsFromUtils(resource.metrics, variableSelector)
const comparisonOperator = allowedOperators[0]
const metricOption = buildConditionMetricOptions(resource.metrics).find(option =>
option.variableSelector[0] === variableSelector[0] && option.variableSelector[1] === variableSelector[1],
)
return {
...resource,
judgmentConfig: {
...resource.judgmentConfig,
conditions: resource.judgmentConfig.conditions.map(condition => condition.id === conditionId
? {
...condition,
variableSelector,
comparisonOperator,
value: getConditionValue(metricOption?.valueType, comparisonOperator),
}
: condition),
},
}
}),
}))
},
updateConditionOperator: (resourceType, resourceId, conditionId, operator) => {
set((state) => {
return {
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
...currentResource,
judgmentConfig: {
...currentResource.judgmentConfig,
conditions: currentResource.judgmentConfig.conditions.map((condition) => {
if (condition.id !== conditionId)
return condition
const metricOption = buildConditionMetricOptions(currentResource.metrics)
.find(option =>
option.variableSelector[0] === condition.variableSelector?.[0]
&& option.variableSelector[1] === condition.variableSelector?.[1],
)
return {
...condition,
comparisonOperator: operator,
value: getConditionValue(metricOption?.valueType, operator, condition.value),
}
}),
},
})),
}
})
},
updateConditionValue: (resourceType, resourceId, conditionId, value) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
judgmentConfig: {
...resource.judgmentConfig,
conditions: resource.judgmentConfig.conditions.map(condition => condition.id === conditionId ? { ...condition, value } : condition),
},
})),
}))
},
setBatchTab: (resourceType, resourceId, tab) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
activeBatchTab: tab,
})),
}))
},
setUploadedFile: (resourceType, resourceId, uploadedFile) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
uploadedFileId: uploadedFile?.id ?? null,
uploadedFileName: uploadedFile?.name ?? null,
})),
}))
},
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
uploadedFileId: null,
uploadedFileName,
})),
}))
},
setSelectedRunId: (resourceType, resourceId, runId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
selectedRunId: runId,
})),
}))
},
runBatchTest: (resourceType, resourceId) => {
const { uploadedFileName } = get().resources[buildResourceKey(resourceType, resourceId)] ?? buildInitialState(resourceType)
const nextRecord = createBatchTestRecord(resourceType, uploadedFileName)
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
})),
}))
window.setTimeout(() => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
batchRecords: resource.batchRecords.map(record => record.id === nextRecord.id
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
})),
}))
}, 1200)
},
}))
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const getAllowedOperators = (
metrics: EvaluationResourceState['metrics'],
variableSelector: [string, string] | null,
) => {
return getAllowedOperatorsFromUtils(metrics, variableSelector)
}
export const isCustomMetricConfigured = (metric: EvaluationResourceState['metrics'][number]) => {
return isCustomMetricConfiguredFromUtils(metric)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return isEvaluationRunnableFromUtils(state)
}
export const requiresConditionValue = (operator: ComparisonOperator) => {
return requiresConditionValueFromUtils(operator)
}

View File

@ -1,153 +0,0 @@
import type { NodeInfo } from '@/types/evaluation'
export type EvaluationResourceType = 'apps' | 'datasets' | 'snippets'
export type EvaluationResourceProps = {
resourceType: EvaluationResourceType
resourceId: string
}
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum'
export type ConditionMetricValueType = 'string' | 'number' | 'boolean'
export type ComparisonOperator
= | 'contains'
| 'not contains'
| 'start with'
| 'end with'
| 'is'
| 'is not'
| 'empty'
| 'not empty'
| 'in'
| 'not in'
| '='
| '≠'
| '>'
| '<'
| '≥'
| '≤'
| 'is null'
| 'is not null'
export type JudgeModelOption = {
id: string
label: string
provider: string
}
export type MetricOption = {
id: string
label: string
description: string
valueType: ConditionMetricValueType
}
export type EvaluationWorkflowOption = {
id: string
label: string
description: string
targetVariables: Array<{
id: string
label: string
}>
}
export type EvaluationFieldOption = {
id: string
label: string
group: string
type: FieldType
options?: Array<{
value: string
label: string
}>
}
export type CustomMetricMapping = {
id: string
inputVariableId: string | null
outputVariableId: string | null
}
export type CustomMetricConfig = {
workflowId: string | null
workflowAppId: string | null
workflowName: string | null
mappings: CustomMetricMapping[]
outputs: Array<{
id: string
valueType: string | null
}>
}
export type EvaluationMetric = {
id: string
optionId: string
kind: MetricKind
label: string
description: string
valueType: ConditionMetricValueType
threshold?: number
nodeInfoList?: NodeInfo[]
customConfig?: CustomMetricConfig
}
export type JudgmentConditionItem = {
id: string
variableSelector: [string, string] | null
comparisonOperator: ComparisonOperator
value: string | string[] | boolean | null
}
export type JudgmentConfig = {
logicalOperator: 'and' | 'or'
conditions: JudgmentConditionItem[]
}
export type ConditionMetricOption = {
id: string
groupLabel: string
itemLabel: string
valueType: ConditionMetricValueType
variableSelector: [string, string]
}
export type ConditionMetricOptionGroup = {
label: string
options: ConditionMetricOption[]
}
export type BatchTestRecord = {
id: string
fileName: string
status: 'running' | 'success' | 'failed'
startedAt: string
summary: string
}
export type EvaluationResourceState = {
judgeModelId: string | null
metrics: EvaluationMetric[]
judgmentConfig: JudgmentConfig
activeBatchTab: BatchTestTab
uploadedFileId: string | null
uploadedFileName: string | null
selectedRunId: string | null
batchRecords: BatchTestRecord[]
}
export type EvaluationMockConfig = {
judgeModels: JudgeModelOption[]
builtinMetrics: MetricOption[]
workflowOptions: EvaluationWorkflowOption[]
fieldOptions: EvaluationFieldOption[]
templateFileName: string
batchRequirements: string[]
historySummaryLabel: string
}

View File

@ -1,131 +0,0 @@
import type { TFunction } from 'i18next'
import type {
ComparisonOperator,
ConditionMetricOption,
ConditionMetricOptionGroup,
ConditionMetricValueType,
EvaluationMetric,
} from './types'
export const TAB_CLASS_NAME = 'flex-1 rounded-lg px-3 py-2 text-left system-sm-medium'
const rawOperatorLabels = new Set<ComparisonOperator>(['=', '≠', '>', '<', '≥', '≤'])
const noValueOperators = new Set<ComparisonOperator>(['empty', 'not empty', 'is null', 'is not null'])
export const encodeModelSelection = (provider: string, model: string) => `${provider}::${model}`
export const decodeModelSelection = (judgeModelId: string | null) => {
if (!judgeModelId)
return undefined
const [provider, model] = judgeModelId.split('::')
if (!provider || !model)
return undefined
return { provider, model }
}
export const getComparisonOperatorLabel = (
operator: ComparisonOperator,
t: TFunction,
) => {
if (rawOperatorLabels.has(operator))
return operator
return t(`nodes.ifElse.comparisonOperator.${operator}` as never, { ns: 'workflow' } as never) as unknown as string
}
export const requiresComparisonValue = (operator: ComparisonOperator) => {
return !noValueOperators.has(operator)
}
const getMetricValueType = (valueType: string | null | undefined): ConditionMetricValueType => {
if (valueType === 'number' || valueType === 'integer')
return 'number'
if (valueType === 'boolean')
return 'boolean'
return 'string'
}
export const getComparisonOperators = (valueType: ConditionMetricValueType): ComparisonOperator[] => {
if (valueType === 'number')
return ['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null']
if (valueType === 'boolean')
return ['is', 'is not', 'is null', 'is not null']
return ['contains', 'not contains', 'start with', 'end with', 'is', 'is not', 'empty', 'not empty', 'in', 'not in', 'is null', 'is not null']
}
export const getDefaultComparisonOperator = (valueType: ConditionMetricValueType): ComparisonOperator => {
return getComparisonOperators(valueType)[0]
}
export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): ConditionMetricOption[] => {
return metrics.flatMap((metric) => {
if (metric.kind === 'builtin') {
return (metric.nodeInfoList ?? []).map((nodeInfo) => {
return {
id: `${nodeInfo.node_id}:${metric.optionId}`,
groupLabel: metric.label,
itemLabel: nodeInfo.title || nodeInfo.node_id,
valueType: metric.valueType,
variableSelector: [nodeInfo.node_id, metric.optionId] as [string, string],
}
})
}
const customConfig = metric.customConfig
if (!customConfig?.workflowId)
return []
return customConfig.outputs.map((output) => {
return {
id: `${customConfig.workflowId}:${output.id}`,
groupLabel: customConfig.workflowName ?? metric.label,
itemLabel: output.id,
valueType: getMetricValueType(output.valueType),
variableSelector: [customConfig.workflowId, output.id] as [string, string],
}
})
})
}
export const groupConditionMetricOptions = (metricOptions: ConditionMetricOption[]): ConditionMetricOptionGroup[] => {
const groups = metricOptions.reduce<Map<string, ConditionMetricOption[]>>((acc, option) => {
acc.set(option.groupLabel, [...(acc.get(option.groupLabel) ?? []), option])
return acc
}, new Map())
return Array.from(groups.entries()).map(([label, options]) => ({
label,
options,
}))
}
const conditionMetricValueTypeTranslationKeys = {
string: 'conditions.valueTypes.string',
number: 'conditions.valueTypes.number',
boolean: 'conditions.valueTypes.boolean',
} as const
export const getConditionMetricValueTypeTranslationKey = (
valueType: ConditionMetricValueType,
) => {
return conditionMetricValueTypeTranslationKeys[valueType]
}
export const serializeVariableSelector = (value: [string, string] | null | undefined) => {
return value ? JSON.stringify(value) : ''
}
export const isSelectorEqual = (
left: [string, string] | null | undefined,
right: [string, string] | null | undefined,
) => {
return left?.[0] === right?.[0] && left?.[1] === right?.[1]
}

View File

@ -107,7 +107,7 @@ const AppNav = () => {
icon={<RiRobot2Line className="h-4 w-4" />}
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app', 'snippets']}
activeSegment={['apps', 'app']}
link="/apps"
curNav={appDetail}
navigationItems={navItems}

View File

@ -14,7 +14,7 @@ const HeaderWrapper = ({
children,
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')

View File

@ -1,139 +0,0 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetPage from '..'
const mockUseSnippetInit = vi.fn()
const mockSetAppSidebarExpand = vi.fn()
vi.mock('../hooks/use-snippet-init', () => ({
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
}))
vi.mock('../components/snippet-main', () => ({
default: ({ snippetId }: { snippetId: string }) => <div data-testid="snippet-main">{snippetId}</div>,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/app-sidebar/snippet-info', () => ({
default: () => <div data-testid="snippet-info" />,
}))
vi.mock('@/app/components/evaluation', () => ({
default: ({ resourceId }: { resourceId: string }) => <div data-testid="evaluation">{resourceId}</div>,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
author: 'Evan',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [],
uiMeta: {
inputFieldCount: 0,
checklistCount: 0,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetInit.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the orchestrate route shell with independent main content', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('snippet-info')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
})
it('should render loading fallback when orchestrate data is unavailable', () => {
mockUseSnippetInit.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})

View File

@ -1,123 +0,0 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetEvaluationPage from '../snippet-evaluation-page'
const mockUseSnippetApiDetail = vi.fn()
const mockGetSnippetDetailMock = vi.fn()
const mockSetAppSidebarExpand = vi.fn()
vi.mock('@/service/use-snippets', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
return {
...actual,
useSnippetApiDetail: (snippetId: string) => mockUseSnippetApiDetail(snippetId),
useUpdateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
}))
vi.mock('@/service/use-snippets.mock', () => ({
getSnippetDetailMock: (snippetId: string) => mockGetSnippetDetailMock(snippetId),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/evaluation', () => ({
default: ({ resourceId }: { resourceId: string }) => <div data-testid="evaluation">{resourceId}</div>,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
author: 'Evan',
updatedAt: '2024-03-24',
usage: '19',
icon: '🪄',
iconBackground: '#E0EAFF',
},
graph: {
nodes: [],
edges: [],
viewport: {
x: 0,
y: 0,
zoom: 1,
},
},
inputFields: [],
uiMeta: {
inputFieldCount: 0,
checklistCount: 0,
autoSavedAt: '2024-03-24 12:00',
},
}
describe('SnippetEvaluationPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetApiDetail.mockReturnValue({
data: undefined,
isLoading: false,
})
mockGetSnippetDetailMock.mockReturnValue(mockSnippetDetail)
})
it('should render evaluation with mock snippet detail data', () => {
render(<SnippetEvaluationPage snippetId="snippet-1" />)
expect(mockGetSnippetDetailMock).toHaveBeenCalledWith('snippet-1')
expect(mockUseSnippetApiDetail).not.toHaveBeenCalled()
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('evaluation')).toHaveTextContent('snippet-1')
})
})

View File

@ -1,80 +0,0 @@
import type { InputVar } from '@/models/pipeline'
import { render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetInputFieldEditor from '../input-field-editor'
const mockUseFloatingRight = vi.fn()
vi.mock('@/app/components/rag-pipeline/components/panel/input-field/hooks', () => ({
useFloatingRight: (...args: unknown[]) => mockUseFloatingRight(...args),
}))
vi.mock('@/app/components/rag-pipeline/components/panel/input-field/editor/form', () => ({
default: ({ isEditMode }: { isEditMode: boolean }) => (
<div data-testid="snippet-input-field-form">{isEditMode ? 'edit' : 'create'}</div>
),
}))
const createField = (overrides: Partial<InputVar> = {}): InputVar => ({
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
options: [],
placeholder: 'Paste a source article URL',
max_length: 256,
...overrides,
})
describe('SnippetInputFieldEditor', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFloatingRight.mockReturnValue({
floatingRight: false,
floatingRightWidth: 400,
})
})
// Verifies the default desktop layout keeps the editor inline with the panel.
describe('Rendering', () => {
it('should render the add title without floating positioning by default', () => {
render(
<SnippetInputFieldEditor
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
const title = screen.getByText('datasetPipeline.inputFieldPanel.addInputField')
const editor = title.parentElement
expect(title).toBeInTheDocument()
expect(editor).not.toHaveClass('absolute')
expect(editor).toHaveStyle({ width: 'min(400px, calc(100vw - 24px))' })
expect(mockUseFloatingRight).toHaveBeenCalledWith(400)
})
it('should float over the panel when there is not enough room', () => {
mockUseFloatingRight.mockReturnValue({
floatingRight: true,
floatingRightWidth: 320,
})
render(
<SnippetInputFieldEditor
field={createField()}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
const title = screen.getByText('datasetPipeline.inputFieldPanel.editInputField')
const editor = title.parentElement
expect(title).toBeInTheDocument()
expect(editor).toHaveClass('absolute', 'right-0', 'z-[100]')
expect(editor).toHaveStyle({ width: 'min(320px, calc(100vw - 24px))' })
expect(screen.getByTestId('snippet-input-field-form')).toHaveTextContent('edit')
})
})
})

View File

@ -1,22 +0,0 @@
import { render, screen } from '@testing-library/react'
import PublishMenu from '../publish-menu'
describe('PublishMenu', () => {
it('should render the draft summary and publish shortcut', () => {
const { container } = render(
<PublishMenu
uiMeta={{
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
}}
onPublish={vi.fn()}
/>,
)
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
expect(screen.getByText('Auto-saved · a few seconds ago')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.publishButton' })).toBeInTheDocument()
expect(container.querySelectorAll('.system-kbd')).toHaveLength(3)
})
})

Some files were not shown because too many files have changed in this diff Show More