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' import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest, { params }: { export async function POST(request: NextRequest, { params }: {
params: { conversationId: string } params: Promise<{ conversationId: string }>
}) { }) {
const body = await request.json() const body = await request.json()
const { const {
auto_generate, auto_generate,
name, name,
} = body } = body
const { conversationId } = params const { conversationId } = await params
const { user } = getInfo(request) const { user } = getInfo(request)
// auto generate name // auto generate name

View File

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

View File

@ -15,10 +15,10 @@ export const getInfo = (request: NextRequest) => {
} }
export const setSession = (sessionId: string) => { export const setSession = (sessionId: string) => {
if (APP_INFO.disable_session_same_site) if (APP_INFO.disable_session_same_site)
return { 'Set-Cookie': `session_id=${sessionId}; SameSite=None; Secure` } { 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) 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 [imagePreviewUrl, setImagePreviewUrl] = useState('')
const imgNum = srcs.length const validSrcs = srcs.filter(src => src && src.trim() !== '')
const imgNum = validSrcs.length
const imgStyle = getWidthStyle(imgNum) const imgStyle = getWidthStyle(imgNum)
if (imgNum === 0) { return null }
return ( return (
<div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')}> <div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')}>
{/* TODO: support preview */} {validSrcs.map((src, index) => (
{srcs.map((src, index) => (
<img <img
key={index} key={index}
className={s.item} className={s.item}

View File

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

View File

@ -1,9 +1,8 @@
'use client' 'use client'
import classNames from 'classnames' import classNames from 'classnames'
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React, { useState } from 'react'
import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972 import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import 'react-tooltip/dist/react-tooltip.css'
interface TooltipProps { interface TooltipProps {
selector: string selector: string
@ -15,6 +14,10 @@ interface TooltipProps {
children: React.ReactNode 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> = ({ const Tooltip: FC<TooltipProps> = ({
selector, selector,
content, content,
@ -24,22 +27,31 @@ const Tooltip: FC<TooltipProps> = ({
className, className,
clickable, clickable,
}) => { }) => {
const [open, setOpen] = useState(false)
const triggerMethod = clickable ? 'click' : 'hover'
return ( return (
<div className='tooltip-container'> <PortalToFollowElem
{React.cloneElement(children as React.ReactElement, { open={open}
'data-tooltip-id': selector, onOpenChange={setOpen}
}) placement={position}
} offset={10}
<ReactTooltip >
id={selector} <PortalToFollowElemTrigger
content={content} data-selector={selector}
className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)} onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
place={position} onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)}
clickable={clickable} onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)}
> >
{htmlContent && htmlContent} {children}
</ReactTooltip> </PortalToFollowElemTrigger>
</div> <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> </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={`${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]'}`}> <div className={`ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl ${workflowProcess && 'min-w-[480px]'}`}>
{workflowProcess && ( {workflowProcess && (

View File

@ -170,7 +170,7 @@ const Chat: FC<IChatProps> = ({
</div> </div>
{ {
!isHideSendInput && ( !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'> <div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
{ {
visionConfig?.enabled && ( visionConfig?.enabled && (
@ -217,8 +217,8 @@ const Chat: FC<IChatProps> = ({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoSize autoSize
/> />
<div className="absolute bottom-2 right-2 flex items-center h-8"> <div className="absolute bottom-2 right-6 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={`${s.count} mr-3 h-5 leading-5 text-sm bg-gray-50 text-gray-500 px-2 rounded`}>{query.trim().length}</div>
<Tooltip <Tooltip
selector='send-tip' selector='send-tip'
htmlContent={ htmlContent={

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ export const APP_INFO: AppInfo = {
copyright: '', copyright: '',
privacy_policy: '', privacy_policy: '',
default_language: 'en', 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 export const isShowPrompt = false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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