Compare commits

..

5 Commits

Author SHA1 Message Date
-LAN-
beb3ce172d Harden MyScale query parameterization 2026-02-04 19:06:15 +08:00
zxhlyh
cc5705cb71 fix: auto summary env (#31930) 2026-02-04 17:47:38 +08:00
wangxiaolei
74b027c41a fix: fix mcp output schema is union type frontend crash (#31779)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-02-04 17:33:41 +08:00
Stephen Zhou
5f69470ebf test: try fix test, clear test log in CI (#31912) 2026-02-04 17:05:15 +08:00
wangxiaolei
ec7ccd800c fix: fix mcp server status is not right (#31826)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-02-04 16:55:12 +08:00
10 changed files with 179 additions and 67 deletions

View File

@@ -33,6 +33,18 @@ class SortOrder(StrEnum):
class MyScaleVector(BaseVector):
_METADATA_KEY_WHITELIST = {
"annotation_id",
"app_id",
"batch",
"dataset_id",
"doc_hash",
"doc_id",
"document_id",
"lang",
"source",
}
def __init__(self, collection_name: str, config: MyScaleConfig, metric: str = "Cosine"):
super().__init__(collection_name)
self._config = config
@@ -45,10 +57,17 @@ class MyScaleVector(BaseVector):
password=config.password,
)
self._client.command("SET allow_experimental_object_type=1")
self._qualified_table = f"{self._config.database}.{self._collection_name}"
def get_type(self) -> str:
return VectorType.MYSCALE
@classmethod
def _validate_metadata_key(cls, key: str) -> str:
if key not in cls._METADATA_KEY_WHITELIST:
raise ValueError(f"Unsupported metadata key: {key!r}")
return key
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
dimension = len(embeddings[0])
self._create_collection(dimension)
@@ -59,7 +78,7 @@ class MyScaleVector(BaseVector):
self._client.command(f"CREATE DATABASE IF NOT EXISTS {self._config.database}")
fts_params = f"('{self._config.fts_params}')" if self._config.fts_params else ""
sql = f"""
CREATE TABLE IF NOT EXISTS {self._config.database}.{self._collection_name}(
CREATE TABLE IF NOT EXISTS {self._qualified_table}(
id String,
text String,
vector Array(Float32),
@@ -74,23 +93,21 @@ class MyScaleVector(BaseVector):
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
ids = []
columns = ["id", "text", "vector", "metadata"]
values = []
rows = []
for i, doc in enumerate(documents):
if doc.metadata is not None:
doc_id = doc.metadata.get("doc_id", str(uuid.uuid4()))
row = (
doc_id,
self.escape_str(doc.page_content),
embeddings[i],
json.dumps(doc.metadata) if doc.metadata else {},
rows.append(
(
doc_id,
self.escape_str(doc.page_content),
embeddings[i],
json.dumps(doc.metadata or {}),
)
)
values.append(str(row))
ids.append(doc_id)
sql = f"""
INSERT INTO {self._config.database}.{self._collection_name}
({",".join(columns)}) VALUES {",".join(values)}
"""
self._client.command(sql)
if rows:
self._client.insert(self._qualified_table, rows, column_names=columns)
return ids
@staticmethod
@@ -98,49 +115,80 @@ class MyScaleVector(BaseVector):
return "".join(" " if c in {"\\", "'"} else c for c in str(value))
def text_exists(self, id: str) -> bool:
results = self._client.query(f"SELECT id FROM {self._config.database}.{self._collection_name} WHERE id='{id}'")
results = self._client.query(
f"SELECT id FROM {self._qualified_table} WHERE id = %(id)s LIMIT 1",
parameters={"id": id},
)
return results.row_count > 0
def delete_by_ids(self, ids: list[str]):
if not ids:
return
placeholders, params = self._build_in_params("id", ids)
self._client.command(
f"DELETE FROM {self._config.database}.{self._collection_name} WHERE id IN {str(tuple(ids))}"
f"DELETE FROM {self._qualified_table} WHERE id IN ({placeholders})",
parameters=params,
)
def get_ids_by_metadata_field(self, key: str, value: str):
safe_key = self._validate_metadata_key(key)
rows = self._client.query(
f"SELECT DISTINCT id FROM {self._config.database}.{self._collection_name} WHERE metadata.{key}='{value}'"
f"SELECT DISTINCT id FROM {self._qualified_table} WHERE metadata.{safe_key} = %(value)s",
parameters={"value": value},
).result_rows
return [row[0] for row in rows]
def delete_by_metadata_field(self, key: str, value: str):
safe_key = self._validate_metadata_key(key)
self._client.command(
f"DELETE FROM {self._config.database}.{self._collection_name} WHERE metadata.{key}='{value}'"
f"DELETE FROM {self._qualified_table} WHERE metadata.{safe_key} = %(value)s",
parameters={"value": value},
)
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
return self._search(f"distance(vector, {str(query_vector)})", self._vec_order, **kwargs)
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
return self._search(f"TextSearch('enable_nlq=false')(text, '{query}')", SortOrder.DESC, **kwargs)
return self._search(
"TextSearch('enable_nlq=false')(text, %(query)s)",
SortOrder.DESC,
parameters={"query": query},
**kwargs,
)
def _search(self, dist: str, order: SortOrder, **kwargs: Any) -> list[Document]:
@staticmethod
def _build_in_params(prefix: str, values: list[str]) -> tuple[str, dict[str, str]]:
params: dict[str, str] = {}
placeholders = []
for i, value in enumerate(values):
name = f"{prefix}_{i}"
placeholders.append(f"%({name})s")
params[name] = value
return ", ".join(placeholders), params
def _search(
self,
dist: str,
order: SortOrder,
parameters: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Document]:
top_k = kwargs.get("top_k", 4)
if not isinstance(top_k, int) or top_k <= 0:
raise ValueError("top_k must be a positive integer")
score_threshold = float(kwargs.get("score_threshold") or 0.0)
where_str = (
f"WHERE dist < {1 - score_threshold}"
if self._metric.upper() == "COSINE" and order == SortOrder.ASC and score_threshold > 0.0
else ""
)
where_clauses = []
if self._metric.upper() == "COSINE" and order == SortOrder.ASC and score_threshold > 0.0:
where_clauses.append(f"dist < {1 - score_threshold}")
document_ids_filter = kwargs.get("document_ids_filter")
query_params = dict(parameters or {})
if document_ids_filter:
document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
where_str = f"{where_str} AND metadata['document_id'] in ({document_ids})"
placeholders, params = self._build_in_params("document_id", document_ids_filter)
where_clauses.append(f"metadata['document_id'] IN ({placeholders})")
query_params.update(params)
where_str = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
sql = f"""
SELECT text, vector, metadata, {dist} as dist FROM {self._config.database}.{self._collection_name}
SELECT text, vector, metadata, {dist} as dist FROM {self._qualified_table}
{where_str} ORDER BY dist {order.value} LIMIT {top_k}
"""
try:
@@ -150,14 +198,14 @@ class MyScaleVector(BaseVector):
vector=r["vector"],
metadata=r["metadata"],
)
for r in self._client.query(sql).named_results()
for r in self._client.query(sql, parameters=query_params).named_results()
]
except Exception:
logger.exception("Vector search operation failed")
return []
def delete(self):
self._client.command(f"DROP TABLE IF EXISTS {self._config.database}.{self._collection_name}")
self._client.command(f"DROP TABLE IF EXISTS {self._qualified_table}")
class MyScaleVectorFactory(AbstractVectorFactory):

View File

@@ -2031,6 +2031,9 @@ describe('CommonCreateModal', () => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
// Flush pending state updates from createBuilder promise resolution
await act(async () => {})
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'test' } })

View File

@@ -613,6 +613,11 @@ describe('UpdateDSLModal', () => {
expect(importButton).not.toBeDisabled()
})
// Flush the FileReader microtask to ensure fileContent is set
await act(async () => {
await new Promise<void>(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
fireEvent.click(importButton)
@@ -761,6 +766,8 @@ describe('UpdateDSLModal', () => {
})
it('should call importDSLConfirm when confirm button is clicked in error modal', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockImportDSL.mockResolvedValue({
id: 'import-id',
status: DSLImportStatus.PENDING,
@@ -778,20 +785,27 @@ describe('UpdateDSLModal', () => {
const fileInput = screen.getByTestId('file-input')
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } })
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
await new Promise<void>(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
fireEvent.click(importButton)
expect(importButton).not.toBeDisabled()
await act(async () => {
fireEvent.click(importButton)
// Flush the promise resolution from mockImportDSL
await Promise.resolve()
// Advance past the 300ms setTimeout in the component
await vi.advanceTimersByTimeAsync(350)
})
// Wait for error modal
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 500 })
})
// Click confirm button
const confirmButton = screen.getByText('newApp.Confirm')
@@ -800,6 +814,8 @@ describe('UpdateDSLModal', () => {
await waitFor(() => {
expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id')
})
vi.useRealTimers()
})
it('should show success notification after confirm completes', async () => {
@@ -1008,6 +1024,8 @@ describe('UpdateDSLModal', () => {
})
it('should call handleCheckPluginDependencies after confirm', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockImportDSL.mockResolvedValue({
id: 'import-id',
status: DSLImportStatus.PENDING,
@@ -1025,19 +1043,27 @@ describe('UpdateDSLModal', () => {
const fileInput = screen.getByTestId('file-input')
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } })
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
await new Promise<void>(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
fireEvent.click(importButton)
expect(importButton).not.toBeDisabled()
await act(async () => {
fireEvent.click(importButton)
// Flush the promise resolution from mockImportDSL
await Promise.resolve()
// Advance past the 300ms setTimeout in the component
await vi.advanceTimersByTimeAsync(350)
})
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 500 })
})
const confirmButton = screen.getByText('newApp.Confirm')
fireEvent.click(confirmButton)
@@ -1045,6 +1071,8 @@ describe('UpdateDSLModal', () => {
await waitFor(() => {
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true)
})
vi.useRealTimers()
})
it('should handle undefined imported_dsl_version and current_dsl_version', async () => {

View File

@@ -103,15 +103,22 @@ const MCPDetailContent: FC<Props> = ({
return
if (!detail)
return
const res = await authorizeMcp({
provider_id: detail.id,
})
if (res.result === 'success')
handleUpdateTools()
try {
const res = await authorizeMcp({
provider_id: detail.id,
})
if (res.result === 'success')
handleUpdateTools()
else if (res.authorization_url)
openOAuthPopup(res.authorization_url, handleOAuthCallback)
}, [onFirstCreate, isCurrentWorkspaceManager, detail, authorizeMcp, handleUpdateTools, handleOAuthCallback])
else if (res.authorization_url)
openOAuthPopup(res.authorization_url, handleOAuthCallback)
}
catch {
// On authorization error, refresh the parent component state
// to update the connection status indicator
onUpdate()
}
}, [onFirstCreate, isCurrentWorkspaceManager, detail, authorizeMcp, handleUpdateTools, handleOAuthCallback, onUpdate])
const handleUpdate = useCallback(async (data: any) => {
if (!detail)

View File

@@ -1,6 +1,7 @@
import type { ToolNodeType, ToolVarInputs } from './types'
import type { InputVar } from '@/app/components/workflow/types'
import { useBoolean } from 'ahooks'
import { capitalize } from 'es-toolkit/string'
import { produce } from 'immer'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -25,6 +26,12 @@ import {
} from '@/service/use-tools'
import { canFindTool } from '@/utils'
import { useWorkflowStore } from '../../store'
import { normalizeJsonSchemaType } from './output-schema-utils'
const formatDisplayType = (output: Record<string, unknown>): string => {
const normalizedType = normalizeJsonSchemaType(output) || 'Unknown'
return capitalize(normalizedType)
}
const useConfig = (id: string, payload: ToolNodeType) => {
const workflowStore = useWorkflowStore()
@@ -247,20 +254,13 @@ const useConfig = (id: string, payload: ToolNodeType) => {
})
}
else {
const normalizedType = normalizeJsonSchemaType(output)
res.push({
name: outputKey,
type:
output.type === 'array'
? `Array[${output.items?.type
? output.items.type.slice(0, 1).toLocaleUpperCase()
+ output.items.type.slice(1)
: 'Unknown'
}]`
: `${output.type
? output.type.slice(0, 1).toLocaleUpperCase()
+ output.type.slice(1)
: 'Unknown'
}`,
normalizedType === 'array'
? `Array[${output.items ? formatDisplayType(output.items) : 'Unknown'}]`
: formatDisplayType(output),
description: output.description,
})
}

View File

@@ -46,7 +46,7 @@
"uglify-embed": "node ./bin/uglify-embed",
"i18n:check": "tsx ./scripts/check-i18n.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:coverage": "vitest run --coverage --reporter=dot --silent=passed-only",
"test:watch": "vitest --watch",
"analyze-component": "node ./scripts/analyze-component.js",
"refactor-component": "node ./scripts/refactor-component.js",
@@ -233,7 +233,8 @@
"uglify-js": "3.19.3",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.0.4",
"vitest": "4.0.17"
"vitest": "4.0.17",
"vitest-canvas-mock": "1.1.3"
},
"pnpm": {
"overrides": {

26
web/pnpm-lock.yaml generated
View File

@@ -579,6 +579,9 @@ importers:
vitest:
specifier: 4.0.17
version: 4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest-canvas-mock:
specifier: 1.1.3
version: 1.1.3(vitest@4.0.17)
packages:
@@ -4002,6 +4005,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
cssfontparser@1.2.1:
resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==}
cssstyle@5.3.7:
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
engines: {node: '>=20'}
@@ -5751,6 +5757,9 @@ packages:
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
moo-color@1.0.3:
resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -7216,6 +7225,11 @@ packages:
yaml:
optional: true
vitest-canvas-mock@1.1.3:
resolution: {integrity: sha512-zlKJR776Qgd+bcACPh0Pq5MG3xWq+CdkACKY/wX4Jyija0BSz8LH3aCCgwFKYFwtm565+050YFEGG9Ki0gE/Hw==}
peerDependencies:
vitest: ^3.0.0 || ^4.0.0
vitest@4.0.17:
resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -11274,6 +11288,8 @@ snapshots:
cssesc@3.0.0: {}
cssfontparser@1.2.1: {}
cssstyle@5.3.7:
dependencies:
'@asamuzakjp/css-color': 4.1.1
@@ -13573,6 +13589,10 @@ snapshots:
dompurify: 3.2.7
marked: 14.0.0
moo-color@1.0.3:
dependencies:
color-name: 1.1.4
mri@1.2.0: {}
mrmime@2.0.1: {}
@@ -15202,6 +15222,12 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitest-canvas-mock@1.1.3(vitest@4.0.17):
dependencies:
cssfontparser: 1.2.1
moo-color: 1.0.3
vitest: 4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest@4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.17

View File

@@ -1,7 +1,6 @@
import type { App, AppCategory } from '@/models/explore'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { fetchAppList, fetchBanners, fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
import { AppSourceType, fetchAppMeta, fetchAppParams } from './share'
@@ -14,9 +13,8 @@ type ExploreAppListData = {
}
export const useExploreAppList = () => {
const locale = useLocale()
return useQuery<ExploreAppListData>({
queryKey: [NAME_SPACE, 'appList', locale],
queryKey: [NAME_SPACE, 'appList'],
queryFn: async () => {
const { categories, recommended_apps } = await fetchAppList()
return {

View File

@@ -8,7 +8,7 @@ export default mergeConfig(viteConfig, defineConfig({
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'json-summary'],
reporter: ['json', 'json-summary'],
},
},
}))

View File

@@ -1,6 +1,7 @@
import { act, cleanup } from '@testing-library/react'
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
import '@testing-library/jest-dom/vitest'
import 'vitest-canvas-mock'
mockResizeObserver()