Merge remote-tracking branch 'origin/main' into feat/trigger

# Conflicts:
#	api/docker/entrypoint.sh
#	api/uv.lock
#	dev/start-worker
#	docker/.env.example
#	docker/docker-compose.yaml
#	web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx
#	web/app/components/base/date-and-time-picker/date-picker/index.tsx
#	web/app/components/base/date-and-time-picker/types.ts
This commit is contained in:
Harry
2025-11-11 12:42:01 +08:00
97 changed files with 6372 additions and 1289 deletions

View File

@ -1,20 +1,23 @@
'use client'
import { TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import React, { useState } from 'react'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import { useTranslation } from 'react-i18next'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { Item } from '@/app/components/base/select'
import { SimpleSelect } from '@/app/components/base/select'
import { AppModeEnum } from '@/types/app'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import TimeRangePicker from './time-range-picker'
dayjs.extend(quarterOfYear)
const today = dayjs()
const TIME_PERIOD_MAPPING = [
{ value: 0, name: 'today' },
{ value: 7, name: 'last7days' },
{ value: 30, name: 'last30days' },
]
const queryDateFormat = 'YYYY-MM-DD HH:mm'
export type IChartViewProps = {
@ -25,23 +28,9 @@ export type IChartViewProps = {
export default function ChartView({ appId, headerRight }: IChartViewProps) {
const { t } = useTranslation()
const appDetail = useAppStore(state => state.appDetail)
const isChatApp = appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW
const isWorkflow = appDetail?.mode === AppModeEnum.WORKFLOW
const [period, setPeriod] = useState<PeriodParams>({ name: t('appLog.filter.period.last7days'), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } })
const onSelect = (item: Item) => {
if (item.value === -1) {
setPeriod({ name: item.name, query: undefined })
}
else if (item.value === 0) {
const startOfToday = today.startOf('day').format(queryDateFormat)
const endOfToday = today.endOf('day').format(queryDateFormat)
setPeriod({ name: item.name, query: { start: startOfToday, end: endOfToday } })
}
else {
setPeriod({ name: item.name, query: { start: today.subtract(item.value as number, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } })
}
}
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>({ name: t('appLog.filter.period.today'), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } })
if (!appDetail)
return null
@ -51,20 +40,11 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
<div className='mb-4'>
<div className='system-xl-semibold mb-2 text-text-primary'>{t('common.appMenus.overview')}</div>
<div className='flex items-center justify-between'>
<div className='flex flex-row items-center'>
<SimpleSelect
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
className='mt-0 !w-40'
notClearable={true}
onSelect={(item) => {
const id = item.value
const value = TIME_PERIOD_MAPPING[id]?.value ?? '-1'
const name = item.name || t('appLog.filter.period.allTime')
onSelect({ value, name })
}}
defaultValue={'2'}
/>
</div>
<TimeRangePicker
ranges={TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
{headerRight}
</div>
</div>

View File

@ -0,0 +1,80 @@
'use client'
import { RiCalendarLine } from '@remixicon/react'
import type { Dayjs } from 'dayjs'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import cn from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format'
import { useI18N } from '@/context/i18n'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { noop } from 'lodash-es'
import dayjs from 'dayjs'
type Props = {
start: Dayjs
end: Dayjs
onStartChange: (date?: Dayjs) => void
onEndChange: (date?: Dayjs) => void
}
const today = dayjs()
const DatePicker: FC<Props> = ({
start,
end,
onStartChange,
onEndChange,
}) => {
const { locale } = useI18N()
const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => {
return (
<div className={cn('system-sm-regular flex h-7 cursor-pointer items-center rounded-lg px-1 text-components-input-text-filled hover:bg-state-base-hover', isOpen && 'bg-state-base-hover')} onClick={handleClickTrigger}>
{value ? formatToLocalTime(value, locale, 'MMM D') : ''}
</div>
)
}, [locale])
const availableStartDate = end.subtract(30, 'day')
const startDateDisabled = useCallback((date: Dayjs) => {
if (date.isAfter(today, 'date'))
return true
return !((date.isAfter(availableStartDate, 'date') || date.isSame(availableStartDate, 'date')) && (date.isBefore(end, 'date') || date.isSame(end, 'date')))
}, [availableStartDate, end])
const availableEndDate = start.add(30, 'day')
const endDateDisabled = useCallback((date: Dayjs) => {
if (date.isAfter(today, 'date'))
return true
return !((date.isAfter(start, 'date') || date.isSame(start, 'date')) && (date.isBefore(availableEndDate, 'date') || date.isSame(availableEndDate, 'date')))
}, [availableEndDate, start])
return (
<div className='flex h-8 items-center space-x-0.5 rounded-lg bg-components-input-bg-normal px-2'>
<div className='p-px'>
<RiCalendarLine className='size-3.5 text-text-tertiary' />
</div>
<Picker
value={start}
onChange={onStartChange}
renderTrigger={renderDate}
needTimePicker={false}
onClear={noop}
noConfirm
getIsDateDisabled={startDateDisabled}
/>
<span className='system-sm-regular text-text-tertiary'>-</span>
<Picker
value={end}
onChange={onEndChange}
renderTrigger={renderDate}
needTimePicker={false}
onClear={noop}
noConfirm
getIsDateDisabled={endDateDisabled}
/>
</div>
)
}
export default React.memo(DatePicker)

View File

@ -0,0 +1,86 @@
'use client'
import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import type { Dayjs } from 'dayjs'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import RangeSelector from './range-selector'
import DatePicker from './date-picker'
import dayjs from 'dayjs'
import { useI18N } from '@/context/i18n'
import { formatToLocalTime } from '@/utils/format'
const today = dayjs()
type Props = {
ranges: { value: number; name: string }[]
onSelect: (payload: PeriodParams) => void
queryDateFormat: string
}
const TimeRangePicker: FC<Props> = ({
ranges,
onSelect,
queryDateFormat,
}) => {
const { locale } = useI18N()
const [isCustomRange, setIsCustomRange] = useState(false)
const [start, setStart] = useState<Dayjs>(today)
const [end, setEnd] = useState<Dayjs>(today)
const handleRangeChange = useCallback((payload: PeriodParamsWithTimeRange) => {
setIsCustomRange(false)
setStart(payload.query!.start)
setEnd(payload.query!.end)
onSelect({
name: payload.name,
query: {
start: payload.query!.start.format(queryDateFormat),
end: payload.query!.end.format(queryDateFormat),
},
})
}, [onSelect, queryDateFormat])
const handleDateChange = useCallback((type: 'start' | 'end') => {
return (date?: Dayjs) => {
if (!date) return
if (type === 'start' && date.isSame(start)) return
if (type === 'end' && date.isSame(end)) return
if (type === 'start')
setStart(date)
else
setEnd(date)
const currStart = type === 'start' ? date : start
const currEnd = type === 'end' ? date : end
onSelect({
name: `${formatToLocalTime(currStart, locale, 'MMM D')} - ${formatToLocalTime(currEnd, locale, 'MMM D')}`,
query: {
start: currStart.format(queryDateFormat),
end: currEnd.format(queryDateFormat),
},
})
setIsCustomRange(true)
}
}, [start, end, onSelect, locale, queryDateFormat])
return (
<div className='flex items-center'>
<RangeSelector
isCustomRange={isCustomRange}
ranges={ranges}
onSelect={handleRangeChange}
/>
<HourglassShape className='h-3.5 w-2 text-components-input-bg-normal' />
<DatePicker
start={start}
end={end}
onStartChange={handleDateChange('start')}
onEndChange={handleDateChange('end')}
/>
</div>
)
}
export default React.memo(TimeRangePicker)

View File

@ -0,0 +1,81 @@
'use client'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { SimpleSelect } from '@/app/components/base/select'
import type { Item } from '@/app/components/base/select'
import dayjs from 'dayjs'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
const today = dayjs()
type Props = {
isCustomRange: boolean
ranges: { value: number; name: string }[]
onSelect: (payload: PeriodParamsWithTimeRange) => void
}
const RangeSelector: FC<Props> = ({
isCustomRange,
ranges,
onSelect,
}) => {
const { t } = useTranslation()
const handleSelectRange = useCallback((item: Item) => {
const { name, value } = item
let period: TimeRange | null = null
if (value === 0) {
const startOfToday = today.startOf('day')
const endOfToday = today.endOf('day')
period = { start: startOfToday, end: endOfToday }
}
else {
period = { start: today.subtract(item.value as number, 'day').startOf('day'), end: today.endOf('day') }
}
onSelect({ query: period!, name })
}, [onSelect])
const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => {
return (
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pl-3 pr-2', isOpen && 'bg-state-base-hover-alt')}>
<div className='system-sm-regular text-components-input-text-filled'>{isCustomRange ? t('appLog.filter.period.custom') : item?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
</div>
)
}, [isCustomRange])
const renderOption = useCallback(({ item, selected }: { item: Item; selected: boolean }) => {
return (
<>
{selected && (
<span
className={cn(
'absolute left-2 top-[9px] flex items-center text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
<span className={cn('system-md-regular block truncate')}>{item.name}</span>
</>
)
}, [])
return (
<SimpleSelect
items={ranges.map(v => ({ ...v, name: t(`appLog.filter.period.${v.name}`) }))}
className='mt-0 !w-40'
notClearable={true}
onSelect={handleSelectRange}
defaultValue={0}
wrapperClassName='h-8'
optionWrapClassName='w-[200px] translate-x-[-24px]'
renderTrigger={renderTrigger}
optionClassName='flex items-center py-0 pl-7 pr-2 h-8'
renderOption={renderOption}
/>
)
}
export default React.memo(RangeSelector)

View File

@ -4,6 +4,7 @@ import React from 'react'
import ReactECharts from 'echarts-for-react'
import type { EChartsOption } from 'echarts'
import useSWR from 'swr'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { get } from 'lodash-es'
import Decimal from 'decimal.js'
@ -78,6 +79,16 @@ export type PeriodParams = {
}
}
export type TimeRange = {
start: Dayjs
end: Dayjs
}
export type PeriodParamsWithTimeRange = {
name: string
query?: TimeRange
}
export type IBizChartProps = {
period: PeriodParams
id: string
@ -215,9 +226,7 @@ const Chart: React.FC<IChartProps> = ({
formatter(params) {
return `<div style='color:#6B7280;font-size:12px'>${params.name}</div>
<div style='font-size:14px;color:#1F2A37'>${valueFormatter((params.data as any)[yField])}
${!CHART_TYPE_CONFIG[chartType].showTokens
? ''
: `<span style='font-size:12px'>
${!CHART_TYPE_CONFIG[chartType].showTokens ? '' : `<span style='font-size:12px'>
<span style='margin-left:4px;color:#6B7280'>(</span>
<span style='color:#FF8A4C'>~$${get(params.data, 'total_price', 0)}</span>
<span style='color:#6B7280'>)</span>

View File

@ -8,9 +8,10 @@ const Calendar: FC<CalendarProps> = ({
selectedDate,
onDateClick,
wrapperClassName,
getIsDateDisabled,
}) => {
return <div className={wrapperClassName}>
<DaysOfWeek/>
<DaysOfWeek />
<div className='grid grid-cols-7 gap-0.5 p-2'>
{
days.map(day => <CalendarItem
@ -18,6 +19,7 @@ const Calendar: FC<CalendarProps> = ({
day={day}
selectedDate={selectedDate}
onClick={onDateClick}
isDisabled={getIsDateDisabled ? getIsDateDisabled(day.date) : false}
/>)
}
</div>

View File

@ -7,6 +7,7 @@ const Item: FC<CalendarItemProps> = ({
day,
selectedDate,
onClick,
isDisabled,
}) => {
const { date, isCurrentMonth } = day
const isSelected = selectedDate?.isSame(date, 'date')
@ -14,11 +15,12 @@ const Item: FC<CalendarItemProps> = ({
return (
<button type="button"
onClick={() => onClick(date)}
onClick={() => !isDisabled && onClick(date)}
className={cn(
'system-sm-medium relative flex items-center justify-center rounded-lg px-1 py-2',
isCurrentMonth ? 'text-text-secondary' : 'text-text-quaternary hover:text-text-secondary',
isSelected ? 'system-sm-medium bg-components-button-primary-bg text-components-button-primary-text' : 'hover:bg-state-base-hover',
isDisabled && 'cursor-not-allowed text-text-quaternary hover:bg-transparent',
)}
>
{date.date()}

View File

@ -36,7 +36,8 @@ const DatePicker = ({
renderTrigger,
triggerWrapClassName,
popupZIndexClassname = 'z-[11]',
notClearable = false,
noConfirm,
getIsDateDisabled,
}: DatePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
@ -121,11 +122,20 @@ const DatePicker = ({
setCurrentDate(currentDate.clone().subtract(1, 'month'))
}, [currentDate])
const handleConfirmDate = useCallback((passedInSelectedDate?: Dayjs) => {
// passedInSelectedDate may be a click event when noConfirm is false
const nextDate = (dayjs.isDayjs(passedInSelectedDate) ? passedInSelectedDate : selectedDate)
onChange(nextDate ? nextDate.tz(timezone) : undefined)
setIsOpen(false)
}, [selectedDate, onChange, timezone])
const handleDateSelect = useCallback((day: Dayjs) => {
const newDate = cloneTime(day, selectedDate || getDateWithTimezone({ timezone }))
setCurrentDate(newDate)
setSelectedDate(newDate)
}, [selectedDate, timezone])
if (noConfirm)
handleConfirmDate(newDate)
}, [selectedDate, timezone, noConfirm, handleConfirmDate])
const handleSelectCurrentDate = () => {
const newDate = getDateWithTimezone({ timezone })
@ -135,12 +145,6 @@ const DatePicker = ({
setIsOpen(false)
}
const handleConfirmDate = () => {
// debugger
onChange(selectedDate ? selectedDate.tz(timezone) : undefined)
setIsOpen(false)
}
const handleClickTimePicker = () => {
if (view === ViewType.date) {
setView(ViewType.time)
@ -208,7 +212,7 @@ const DatePicker = ({
<PortalToFollowElem
open={isOpen}
onOpenChange={setIsOpen}
placement='bottom-start'
placement='bottom-end'
>
<PortalToFollowElemTrigger className={triggerWrapClassName}>
{renderTrigger ? (renderTrigger({
@ -232,17 +236,15 @@ const DatePicker = ({
<RiCalendarLine className={cn(
'h-4 w-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedDate)) && !notClearable && 'group-hover:hidden',
(displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
)} />
{!notClearable && (
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedDate)) && 'hover:text-text-secondary group-hover:inline-block',
)}
onClick={handleClear}
/>
)}
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedDate)) && 'hover:text-text-secondary group-hover:inline-block',
)}
onClick={handleClear}
/>
</div>
)}
</PortalToFollowElemTrigger>
@ -273,6 +275,7 @@ const DatePicker = ({
days={days}
selectedDate={selectedDate}
onDateClick={handleDateSelect}
getIsDateDisabled={getIsDateDisabled}
/>
) : view === ViewType.yearMonth ? (
<YearAndMonthPickerOptions
@ -293,7 +296,7 @@ const DatePicker = ({
{/* Footer */}
{
[ViewType.date, ViewType.time].includes(view) ? (
[ViewType.date, ViewType.time].includes(view) && !noConfirm && (
<DatePickerFooter
needTimePicker={needTimePicker}
displayTime={displayTime}
@ -302,7 +305,10 @@ const DatePicker = ({
handleSelectCurrentDate={handleSelectCurrentDate}
handleConfirmDate={handleConfirmDate}
/>
) : (
)
}
{
![ViewType.date, ViewType.time].includes(view) && (
<YearAndMonthPickerFooter
handleYearMonthCancel={handleYearMonthCancel}
handleYearMonthConfirm={handleYearMonthConfirm}

View File

@ -30,7 +30,8 @@ export type DatePickerProps = {
renderTrigger?: (props: TriggerProps) => React.ReactNode
minuteFilter?: (minutes: string[]) => string[]
popupZIndexClassname?: string
notClearable?: boolean
noConfirm?: boolean
getIsDateDisabled?: (date: Dayjs) => boolean
}
export type DatePickerHeaderProps = {
@ -64,12 +65,6 @@ export type TimePickerProps = {
title?: string
minuteFilter?: (minutes: string[]) => string[]
popupClassName?: string
notClearable?: boolean
triggerFullWidth?: boolean
/** Show timezone label inline with the time picker */
showTimezone?: boolean
/** Placement of the popup relative to the trigger */
placement?: 'bottom-start' | 'bottom-end' | 'bottom'
}
export type TimePickerFooterProps = {
@ -87,12 +82,14 @@ export type CalendarProps = {
selectedDate: Dayjs | undefined
onDateClick: (date: Dayjs) => void
wrapperClassName?: string
getIsDateDisabled?: (date: Dayjs) => boolean
}
export type CalendarItemProps = {
day: Day
selectedDate: Dayjs | undefined
onClick: (date: Dayjs) => void
isDisabled: boolean
}
export type TimeOptionsProps = {

View File

@ -0,0 +1,3 @@
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 14C8 11.7909 6.20914 10 4 10C1.79086 10 0 11.7909 0 14V0C8.05332e-08 2.20914 1.79086 4 4 4C6.20914 4 8 2.20914 8 0V14Z" fill="#C8CEDA" fill-opacity="1"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@ -0,0 +1,27 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "8",
"height": "14",
"viewBox": "0 0 8 14",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M8 14C8 11.7909 6.20914 10 4 10C1.79086 10 0 11.7909 0 14V0C8.05332e-08 2.20914 1.79086 4 4 4C6.20914 4 8 2.20914 8 0V14Z",
"fill": "currentColor",
"fill-opacity": "1"
},
"children": []
}
]
},
"name": "HourglassShape"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './HourglassShape.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'HourglassShape'
export default Icon

View File

@ -1,6 +1,7 @@
export { default as AnthropicText } from './AnthropicText'
export { default as Generator } from './Generator'
export { default as Group } from './Group'
export { default as HourglassShape } from './HourglassShape'
export { default as Mcp } from './Mcp'
export { default as NoToolPlaceholder } from './NoToolPlaceholder'
export { default as Openai } from './Openai'

View File

@ -34,7 +34,7 @@ export type Item = {
export type ISelectProps = {
className?: string
wrapperClassName?: string
renderTrigger?: (value: Item | null) => React.JSX.Element | null
renderTrigger?: (value: Item | null, isOpen: boolean) => React.JSX.Element | null
items?: Item[]
defaultValue?: number | string
disabled?: boolean
@ -222,7 +222,7 @@ const SimpleSelect: FC<ISelectProps> = ({
>
{({ open }) => (
<div className={classNames('group/simple-select relative h-9', wrapperClassName)}>
{renderTrigger && <ListboxButton className='w-full'>{renderTrigger(selectedItem)}</ListboxButton>}
{renderTrigger && <ListboxButton className='w-full'>{renderTrigger(selectedItem, open)}</ListboxButton>}
{!renderTrigger && (
<ListboxButton onClick={() => {
onOpenChange?.(open)

View File

@ -74,7 +74,8 @@ Chat applications support session persistence, allowing previous chat history to
If set to `false`, can achieve async title generation by calling the conversation rename API and setting `auto_generate` to `true`.
</Property>
<Property name='workflow_id' type='string' key='workflow_id'>
(Optional) Workflow ID to specify a specific version, if not provided, uses the default published version.
(Optional) Workflow ID to specify a specific version, if not provided, uses the default published version.<br/>
How to obtain: In the version history interface, click the copy icon on the right side of each version entry to copy the complete workflow ID.
</Property>
<Property name='trace_id' type='string' key='trace_id'>
(Optional) Trace ID. Used for integration with existing business trace components to achieve end-to-end distributed tracing. If not provided, the system will automatically generate a trace_id. Supports the following three ways to pass, in order of priority:<br/>

View File

@ -74,7 +74,8 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
`false`に設定すると、会話のリネームAPIを呼び出し、`auto_generate`を`true`に設定することで非同期タイトル生成を実現できます。
</Property>
<Property name='workflow_id' type='string' key='workflow_id'>
オプションワークフローID、特定のバージョンを指定するために使用、提供されない場合はデフォルトの公開バージョンを使用。
オプションワークフローID、特定のバージョンを指定するために使用、提供されない場合はデフォルトの公開バージョンを使用。<br/>
取得方法バージョン履歴インターフェースで、各バージョンエントリの右側にあるコピーアイコンをクリックすると、完全なワークフローIDをコピーできます。
</Property>
<Property name='trace_id' type='string' key='trace_id'>
オプショントレースID。既存の業務システムのトレースコンポーネントと連携し、エンドツーエンドの分散トレーシングを実現するために使用します。指定がない場合、システムが自動的に trace_id を生成します。以下の3つの方法で渡すことができ、優先順位は次のとおりです<br/>

View File

@ -72,7 +72,8 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
(选填)自动生成标题,默认 `true`。 若设置为 `false`,则可通过调用会话重命名接口并设置 `auto_generate` 为 `true` 实现异步生成标题。
</Property>
<Property name='workflow_id' type='string' key='workflow_id'>
选填工作流ID用于指定特定版本如果不提供则使用默认的已发布版本。
选填工作流ID用于指定特定版本如果不提供则使用默认的已发布版本。<br/>
获取方式:在版本历史界面,点击每个版本条目右侧的复制图标即可复制完整的工作流 ID。
</Property>
<Property name='trace_id' type='string' key='trace_id'>
选填链路追踪ID。适用于与业务系统已有的trace组件打通实现端到端分布式追踪等场景。如果未指定系统会自动生成<code>trace_id</code>。支持以下三种方式传递,具体优先级依次为:<br/>

View File

@ -344,7 +344,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
### パス
- `workflow_id` (string) 必須 特定バージョンのワークフローを指定するためのワークフローID
取得方法:バージョン履歴で特定バージョンのワークフローIDを照会できます。
取得方法:バージョン履歴インターフェースで、各バージョンエントリの右側にあるコピーアイコンをクリックすると、完全なワークフローIDをコピーできます。
### リクエストボディ
- `inputs` (object) 必須

View File

@ -334,7 +334,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
### Path
- `workflow_id` (string) Required 工作流ID用于指定特定版本的工作流
获取方式:可以在版本历史中查询特定版本的工作流ID。
获取方式:在版本历史界面,点击每个版本条目右侧的复制图标即可复制完整的工作流 ID。
### Request Body
- `inputs` (object) Required

View File

@ -86,7 +86,7 @@ const ModelList: FC<ModelListProps> = ({
{
models.map(model => (
<ModelListItem
key={`${model.model}-${model.fetch_from}`}
key={`${model.model}-${model.model_type}-${model.fetch_from}`}
{...{
model,
provider,

View File

@ -856,6 +856,18 @@
color: var(--color-prettylights-syntax-comment);
}
.markdown-body .katex {
/* Allow long inline formulas to wrap instead of overflowing */
white-space: normal !important;
overflow-wrap: break-word; /* better cross-browser support */
word-break: break-word; /* non-standard fallback for older WebKit/Blink */
}
.markdown-body .katex-display {
/* Fallback for very long display equations */
overflow-x: auto;
}
.markdown-body .pl-c1,
.markdown-body .pl-s .pl-v {
color: var(--color-prettylights-syntax-constant);

View File

@ -10,6 +10,7 @@ import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { noop } from 'lodash-es'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { useGlobalPublicStore } from './global-public-context'
export type AppContextValue = {
userProfile: UserProfileResponse
@ -77,6 +78,7 @@ export type AppContextProviderProps = {
}
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: userProfileResponse, mutate: mutateUserProfile, error: userProfileError } = useSWR({ url: '/account/profile', params: {} }, fetchUserProfile)
const { data: currentWorkspaceResponse, mutate: mutateCurrentWorkspace, isLoading: isLoadingCurrentWorkspace } = useSWR({ url: '/workspaces/current', params: {} }, fetchCurrentWorkspace)
@ -92,10 +94,12 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
try {
const result = await userProfileResponse.json()
setUserProfile(result)
const current_version = userProfileResponse.headers.get('x-version')
const current_env = process.env.NODE_ENV === 'development' ? 'DEVELOPMENT' : userProfileResponse.headers.get('x-env')
const versionData = await fetchLangGeniusVersion({ url: '/version', params: { current_version } })
setLangGeniusVersionInfo({ ...versionData, current_version, latest_version: versionData.version, current_env })
if (!systemFeatures.branding.enabled) {
const current_version = userProfileResponse.headers.get('x-version')
const current_env = process.env.NODE_ENV === 'development' ? 'DEVELOPMENT' : userProfileResponse.headers.get('x-env')
const versionData = await fetchLangGeniusVersion({ url: '/version', params: { current_version } })
setLangGeniusVersionInfo({ ...versionData, current_version, latest_version: versionData.version, current_env })
}
}
catch (error) {
console.error('Failed to update user profile:', error)

View File

@ -66,6 +66,8 @@ const translation = {
quarterToDate: 'Quartal bis heute',
yearToDate: 'Jahr bis heute',
allTime: 'Gesamte Zeit',
last30days: 'Letzte 30 Tage',
custom: 'Benutzerdefiniert',
},
annotation: {
all: 'Alle',

View File

@ -60,6 +60,7 @@ const translation = {
period: {
today: 'Today',
last7days: 'Last 7 Days',
last30days: 'Last 30 Days',
last4weeks: 'Last 4 weeks',
last3months: 'Last 3 months',
last12months: 'Last 12 months',
@ -67,6 +68,7 @@ const translation = {
quarterToDate: 'Quarter to date',
yearToDate: 'Year to date',
allTime: 'All time',
custom: 'Custom',
},
annotation: {
all: 'All',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Trimestre hasta la fecha',
yearToDate: 'Año hasta la fecha',
allTime: 'Todo el tiempo',
custom: 'Personalizado',
last30days: 'Últimos 30 días',
},
annotation: {
all: 'Todos',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'از ابتدای فصل تاکنون',
yearToDate: 'از ابتدای سال تاکنون',
allTime: 'همه زمان‌ها',
last30days: '۳۰ روز گذشته',
custom: 'سفارشی',
},
annotation: {
all: 'همه',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Trimestre à ce jour',
yearToDate: 'Année à ce jour',
allTime: 'Tout le temps',
custom: 'Personnalisé',
last30days: 'Derniers 30 jours',
},
annotation: {
all: 'Tous',

View File

@ -67,6 +67,8 @@ const translation = {
quarterToDate: 'तिमाही तक तिथि',
yearToDate: 'वर्ष तक तिथि',
allTime: 'सभी समय',
last30days: 'पिछले 30 दिन',
custom: 'कस्टम',
},
annotation: {
all: 'सभी',

View File

@ -60,6 +60,8 @@ const translation = {
yearToDate: 'Tahun hingga saat ini',
allTime: 'Sepanjang masa',
last12months: '12 bulan terakhir',
custom: 'Kustom',
last30days: '30 Hari Terakhir',
},
annotation: {
all: 'Semua',

View File

@ -69,6 +69,8 @@ const translation = {
quarterToDate: 'Trimestre corrente',
yearToDate: 'Anno corrente',
allTime: 'Tutto il tempo',
custom: 'Personalizzato',
last30days: 'Ultimi 30 giorni',
},
annotation: {
all: 'Tutti',

View File

@ -60,6 +60,7 @@ const translation = {
period: {
today: '今日',
last7days: '過去 7 日間',
last30days: '過去 30 日間',
last4weeks: '過去 4 週間',
last3months: '過去 3 ヶ月',
last12months: '過去 12 ヶ月',
@ -67,6 +68,7 @@ const translation = {
quarterToDate: '四半期初から今日まで',
yearToDate: '年初から今日まで',
allTime: 'すべての期間',
custom: 'カスタム',
},
annotation: {
all: 'すべて',

View File

@ -66,6 +66,8 @@ const translation = {
quarterToDate: '분기 초부터 오늘까지',
yearToDate: '연 초부터 오늘까지',
allTime: '모든 기간',
last30days: '최근 30일',
custom: '사용자 정의',
},
annotation: {
all: '모두',

View File

@ -69,6 +69,8 @@ const translation = {
quarterToDate: 'Od początku kwartału',
yearToDate: 'Od początku roku',
allTime: 'Cały czas',
custom: 'Niestandardowy',
last30days: 'Ostatnie 30 dni',
},
annotation: {
all: 'Wszystkie',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Trimestre até hoje',
yearToDate: 'Ano até hoje',
allTime: 'Todo o tempo',
custom: 'Personalizado',
last30days: 'Últimos 30 Dias',
},
annotation: {
all: 'Tudo',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Trimestrul curent',
yearToDate: 'Anul curent',
allTime: 'Tot timpul',
custom: 'Personalizat',
last30days: 'Ultimele 30 de zile',
},
annotation: {
all: 'Toate',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'С начала квартала',
yearToDate: 'С начала года',
allTime: 'Все время',
last30days: 'Последние 30 дней',
custom: 'Кастомный',
},
annotation: {
all: 'Все',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Četrtletje do danes',
yearToDate: 'Leto do danes',
allTime: 'Vse obdobje',
last30days: 'Zadnjih 30 dni',
custom: 'Po meri',
},
annotation: {
all: 'Vse',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'ไตรมาสจนถึงปัจจุบัน',
yearToDate: 'ปีจนถึงปัจจุบัน',
allTime: 'ตลอดเวลา',
last30days: '30 วันที่ผ่านมา',
custom: 'กำหนดเอง',
},
annotation: {
all: 'ทั้งหมด',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Çeyrek Başlangıcından İtibaren',
yearToDate: 'Yıl Başlangıcından İtibaren',
allTime: 'Tüm Zamanlar',
custom: 'Özel',
last30days: 'Son 30 Gün',
},
annotation: {
all: 'Hepsi',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Квартал до сьогодні',
yearToDate: 'Рік до сьогодні',
allTime: 'За весь час',
last30days: 'Останні 30 днів',
custom: 'Користувацький',
},
annotation: {
all: 'Всі',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: 'Quý hiện tại',
yearToDate: 'Năm hiện tại',
allTime: 'Tất cả thời gian',
custom: 'Tùy chỉnh',
last30days: '30 Ngày Qua',
},
annotation: {
all: 'Tất cả',

View File

@ -60,6 +60,7 @@ const translation = {
period: {
today: '今天',
last7days: '过去 7 天',
last30days: '过去 30 天',
last4weeks: '过去 4 周',
last3months: '过去 3 月',
last12months: '过去 12 月',
@ -67,6 +68,7 @@ const translation = {
quarterToDate: '本季度至今',
yearToDate: '本年至今',
allTime: '所有时间',
custom: '自定义',
},
annotation: {
all: '全部',

View File

@ -65,6 +65,8 @@ const translation = {
quarterToDate: '本季度至今',
yearToDate: '本年至今',
allTime: '所有時間',
last30days: '過去30天',
custom: '自訂',
},
annotation: {
all: '全部',

View File

@ -1,3 +1,50 @@
import type { Locale } from '@/i18n-config'
import type { Dayjs } from 'dayjs'
import 'dayjs/locale/de'
import 'dayjs/locale/es'
import 'dayjs/locale/fa'
import 'dayjs/locale/fr'
import 'dayjs/locale/hi'
import 'dayjs/locale/id'
import 'dayjs/locale/it'
import 'dayjs/locale/ja'
import 'dayjs/locale/ko'
import 'dayjs/locale/pl'
import 'dayjs/locale/pt-br'
import 'dayjs/locale/ro'
import 'dayjs/locale/ru'
import 'dayjs/locale/sl'
import 'dayjs/locale/th'
import 'dayjs/locale/tr'
import 'dayjs/locale/uk'
import 'dayjs/locale/vi'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/zh-tw'
const localeMap: Record<Locale, string> = {
'en-US': 'en',
'zh-Hans': 'zh-cn',
'zh-Hant': 'zh-tw',
'pt-BR': 'pt-br',
'es-ES': 'es',
'fr-FR': 'fr',
'de-DE': 'de',
'ja-JP': 'ja',
'ko-KR': 'ko',
'ru-RU': 'ru',
'it-IT': 'it',
'th-TH': 'th',
'id-ID': 'id',
'uk-UA': 'uk',
'vi-VN': 'vi',
'ro-RO': 'ro',
'pl-PL': 'pl',
'hi-IN': 'hi',
'tr-TR': 'tr',
'fa-IR': 'fa',
'sl-SI': 'sl',
}
/**
* Formats a number with comma separators.
* @example formatNumber(1234567) will return '1,234,567'
@ -90,3 +137,7 @@ export const formatNumberAbbreviated = (num: number) => {
}
}
}
export const formatToLocalTime = (time: Dayjs, local: string, format: string) => {
return time.locale(localeMap[local] ?? 'en').format(format)
}

View File

@ -45,5 +45,118 @@ describe('get-icon', () => {
const result = getIconFromMarketPlace(pluginId)
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`)
})
/**
* Security tests: Path traversal attempts
* These tests document current behavior and potential security concerns
* Note: Current implementation does not sanitize path traversal sequences
*/
test('handles path traversal attempts', () => {
const pluginId = '../../../etc/passwd'
const result = getIconFromMarketPlace(pluginId)
// Current implementation includes path traversal sequences in URL
// This is a potential security concern that should be addressed
expect(result).toContain('../')
expect(result).toContain(pluginId)
})
test('handles multiple path traversal attempts', () => {
const pluginId = '../../../../etc/passwd'
const result = getIconFromMarketPlace(pluginId)
// Current implementation includes path traversal sequences in URL
expect(result).toContain('../')
expect(result).toContain(pluginId)
})
test('passes through URL-encoded path traversal sequences', () => {
const pluginId = '..%2F..%2Fetc%2Fpasswd'
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
})
/**
* Security tests: Null and undefined handling
* These tests document current behavior with invalid input types
* Note: Current implementation converts null/undefined to strings instead of throwing
*/
test('handles null plugin ID', () => {
// Current implementation converts null to string "null"
const result = getIconFromMarketPlace(null as any)
expect(result).toContain('null')
// This is a potential issue - should validate input type
})
test('handles undefined plugin ID', () => {
// Current implementation converts undefined to string "undefined"
const result = getIconFromMarketPlace(undefined as any)
expect(result).toContain('undefined')
// This is a potential issue - should validate input type
})
/**
* Security tests: URL-sensitive characters
* These tests verify that URL-sensitive characters are handled appropriately
*/
test('does not encode URL-sensitive characters', () => {
const pluginId = 'plugin/with?special=chars#hash'
const result = getIconFromMarketPlace(pluginId)
// Note: Current implementation doesn't encode, but test documents the behavior
expect(result).toContain(pluginId)
expect(result).toContain('?')
expect(result).toContain('#')
expect(result).toContain('=')
})
test('handles URL characters like & and %', () => {
const pluginId = 'plugin&with%encoding'
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
})
/**
* Edge case tests: Extreme inputs
* These tests verify behavior with unusual but valid inputs
*/
test('handles very long plugin ID', () => {
const pluginId = 'a'.repeat(10000)
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
expect(result.length).toBeGreaterThan(10000)
})
test('handles Unicode characters', () => {
const pluginId = '插件-🚀-测试-日本語'
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
})
test('handles control characters', () => {
const pluginId = 'plugin\nwith\ttabs\r\nand\0null'
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
})
/**
* Security tests: XSS attempts
* These tests verify that XSS attempts are handled appropriately
*/
test('handles XSS attempts with script tags', () => {
const pluginId = '<script>alert("xss")</script>'
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
// Note: Current implementation doesn't sanitize, but test documents the behavior
})
test('handles XSS attempts with event handlers', () => {
const pluginId = 'plugin"onerror="alert(1)"'
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
})
test('handles XSS attempts with encoded script tags', () => {
const pluginId = '%3Cscript%3Ealert%28%22xss%22%29%3C%2Fscript%3E'
const result = getIconFromMarketPlace(pluginId)
expect(result).toContain(pluginId)
})
})
})

View File

@ -87,7 +87,8 @@ describe('time', () => {
test('works with timestamps', () => {
const date = 1705276800000 // 2024-01-15 00:00:00 UTC
const result = formatTime({ date, dateFormat: 'YYYY-MM-DD' })
expect(result).toContain('2024-01-1') // Account for timezone differences
// Account for timezone differences: UTC-5 to UTC+8 can result in 2024-01-14 or 2024-01-15
expect(result).toMatch(/^2024-01-(14|15)$/)
})
test('handles ISO 8601 format', () => {