From 2f431ade1325dc636a789b7f17ccb05e65106559 Mon Sep 17 00:00:00 2001 From: dext-stu Date: Tue, 5 May 2026 04:33:27 +0800 Subject: [PATCH 1/3] fix(playground): render math formulas in markdown responses --- .../src/components/ai-elements/response.tsx | 12 +++++++++++- web/default/src/components/ui/markdown.tsx | 12 +++++++++--- web/default/src/lib/markdown-math.ts | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 web/default/src/lib/markdown-math.ts diff --git a/web/default/src/components/ai-elements/response.tsx b/web/default/src/components/ai-elements/response.tsx index 2a35044dff9..24fb4c12259 100644 --- a/web/default/src/components/ai-elements/response.tsx +++ b/web/default/src/components/ai-elements/response.tsx @@ -3,6 +3,10 @@ import { type ComponentProps, memo } from 'react' import { Streamdown } from 'streamdown' import { cn } from '@/lib/utils' +import rehypeKatex from 'rehype-katex' +import remarkMath from 'remark-math' +import { normalizeMathDelimiters } from '@/lib/markdown-math' +import 'katex/dist/katex.min.css' type ResponseProps = ComponentProps @@ -23,6 +27,10 @@ export const Response = memo( } const safeChildren = stripCustomTags(children) as string + const normalizedChildren = + typeof safeChildren === 'string' + ? normalizeMathDelimiters(safeChildren) + : safeChildren return ( *:first-child]:mt-0 [&>*:last-child]:mb-0', className )} + remarkPlugins={[remarkMath]} + rehypePlugins={[rehypeKatex]} {...props} > - {safeChildren} + {normalizedChildren} ) }, diff --git a/web/default/src/components/ui/markdown.tsx b/web/default/src/components/ui/markdown.tsx index df4be48c146..7bc417bdf5e 100644 --- a/web/default/src/components/ui/markdown.tsx +++ b/web/default/src/components/ui/markdown.tsx @@ -1,7 +1,11 @@ import ReactMarkdown from 'react-markdown' import rehypeRaw from 'rehype-raw' +import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' import { cn } from '@/lib/utils' +import { normalizeMathDelimiters } from '@/lib/markdown-math' +import 'katex/dist/katex.min.css' interface MarkdownProps { children: string @@ -9,6 +13,8 @@ interface MarkdownProps { } export function Markdown({ children, className }: MarkdownProps) { + const content = normalizeMathDelimiters(children) + return (
( @@ -39,7 +45,7 @@ export function Markdown({ children, className }: MarkdownProps) { ), }} > - {children} + {content}
) diff --git a/web/default/src/lib/markdown-math.ts b/web/default/src/lib/markdown-math.ts new file mode 100644 index 00000000000..da850047972 --- /dev/null +++ b/web/default/src/lib/markdown-math.ts @@ -0,0 +1,14 @@ +const MATH_DELIMITER_PATTERN = + /(```[\s\S]*?```|`[^`\n]*`)|\\\[([\s\S]*?[^\\])\\\]|\\\(([\s\S]*?[^\\])\\\)/g + +export function normalizeMathDelimiters(input: string): string { + return input.replace( + MATH_DELIMITER_PATTERN, + (match, codeBlock, blockMath, inlineMath) => { + if (codeBlock) return codeBlock + if (blockMath) return `$$${blockMath}$$` + if (inlineMath) return `$${inlineMath}$` + return match + } + ) +} From a85dcbcae3e7ef2e251b88bad3e45eeb3c3028c6 Mon Sep 17 00:00:00 2001 From: dext-stu Date: Tue, 5 May 2026 04:43:33 +0800 Subject: [PATCH 2/3] fix(playground): avoid rewriting fenced code math markers --- web/default/src/lib/markdown-math.ts | 156 +++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 11 deletions(-) diff --git a/web/default/src/lib/markdown-math.ts b/web/default/src/lib/markdown-math.ts index da850047972..5b73c7085a8 100644 --- a/web/default/src/lib/markdown-math.ts +++ b/web/default/src/lib/markdown-math.ts @@ -1,14 +1,148 @@ -const MATH_DELIMITER_PATTERN = - /(```[\s\S]*?```|`[^`\n]*`)|\\\[([\s\S]*?[^\\])\\\]|\\\(([\s\S]*?[^\\])\\\)/g +const NEWLINE = '\n' + +function countRepeatedChars(input: string, start: number, char: string) { + let index = start + while (index < input.length && input[index] === char) { + index += 1 + } + return index - start +} + +function isEscaped(input: string, index: number) { + let backslashCount = 0 + let cursor = index - 1 + + while (cursor >= 0 && input[cursor] === '\\') { + backslashCount += 1 + cursor -= 1 + } + + return backslashCount % 2 === 1 +} + +function findClosingDelimiter( + input: string, + start: number, + openChar: '(' | '[', + closeChar: ')' | ']' +) { + for (let index = start; index < input.length - 1; index += 1) { + if ( + input[index] === '\\' && + input[index + 1] === closeChar && + !isEscaped(input, index) + ) { + const content = input.slice(start, index) + if (content.length === 0 || content.endsWith('\\')) { + return null + } + + return { + content, + end: index + 2, + wrapper: openChar === '[' ? '$$' : '$', + } + } + } + + return null +} + +function findFenceEnd( + input: string, + searchStart: number, + marker: '`' | '~', + markerCount: number +) { + let cursor = searchStart + + while (cursor < input.length) { + const lineEnd = input.indexOf(NEWLINE, cursor) + const nextCursor = lineEnd === -1 ? input.length : lineEnd + 1 + const line = input.slice(cursor, nextCursor) + const trimmed = line.replace(/\r?\n$/, '') + const indentMatch = trimmed.match(/^ {0,3}/) + const indentLength = indentMatch?.[0].length ?? 0 + const markerRun = countRepeatedChars(trimmed, indentLength, marker) + + if (markerRun >= markerCount) { + const rest = trimmed.slice(indentLength + markerRun).trim() + if (rest.length === 0) { + return nextCursor + } + } + + cursor = nextCursor + } + + return input.length +} export function normalizeMathDelimiters(input: string): string { - return input.replace( - MATH_DELIMITER_PATTERN, - (match, codeBlock, blockMath, inlineMath) => { - if (codeBlock) return codeBlock - if (blockMath) return `$$${blockMath}$$` - if (inlineMath) return `$${inlineMath}$` - return match - } - ) + let output = '' + let index = 0 + + while (index < input.length) { + const lineStart = + index === 0 || input[index - 1] === '\n' || input[index - 1] === '\r' + + if (lineStart) { + const lineEnd = input.indexOf(NEWLINE, index) + const currentLineEnd = lineEnd === -1 ? input.length : lineEnd + 1 + const line = input.slice(index, currentLineEnd) + const trimmed = line.replace(/\r?\n$/, '') + const indentMatch = trimmed.match(/^ {0,3}/) + const indentLength = indentMatch?.[0].length ?? 0 + const marker = trimmed[indentLength] + + if (marker === '`' || marker === '~') { + const markerCount = countRepeatedChars(trimmed, indentLength, marker) + + if (markerCount >= 3) { + const fenceEnd = findFenceEnd( + input, + currentLineEnd, + marker, + markerCount + ) + output += input.slice(index, fenceEnd) + index = fenceEnd + continue + } + } + } + + if (input[index] === '`') { + const tickCount = countRepeatedChars(input, index, '`') + const closing = input.indexOf('`'.repeat(tickCount), index + tickCount) + + if (closing !== -1) { + const end = closing + tickCount + output += input.slice(index, end) + index = end + continue + } + } + + if ( + input[index] === '\\' && + !isEscaped(input, index) && + (input[index + 1] === '(' || input[index + 1] === '[') + ) { + const openChar = input[index + 1] as '(' | '[' + const closeChar = openChar === '(' ? ')' : ']' + const match = findClosingDelimiter(input, index + 2, openChar, closeChar) + + if (match) { + output += `${match.wrapper}${match.content}${match.wrapper}` + index = match.end + continue + } + } + + output += input[index] + index += 1 + } + + return output } From 8f7b010d719061ab805f4d1e921a8755159c118f Mon Sep 17 00:00:00 2001 From: dext-stu Date: Tue, 5 May 2026 05:02:07 +0800 Subject: [PATCH 3/3] fix(web): avoid theme lookup on every asset request --- common/frontend_theme.go | 1 + controller/user.go | 190 +++++++++++++++++++++------------------ router/web-router.go | 34 +++++-- 3 files changed, 128 insertions(+), 97 deletions(-) diff --git a/common/frontend_theme.go b/common/frontend_theme.go index 5183d65c881..af1220b8007 100644 --- a/common/frontend_theme.go +++ b/common/frontend_theme.go @@ -8,6 +8,7 @@ import ( const FrontendThemeCookieName = "frontend_theme" const FrontendThemeCookieMaxAge = 60 * 60 * 24 * 365 +const FrontendThemeSessionKey = "frontend_theme" func NormalizeFrontendTheme(theme string) string { theme = strings.ToLower(strings.TrimSpace(theme)) diff --git a/controller/user.go b/controller/user.go index e5c1e3ade24..116fb8a391b 100644 --- a/controller/user.go +++ b/controller/user.go @@ -92,7 +92,8 @@ func Login(c *gin.Context) { // setup session & cookies and then return user info func setupLogin(user *model.User, c *gin.Context) { model.UpdateUserLastLoginAt(user.Id) - if theme := common.NormalizeFrontendTheme(user.GetSetting().FrontendTheme); theme != "" { + theme := common.NormalizeFrontendTheme(user.GetSetting().FrontendTheme) + if theme != "" { common.SetFrontendThemeCookie(c, theme) } session := sessions.Default(c) @@ -101,6 +102,11 @@ func setupLogin(user *model.User, c *gin.Context) { session.Set("role", user.Role) session.Set("status", user.Status) session.Set("group", user.Group) + if theme != "" { + session.Set(common.FrontendThemeSessionKey, theme) + } else { + session.Delete(common.FrontendThemeSessionKey) + } err := session.Save() if err != nil { common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) @@ -392,6 +398,11 @@ func GetSelf(c *gin.Context) { userSetting := user.GetSetting() if theme := common.NormalizeFrontendTheme(userSetting.FrontendTheme); theme != "" { common.SetFrontendThemeCookie(c, theme) + session := sessions.Default(c) + if common.Interface2String(session.Get(common.FrontendThemeSessionKey)) != theme { + session.Set(common.FrontendThemeSessionKey, theme) + _ = session.Save() + } } // 构建响应数据,包含用户信息和权限 @@ -636,133 +647,136 @@ func UpdateSelf(c *gin.Context) { return } - // 检查是否是用户设置更新请求 (sidebar_modules 或 language) - if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists { - userId := c.GetInt("id") + userId := c.GetInt("id") + settingChanged := false + updatedTheme := "" + var updatedSetting *dto.UserSetting + + for _, key := range []string{"sidebar_modules", "frontend_theme", "language"} { + if _, ok := requestData[key]; ok { + settingChanged = true + break + } + } + + if settingChanged { user, err := model.GetUserById(userId, false) if err != nil { common.ApiError(c, err) return } - // 获取当前用户设置 currentSetting := user.GetSetting() - // 更新sidebar_modules字段 - if sidebarModulesStr, ok := sidebarModules.(string); ok { + if sidebarModules, ok := requestData["sidebar_modules"]; ok { + sidebarModulesStr, valid := sidebarModules.(string) + if !valid { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } currentSetting.SidebarModules = sidebarModulesStr } - // 保存更新后的设置 - user.SetSetting(currentSetting) - if err := user.Update(false); err != nil { - common.ApiErrorI18n(c, i18n.MsgUpdateFailed) - return + if frontendTheme, ok := requestData["frontend_theme"]; ok { + themeStr, valid := frontendTheme.(string) + if !valid { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + themeStr = common.NormalizeFrontendTheme(themeStr) + if themeStr == "" { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + currentSetting.FrontendTheme = themeStr + updatedTheme = themeStr } - common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil) - return + + if language, ok := requestData["language"]; ok { + langStr, valid := language.(string) + if !valid { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + currentSetting.Language = langStr + } + + updatedSetting = ¤tSetting } - // 检查是否是 UI 风格更新请求 - if frontendTheme, themeExists := requestData["frontend_theme"]; themeExists { - userId := c.GetInt("id") - user, err := model.GetUserById(userId, false) - if err != nil { - common.ApiError(c, err) - return + hasProfileUpdate := false + for _, key := range []string{"username", "display_name", "password", "original_password"} { + if _, ok := requestData[key]; ok { + hasProfileUpdate = true + break } + } - currentSetting := user.GetSetting() - themeStr, ok := frontendTheme.(string) - if !ok { + if hasProfileUpdate { + var user model.User + requestDataBytes, err := common.Marshal(requestData) + if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } - themeStr = strings.ToLower(strings.TrimSpace(themeStr)) - if themeStr != "default" && themeStr != "classic" { + err = common.Unmarshal(requestDataBytes, &user) + if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } - currentSetting.FrontendTheme = themeStr - user.SetSetting(currentSetting) - if err := user.Update(false); err != nil { - common.ApiErrorI18n(c, i18n.MsgUpdateFailed) + if user.Password == "" { + user.Password = "$I_LOVE_U" // make Validator happy :) + } + if err := common.Validate.Struct(&user); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidInput) return } - common.SetFrontendThemeCookie(c, themeStr) - - common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil) - return - } - // 检查是否是语言偏好更新请求 - if language, langExists := requestData["language"]; langExists { - userId := c.GetInt("id") - user, err := model.GetUserById(userId, false) + cleanUser := model.User{ + Id: userId, + Username: user.Username, + Password: user.Password, + DisplayName: user.DisplayName, + } + if user.Password == "$I_LOVE_U" { + user.Password = "" + cleanUser.Password = "" + } + updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id) if err != nil { common.ApiError(c, err) return } - - // 获取当前用户设置 - currentSetting := user.GetSetting() - - // 更新language字段 - if langStr, ok := language.(string); ok { - currentSetting.Language = langStr - } - - // 保存更新后的设置 - user.SetSetting(currentSetting) - if err := user.Update(false); err != nil { - common.ApiErrorI18n(c, i18n.MsgUpdateFailed) + if err := cleanUser.Update(updatePassword); err != nil { + common.ApiError(c, err) return } - - common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil) - return } - // 原有的用户信息更新逻辑 - var user model.User - requestDataBytes, err := common.Marshal(requestData) - if err != nil { - common.ApiErrorI18n(c, i18n.MsgInvalidParams) - return - } - err = common.Unmarshal(requestDataBytes, &user) - if err != nil { + if !settingChanged && !hasProfileUpdate { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } - if user.Password == "" { - user.Password = "$I_LOVE_U" // make Validator happy :) - } - if err := common.Validate.Struct(&user); err != nil { - common.ApiErrorI18n(c, i18n.MsgInvalidInput) - return + if updatedSetting != nil { + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + user.SetSetting(*updatedSetting) + if err := user.Update(false); err != nil { + common.ApiErrorI18n(c, i18n.MsgUpdateFailed) + return + } } - cleanUser := model.User{ - Id: c.GetInt("id"), - Username: user.Username, - Password: user.Password, - DisplayName: user.DisplayName, - } - if user.Password == "$I_LOVE_U" { - user.Password = "" // rollback to what it should be - cleanUser.Password = "" - } - updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id) - if err != nil { - common.ApiError(c, err) - return - } - if err := cleanUser.Update(updatePassword); err != nil { - common.ApiError(c, err) - return + if updatedTheme != "" { + common.SetFrontendThemeCookie(c, updatedTheme) + session := sessions.Default(c) + session.Set(common.FrontendThemeSessionKey, updatedTheme) + _ = session.Save() } c.JSON(http.StatusOK, gin.H{ diff --git a/router/web-router.go b/router/web-router.go index fea3916b1a6..03f3f2e3699 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -63,24 +63,40 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) { } func resolveFrontendTheme(c *gin.Context) string { + themeCookie, err := c.Cookie(common.FrontendThemeCookieName) + if err == nil { + theme := common.NormalizeFrontendTheme(themeCookie) + if theme != "" { + return theme + } + } + session := sessions.Default(c) + sessionTheme := common.NormalizeFrontendTheme(common.Interface2String(session.Get(common.FrontendThemeSessionKey))) + if sessionTheme != "" { + common.SetFrontendThemeCookie(c, sessionTheme) + return sessionTheme + } + if sessionID := session.Get("id"); sessionID != nil { if userID, ok := sessionID.(int); ok && userID > 0 { - user, err := model.GetUserById(userID, false) + setting, err := model.GetUserSetting(userID, false) if err == nil { - theme := common.NormalizeFrontendTheme(user.GetSetting().FrontendTheme) + theme := common.NormalizeFrontendTheme(setting.FrontendTheme) if theme != "" { + session.Set(common.FrontendThemeSessionKey, theme) + _ = session.Save() common.SetFrontendThemeCookie(c, theme) return theme } } - } - } - themeCookie, err := c.Cookie(common.FrontendThemeCookieName) - if err == nil { - theme := common.NormalizeFrontendTheme(themeCookie) - if theme != "" { - return theme + + fallbackTheme := common.NormalizeFrontendTheme(common.GetTheme()) + if fallbackTheme != "" { + session.Set(common.FrontendThemeSessionKey, fallbackTheme) + _ = session.Save() + return fallbackTheme + } } } return common.GetTheme()