Merge pull request #182 from lyzno1/feature/streamdown-and-fixes

feat: Streamdown integration and UI/UX improvements
This commit is contained in:
crazywoola
2025-09-16 10:27:39 +08:00
committed by GitHub
19 changed files with 77 additions and 60 deletions

View File

@ -3,14 +3,14 @@ import { NextResponse } from 'next/server'
import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest, { params }: {
params: { conversationId: string }
params: Promise<{ conversationId: string }>
}) {
const body = await request.json()
const {
auto_generate,
name,
} = body
const { conversationId } = params
const { conversationId } = await params
const { user } = getInfo(request)
// auto generate name

View File

@ -3,13 +3,13 @@ import { NextResponse } from 'next/server'
import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest, { params }: {
params: { messageId: string }
params: Promise<{ messageId: string }>
}) {
const body = await request.json()
const {
rating,
} = body
const { messageId } = params
const { messageId } = await params
const { user } = getInfo(request)
const { data } = await client.messageFeedback(messageId, rating, user)
return NextResponse.json(data)

View File

@ -15,10 +15,10 @@ export const getInfo = (request: NextRequest) => {
}
export const setSession = (sessionId: string) => {
if (APP_INFO.disable_session_same_site)
return { 'Set-Cookie': `session_id=${sessionId}; SameSite=None; Secure` }
if (APP_INFO.disable_session_same_site)
{ return { 'Set-Cookie': `session_id=${sessionId}; SameSite=None; Secure` } }
return { 'Set-Cookie': `session_id=${sessionId}` }
return { 'Set-Cookie': `session_id=${sessionId}` }
}
export const client = new ChatClient(API_KEY, API_URL || undefined)

View File

@ -32,12 +32,15 @@ const ImageGallery: FC<Props> = ({
}) => {
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const imgNum = srcs.length
const validSrcs = srcs.filter(src => src && src.trim() !== '')
const imgNum = validSrcs.length
const imgStyle = getWidthStyle(imgNum)
if (imgNum === 0) { return null }
return (
<div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')}>
{/* TODO: support preview */}
{srcs.map((src, index) => (
{validSrcs.map((src, index) => (
<img
key={index}
className={s.item}

View File

@ -9,7 +9,7 @@ interface StreamdownMarkdownProps {
export function StreamdownMarkdown({ content, className = '' }: StreamdownMarkdownProps) {
return (
<div className={`markdown-body streamdown-markdown ${className}`}>
<div className={`streamdown-markdown ${className}`}>
<Streamdown>{content}</Streamdown>
</div>
)

View File

@ -1,9 +1,8 @@
'use client'
import classNames from 'classnames'
import type { FC } from 'react'
import React from 'react'
import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972
import 'react-tooltip/dist/react-tooltip.css'
import React, { useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
interface TooltipProps {
selector: string
@ -15,6 +14,10 @@ interface TooltipProps {
children: React.ReactNode
}
const arrow = (
<svg className="absolute text-white h-2 w-full left-0 top-full" x="0px" y="0px" viewBox="0 0 255 255"><polygon className="fill-current" points="0,0 127.5,127.5 255,0"></polygon></svg>
)
const Tooltip: FC<TooltipProps> = ({
selector,
content,
@ -24,22 +27,31 @@ const Tooltip: FC<TooltipProps> = ({
className,
clickable,
}) => {
const [open, setOpen] = useState(false)
const triggerMethod = clickable ? 'click' : 'hover'
return (
<div className='tooltip-container'>
{React.cloneElement(children as React.ReactElement, {
'data-tooltip-id': selector,
})
}
<ReactTooltip
id={selector}
content={content}
className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)}
place={position}
clickable={clickable}
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={position}
offset={10}
>
<PortalToFollowElemTrigger
data-selector={selector}
onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)}
onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)}
>
{htmlContent && htmlContent}
</ReactTooltip>
</div>
{children}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[999]">
<div className={classNames('relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg', className)}>
{htmlContent ?? content}
{arrow}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -187,7 +187,7 @@ const Answer: FC<IAnswerProps> = ({
</div>
)}
</div>
<div className={`${s.answerWrap}`}>
<div className={`${s.answerWrap} max-w-[calc(100%-3rem)]`}>
<div className={`${s.answer} relative text-sm text-gray-900`}>
<div className={`ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl ${workflowProcess && 'min-w-[480px]'}`}>
{workflowProcess && (

View File

@ -170,7 +170,7 @@ const Chat: FC<IChatProps> = ({
</div>
{
!isHideSendInput && (
<div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
<div className='fixed z-10 bottom-0 left-1/2 transform -translate-x-1/2 pc:ml-[122px] tablet:ml-[96px] mobile:ml-0 pc:w-[794px] tablet:w-[794px] max-w-full mobile:w-full px-3.5'>
<div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
{
visionConfig?.enabled && (
@ -217,8 +217,8 @@ const Chat: FC<IChatProps> = ({
onKeyDown={handleKeyDown}
autoSize
/>
<div className="absolute bottom-2 right-2 flex items-center h-8">
<div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div>
<div className="absolute bottom-2 right-6 flex items-center h-8">
<div className={`${s.count} mr-3 h-5 leading-5 text-sm bg-gray-50 text-gray-500 px-2 rounded`}>{query.trim().length}</div>
<Tooltip
selector='send-tip'
htmlContent={

View File

@ -4,7 +4,7 @@ import React from 'react'
import type { IChatItem } from '../type'
import s from '../style.module.css'
import { Markdown } from '@/app/components/base/markdown'
import StreamdownMarkdown from '@/app/components/base/streamdown-markdown'
import ImageGallery from '@/app/components/base/image-gallery'
type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'> & {
@ -23,7 +23,7 @@ const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar, imgSr
{imgSrcs && imgSrcs.length > 0 && (
<ImageGallery srcs={imgSrcs} />
)}
<Markdown content={content} />
<StreamdownMarkdown content={content} />
</div>
</div>
</div>

View File

@ -174,8 +174,15 @@ const Main: FC<IMainProps> = () => {
const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>([])
const chatListDomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// scroll to bottom
if (chatListDomRef.current) { chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight }
// scroll to bottom with page-level scrolling
if (chatListDomRef.current) {
setTimeout(() => {
chatListDomRef.current?.scrollIntoView({
behavior: 'auto',
block: 'end',
})
}, 50)
}
}, [chatList, currConversationId])
// user can not edit inputs if user had send message
const canEditInputs = !chatList.some(item => item.isAnswer === false) && isNewConversation
@ -677,18 +684,16 @@ const Main: FC<IMainProps> = () => {
{
hasSetInputs && (
<div className='relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full pb-[66px] mx-auto mb-3.5 overflow-hidden'>
<div className='h-full overflow-y-auto' ref={chatListDomRef}>
<Chat
chatList={chatList}
onSend={handleSend}
onFeedback={handleFeedback}
isResponding={isResponding}
checkCanSend={checkCanSend}
visionConfig={visionConfig}
fileConfig={fileConfig}
/>
</div>
<div className='relative grow pc:w-[794px] max-w-full mobile:w-full pb-[180px] mx-auto mb-3.5' ref={chatListDomRef}>
<Chat
chatList={chatList}
onSend={handleSend}
onFeedback={handleFeedback}
isResponding={isResponding}
checkCanSend={checkCanSend}
visionConfig={visionConfig}
fileConfig={fileConfig}
/>
</div>)
}
</div>

View File

@ -37,7 +37,6 @@ const Welcome: FC<IWelcomeProps> = ({
savedInputs,
onInputsChange,
}) => {
console.log(promptConfig)
const { t } = useTranslation()
const hasVar = promptConfig.prompt_variables.length > 0
const [isFold, setIsFold] = useState<boolean>(true)

View File

@ -3,12 +3,12 @@ import { getLocaleOnServer } from '@/i18n/server'
import './styles/globals.css'
import './styles/markdown.scss'
const LocaleLayout = ({
const LocaleLayout = async ({
children,
}: {
children: React.ReactNode
}) => {
const locale = getLocaleOnServer()
const locale = await getLocaleOnServer()
return (
<html lang={locale ?? 'en'} className="h-full">
<body className="h-full">

View File

@ -8,7 +8,7 @@ export const APP_INFO: AppInfo = {
copyright: '',
privacy_policy: '',
default_language: 'en',
disable_session_same_site: false, // set it to true if you want to embed the chatbot in an iframe
disable_session_same_site: false, // set it to true if you want to embed the chatbot in an iframe
}
export const isShowPrompt = false

View File

@ -34,4 +34,3 @@ const translation = {
}
export default translation

View File

@ -31,4 +31,3 @@ const translation = {
}
export default translation

View File

@ -101,4 +101,3 @@ const translation = {
}
export default translation

View File

@ -6,19 +6,20 @@ import { match } from '@formatjs/intl-localematcher'
import type { Locale } from '.'
import { i18n } from '.'
export const getLocaleOnServer = (): Locale => {
export const getLocaleOnServer = async (): Promise<Locale> => {
// @ts-expect-error locales are readonly
const locales: string[] = i18n.locales
let languages: string[] | undefined
// get locale from cookie
const localeCookie = cookies().get('locale')
const localeCookie = (await cookies()).get('locale')
languages = localeCookie?.value ? [localeCookie.value] : []
if (!languages.length) {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {}
headers().forEach((value, key) => (negotiatorHeaders[key] = value))
const headersList = await headers()
headersList.forEach((value, key) => (negotiatorHeaders[key] = value))
// Use negotiator and intl-localematcher to get best locale
languages = new Negotiator({ headers: negotiatorHeaders }).languages()
}

View File

@ -51,7 +51,7 @@
"react-i18next": "^12.2.0",
"react-markdown": "^8.0.6",
"react-syntax-highlighter": "^15.5.0",
"react-tooltip": "~5.8.3",
"react-tooltip": "~5.29.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",

View File

@ -112,7 +112,7 @@ export interface AppInfo {
default_language: Locale
copyright?: string
privacy_policy?: string
disable_session_same_site?: boolean
disable_session_same_site?: boolean
}
export enum Resolution {