mirror of
https://github.com/langgenius/dify.git
synced 2026-04-10 23:57:56 +08:00
Compare commits
8 Commits
feat/evalu
...
docs/e2e-w
| Author | SHA1 | Date | |
|---|---|---|---|
| f0cc019f7e | |||
| 7237aa5eb8 | |||
| 66183c1f0a | |||
| 130ad295d0 | |||
| 6612ba69b1 | |||
| 2dc015b360 | |||
| 2eb43b1e1f | |||
| 8633b2f1f7 |
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
134
e2e/AGENTS.md
134
e2e/AGENTS.md
@ -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
|
||||
|
||||
@ -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 |
@ -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
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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' }))
|
||||
|
||||
|
||||
@ -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}/`)
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -1,7 +0,0 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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' })
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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 || ''}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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
|
||||
@ -1 +1,2 @@
|
||||
export { default as BracketsX } from './BracketsX'
|
||||
export { default as CodeBrowser } from './CodeBrowser'
|
||||
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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' })
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
Reference in New Issue
Block a user