Merge branch 'main' into feat/summary-index

This commit is contained in:
zxhlyh
2026-01-13 16:29:09 +08:00
70 changed files with 3706 additions and 851 deletions

View File

@ -31,6 +31,8 @@ NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON=false
# The timeout for the text generation in millisecond
NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=60000
# Used by web/docker/entrypoint.sh to overwrite/export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS at container startup (Docker only)
TEXT_GENERATION_TIMEOUT_MS=60000
# CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
NEXT_PUBLIC_CSP_WHITELIST=

View File

@ -1,5 +1,5 @@
# base image
FROM node:22-alpine3.21 AS base
FROM node:22.21.1-alpine3.23 AS base
LABEL maintainer="takatost@gmail.com"
# if you located in China, you can use aliyun mirror to speed up

View File

@ -54,7 +54,7 @@ const pageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
}
const AmplitudeProvider: FC<IAmplitudeProps> = ({
sessionReplaySampleRate = 1,
sessionReplaySampleRate = 0.5,
}) => {
useEffect(() => {
// Only enable in Saas edition with valid API key

View File

@ -106,12 +106,12 @@ const ConfigPrompt: FC<Props> = ({
const handleAddPrompt = useCallback(() => {
const newPrompt = produce(payload as PromptItem[], (draft) => {
if (draft.length === 0) {
draft.push({ role: PromptRole.system, text: '' })
draft.push({ role: PromptRole.system, text: '', id: uuid4() })
return
}
const isLastItemUser = draft[draft.length - 1].role === PromptRole.user
draft.push({ role: isLastItemUser ? PromptRole.assistant : PromptRole.user, text: '' })
draft.push({ role: isLastItemUser ? PromptRole.assistant : PromptRole.user, text: '', id: uuid4() })
})
onChange(newPrompt)
}, [onChange, payload])

View File

@ -236,7 +236,8 @@
"brace-expansion@<2.0.2": "2.0.2",
"devalue@<5.3.2": "5.3.2",
"es-iterator-helpers": "npm:@nolyfill/es-iterator-helpers@^1",
"esbuild@<0.25.0": "0.25.0",
"esbuild@<0.27.2": "0.27.2",
"glob@>=10.2.0,<10.5.0": "11.1.0",
"hasown": "npm:@nolyfill/hasown@^1",
"is-arguments": "npm:@nolyfill/is-arguments@^1",
"is-core-module": "npm:@nolyfill/is-core-module@^1",
@ -278,7 +279,6 @@
"@types/react-dom": "~19.2.3",
"brace-expansion": "~2.0",
"canvas": "^3.2.0",
"esbuild": "~0.25.0",
"pbkdf2": "~3.1.3",
"prismjs": "~1.30",
"string-width": "~4.2.3"

626
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

231
web/service/base.spec.ts Normal file
View File

@ -0,0 +1,231 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { handleStream } from './base'
describe('handleStream', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Invalid response data handling', () => {
it('should handle null bufferObj from JSON.parse gracefully', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
// Create a mock response that returns 'data: null'
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode('data: null\n'),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('', true, {
conversationId: undefined,
messageId: '',
errorMessage: 'Invalid response data',
errorCode: 'invalid_data',
})
expect(onCompleted).toHaveBeenCalledWith(true, 'Invalid response data')
})
it('should handle non-object bufferObj from JSON.parse gracefully', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
// Create a mock response that returns a primitive value
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode('data: "string"\n'),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('', true, {
conversationId: undefined,
messageId: '',
errorMessage: 'Invalid response data',
errorCode: 'invalid_data',
})
expect(onCompleted).toHaveBeenCalledWith(true, 'Invalid response data')
})
it('should handle valid message event correctly', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
const validMessage = {
event: 'message',
answer: 'Hello world',
conversation_id: 'conv-123',
task_id: 'task-456',
id: 'msg-789',
}
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode(`data: ${JSON.stringify(validMessage)}\n`),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('Hello world', true, {
conversationId: 'conv-123',
taskId: 'task-456',
messageId: 'msg-789',
})
expect(onCompleted).toHaveBeenCalled()
})
it('should handle error status 400 correctly', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
const errorMessage = {
status: 400,
message: 'Bad request',
code: 'bad_request',
}
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode(`data: ${JSON.stringify(errorMessage)}\n`),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('', false, {
conversationId: undefined,
messageId: '',
errorMessage: 'Bad request',
errorCode: 'bad_request',
})
expect(onCompleted).toHaveBeenCalledWith(true, 'Bad request')
})
it('should handle malformed JSON gracefully', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode('data: {invalid json}\n'),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert - malformed JSON triggers the catch block which calls onData and returns
expect(onData).toHaveBeenCalled()
expect(onCompleted).toHaveBeenCalled()
})
it('should throw error when response is not ok', () => {
// Arrange
const onData = vi.fn()
const mockResponse = {
ok: false,
} as unknown as Response
// Act & Assert
expect(() => handleStream(mockResponse, onData)).toThrow('Network response was not ok')
})
})
})

View File

@ -217,6 +217,17 @@ export const handleStream = (
})
return
}
if (!bufferObj || typeof bufferObj !== 'object') {
onData('', isFirstMessage, {
conversationId: undefined,
messageId: '',
errorMessage: 'Invalid response data',
errorCode: 'invalid_data',
})
hasError = true
onCompleted?.(true, 'Invalid response data')
return
}
if (bufferObj.status === 400 || !bufferObj.event) {
onData('', false, {
conversationId: undefined,

View File

@ -19,6 +19,28 @@ describe('formatNumber', () => {
it('should correctly handle empty input', () => {
expect(formatNumber('')).toBe('')
})
it('should format very small numbers without scientific notation', () => {
expect(formatNumber(0.0000008)).toBe('0.0000008')
expect(formatNumber(0.0000001)).toBe('0.0000001')
expect(formatNumber(0.000001)).toBe('0.000001')
expect(formatNumber(0.00001)).toBe('0.00001')
})
it('should format negative small numbers without scientific notation', () => {
expect(formatNumber(-0.0000008)).toBe('-0.0000008')
expect(formatNumber(-0.0000001)).toBe('-0.0000001')
})
it('should handle small numbers from string input', () => {
expect(formatNumber('0.0000008')).toBe('0.0000008')
expect(formatNumber('8E-7')).toBe('0.0000008')
expect(formatNumber('1e-7')).toBe('0.0000001')
})
it('should handle small numbers with multi-digit mantissa in scientific notation', () => {
expect(formatNumber(1.23e-7)).toBe('0.000000123')
expect(formatNumber(1.234e-7)).toBe('0.0000001234')
expect(formatNumber(12.34e-7)).toBe('0.000001234')
expect(formatNumber(0.0001234)).toBe('0.0001234')
expect(formatNumber('1.23e-7')).toBe('0.000000123')
})
})
describe('formatFileSize', () => {
it('should return the input if it is falsy', () => {

View File

@ -26,11 +26,39 @@ import 'dayjs/locale/zh-tw'
* Formats a number with comma separators.
* @example formatNumber(1234567) will return '1,234,567'
* @example formatNumber(1234567.89) will return '1,234,567.89'
* @example formatNumber(0.0000008) will return '0.0000008'
*/
export const formatNumber = (num: number | string) => {
if (!num)
return num
const parts = num.toString().split('.')
const n = typeof num === 'string' ? Number(num) : num
let numStr: string
// Force fixed decimal for small numbers to avoid scientific notation
if (Math.abs(n) < 0.001 && n !== 0) {
const str = n.toString()
const match = str.match(/e-(\d+)$/)
let precision: number
if (match) {
// Scientific notation: precision is exponent + decimal digits in mantissa
const exponent = Number.parseInt(match[1], 10)
const mantissa = str.split('e')[0]
const mantissaDecimalPart = mantissa.split('.')[1]
precision = exponent + (mantissaDecimalPart?.length || 0)
}
else {
// Decimal notation: count decimal places
const decimalPart = str.split('.')[1]
precision = decimalPart?.length || 0
}
numStr = n.toFixed(precision)
}
else {
numStr = n.toString()
}
const parts = numStr.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return parts.join('.')
}