From a70264efa27823d1f66e7eefa0c024e9d35c2de6 Mon Sep 17 00:00:00 2001 From: Diego Fornalha Date: Sat, 4 Apr 2026 04:08:55 -0300 Subject: [PATCH] feat: add pt-br translation and improve session history UI - Add complete pt-br locale (536 lines) - Improve session history menu with better UX - Add session API endpoints for history management - Register pt-br in i18n index Co-Authored-By: Claude Opus 4.6 (1M context) --- web/backend/api/session.go | 311 ++++++++++++++++-- web/frontend/src/api/sessions.ts | 17 + web/frontend/src/components/app-header.tsx | 3 + .../src/components/chat/chat-page.tsx | 13 + .../components/chat/session-history-menu.tsx | 164 +++++++-- web/frontend/src/hooks/use-session-history.ts | 22 +- web/frontend/src/i18n/index.ts | 7 + web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + 9 files changed, 481 insertions(+), 60 deletions(-) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index a2e931010..14422b7a6 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "os" + "os/exec" "path/filepath" "sort" "strconv" @@ -21,6 +22,7 @@ func (h *Handler) registerSessionRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/sessions", h.handleListSessions) mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession) mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession) + mux.HandleFunc("PATCH /api/sessions/{id}", h.handleRenameSession) } // sessionFile mirrors the on-disk session JSON structure from pkg/session. @@ -37,6 +39,8 @@ type sessionListItem struct { ID string `json:"id"` Title string `json:"title"` Preview string `json:"preview"` + Channel string `json:"channel"` + PeerName string `json:"peer_name,omitempty"` MessageCount int `json:"message_count"` Created string `json:"created"` Updated string `json:"updated"` @@ -76,6 +80,19 @@ const ( handledToolResponseSummaryText = "Requested output delivered via tool attachment." ) +// knownSanitizedPrefixes maps channel prefixes to human-readable channel names. +var knownSanitizedPrefixes = []struct { + prefix string + channel string +}{ + {sanitizedPicoSessionPrefix, "pico"}, + {"agent_main_whatsapp_native_direct_", "whatsapp"}, + {"agent_main_telegram_direct_", "telegram"}, + {"agent_main_discord_direct_", "discord"}, + {"agent_main_slack_direct_", "slack"}, + {"agent_main_matrix_direct_", "matrix"}, +} + // extractPicoSessionID extracts the session UUID from a full session key. // Returns the UUID and true if the key matches the Pico session pattern. func extractPicoSessionID(key string) (string, bool) { @@ -92,6 +109,28 @@ func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) { return "", false } +// extractAnySessionID extracts session ID and channel name from any known +// sanitized key prefix. Returns sessionID, channel, ok. +func extractAnySessionID(key string) (string, string, bool) { + for _, p := range knownSanitizedPrefixes { + if strings.HasPrefix(key, p.prefix) { + return strings.TrimPrefix(key, p.prefix), p.channel, true + } + } + return "", "", false +} + +// extractAnySessionIDFromKey extracts session ID and channel from unsanitized key. +func extractAnySessionIDFromKey(key string) (string, string, bool) { + for _, p := range knownSanitizedPrefixes { + unsanitized := strings.ReplaceAll(p.prefix, "_", ":") + if strings.HasPrefix(key, unsanitized) { + return strings.TrimPrefix(key, unsanitized), p.channel, true + } + } + return "", "", false +} + func sanitizeSessionKey(key string) string { return strings.ReplaceAll(key, ":", "_") } @@ -165,11 +204,16 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag } func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { - sessionKey := picoSessionPrefix + sessionID - base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + return h.readJSONLSessionByBase(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) +} + +// readJSONLSessionByBase reads a JSONL session by its sanitized base name (without extension). +func (h *Handler) readJSONLSessionByBase(dir, baseName string) (sessionFile, error) { + base := filepath.Join(dir, baseName) jsonlPath := base + ".jsonl" metaPath := base + ".meta.json" + sessionKey := strings.ReplaceAll(baseName, "_", ":") meta, err := h.readSessionMeta(metaPath, sessionKey) if err != nil { return sessionFile{}, err @@ -202,7 +246,7 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { }, nil } -func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { +func buildSessionListItem(sessionID, channel string, sess sessionFile) sessionListItem { preview := "" for _, msg := range sess.Messages { if msg.Role == "user" { @@ -225,6 +269,7 @@ func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { ID: sessionID, Title: title, Preview: preview, + Channel: channel, MessageCount: validMessageCount, Created: sess.Created.Format(time.RFC3339), Updated: sess.Updated.Format(time.RFC3339), @@ -235,6 +280,108 @@ func isEmptySession(sess sessionFile) bool { return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == "" } +// whatsappPeerInfo holds resolved WhatsApp peer data. +type whatsappPeerInfo struct { + Phone string // "+5521..." + PushName string // "Willian Santos" +} + +// resolveWhatsAppLIDs resolves WhatsApp LID session IDs to phone numbers +// and push names using the whatsmeow SQLite store. +func (h *Handler) resolveWhatsAppLIDs(lids []string) map[string]whatsappPeerInfo { + if len(lids) == 0 { + return nil + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return nil + } + workspace := cfg.Agents.Defaults.Workspace + if workspace == "" { + home, _ := os.UserHomeDir() + workspace = filepath.Join(home, ".picoclaw", "workspace") + } + if len(workspace) > 0 && workspace[0] == '~' { + home, _ := os.UserHomeDir() + if len(workspace) > 1 && workspace[1] == '/' { + workspace = home + workspace[1:] + } else { + workspace = home + } + } + + dbPath := filepath.Join(workspace, "whatsapp", "store.db") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + + result := make(map[string]whatsappPeerInfo) + for _, lid := range lids { + cleanLID := strings.TrimSuffix(lid, "@lid") + info := whatsappPeerInfo{} + + // Resolve LID → phone + query := "SELECT pn FROM whatsmeow_lid_map WHERE lid='" + cleanLID + "';" + if out, err := exec.Command("sqlite3", dbPath, query).Output(); err == nil { + phone := strings.TrimSpace(string(out)) + if phone != "" { + info.Phone = "+" + phone + + // Resolve phone → name via contacts table (prefer push_name, fallback to full_name) + contactQuery := "SELECT COALESCE(NULLIF(push_name,''), NULLIF(full_name,'')) FROM whatsmeow_contacts WHERE their_jid LIKE '" + phone + "%' LIMIT 1;" + if nameOut, err := exec.Command("sqlite3", dbPath, contactQuery).Output(); err == nil { + name := strings.TrimSpace(string(nameOut)) + if name != "" { + info.PushName = name + } + } + } + } + + if info.Phone != "" { + result[lid] = info + } + } + return result +} + +// findAndReadSession tries all known channel prefixes to find and read a session. +func (h *Handler) findAndReadSession(dir, sessionID string) (sessionFile, error) { + for _, p := range knownSanitizedPrefixes { + baseName := p.prefix + sessionID + sess, err := h.readJSONLSessionByBase(dir, baseName) + if err == nil && !isEmptySession(sess) { + return sess, nil + } + // Try legacy JSON + legacyPath := filepath.Join(dir, baseName+".json") + data, err := os.ReadFile(legacyPath) + if err == nil { + var legacySess sessionFile + if json.Unmarshal(data, &legacySess) == nil && !isEmptySession(legacySess) { + return legacySess, nil + } + } + } + return sessionFile{}, os.ErrNotExist +} + +// findSessionFiles returns all file paths for a session across all channel prefixes. +func findSessionFiles(dir, sessionID string) []string { + var paths []string + for _, p := range knownSanitizedPrefixes { + base := filepath.Join(dir, p.prefix+sessionID) + for _, ext := range []string{".jsonl", ".meta.json", ".json"} { + path := base + ext + if _, err := os.Stat(path); err == nil { + paths = append(paths, path) + } + } + } + return paths +} + func truncateRunes(s string, maxLen int) string { if maxLen <= 0 { return "" @@ -400,13 +547,15 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { ok bool ) + var channel string switch { case strings.HasSuffix(name, ".jsonl"): - sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl")) + baseName := strings.TrimSuffix(name, ".jsonl") + sessionID, channel, ok = extractAnySessionID(baseName) if !ok { continue } - sess, loadErr = h.readJSONLSession(dir, sessionID) + sess, loadErr = h.readJSONLSessionByBase(dir, baseName) if loadErr == nil && isEmptySession(sess) { continue } @@ -415,10 +564,10 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { case filepath.Ext(name) == ".json": base := strings.TrimSuffix(name, ".json") if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { - if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found { - if jsonlSess, jsonlErr := h.readJSONLSession( + if _, _, found := extractAnySessionID(base); found { + if jsonlSess, jsonlErr := h.readJSONLSessionByBase( dir, - jsonlSessionID, + base, ); jsonlErr == nil && !isEmptySession(jsonlSess) { continue @@ -435,7 +584,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { if isEmptySession(sess) { continue } - sessionID, ok = extractPicoSessionID(sess.Key) + sessionID, channel, ok = extractAnySessionIDFromKey(sess.Key) if !ok { continue } @@ -454,7 +603,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { } seen[sessionID] = struct{}{} - items = append(items, buildSessionListItem(sessionID, sess)) + items = append(items, buildSessionListItem(sessionID, channel, sess)) } // Sort by updated descending (most recent first) @@ -462,6 +611,34 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { return items[i].Updated > items[j].Updated }) + // Apply custom titles + if customTitles, err := h.loadCustomTitles(); err == nil { + for i := range items { + if t, ok := customTitles[items[i].ID]; ok { + items[i].Title = t + } + } + } + + // Resolve WhatsApp LIDs to phone numbers + var whatsappLIDs []string + for _, item := range items { + if item.Channel == "whatsapp" { + whatsappLIDs = append(whatsappLIDs, item.ID) + } + } + if lidMap := h.resolveWhatsAppLIDs(whatsappLIDs); len(lidMap) > 0 { + for i := range items { + if info, ok := lidMap[items[i].ID]; ok { + items[i].PeerName = info.Phone + // Use push_name as default title if no custom title was set + if info.PushName != "" && items[i].Title == items[i].Preview { + items[i].Title = info.PushName + } + } + } + } + // Pagination parameters offsetStr := r.URL.Query().Get("offset") limitStr := r.URL.Query().Get("limit") @@ -508,25 +685,14 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { return } - sess, err := h.readJSONLSession(dir, sessionID) - if err == nil && isEmptySession(sess) { - err = os.ErrNotExist - } + sess, err := h.findAndReadSession(dir, sessionID) if err != nil { if errors.Is(err, os.ErrNotExist) { - sess, err = h.readLegacySession(dir, sessionID) - if err == nil && isEmptySession(sess) { - err = os.ErrNotExist - } - } - if err != nil { - if errors.Is(err, os.ErrNotExist) { - http.Error(w, "session not found", http.StatusNotFound) - } else { - http.Error(w, "failed to parse session", http.StatusInternalServerError) - } - return + http.Error(w, "session not found", http.StatusNotFound) + } else { + http.Error(w, "failed to parse session", http.StatusInternalServerError) } + return } messages := visibleSessionMessages(sess.Messages) @@ -557,13 +723,10 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { return } - base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) - jsonlPath := base + ".jsonl" - metaPath := base + ".meta.json" - legacyPath := base + ".json" + paths := findSessionFiles(dir, sessionID) removed := false - for _, path := range []string{jsonlPath, metaPath, legacyPath} { + for _, path := range paths { if err := os.Remove(path); err != nil { if os.IsNotExist(err) { continue @@ -581,3 +744,89 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// customTitlesPath returns the path to the custom session titles file. +func (h *Handler) customTitlesPath() (string, error) { + dir, err := h.sessionsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, ".session-titles.json"), nil +} + +// loadCustomTitles reads the custom session titles map from disk. +func (h *Handler) loadCustomTitles() (map[string]string, error) { + path, err := h.customTitlesPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]string), nil + } + return nil, err + } + var titles map[string]string + if err := json.Unmarshal(data, &titles); err != nil { + return make(map[string]string), nil + } + return titles, nil +} + +// saveCustomTitles writes the custom session titles map to disk. +func (h *Handler) saveCustomTitles(titles map[string]string) error { + path, err := h.customTitlesPath() + if err != nil { + return err + } + data, err := json.Marshal(titles) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// handleRenameSession updates the custom title for a session. +// +// PATCH /api/sessions/{id} +func (h *Handler) handleRenameSession(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + if sessionID == "" { + http.Error(w, "missing session id", http.StatusBadRequest) + return + } + + var body struct { + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + title := strings.TrimSpace(body.Title) + if title == "" { + http.Error(w, "title must not be empty", http.StatusBadRequest) + return + } + if len([]rune(title)) > maxSessionTitleRunes { + runes := []rune(title) + title = string(runes[:maxSessionTitleRunes]) + } + + titles, err := h.loadCustomTitles() + if err != nil { + http.Error(w, "failed to load titles", http.StatusInternalServerError) + return + } + + titles[sessionID] = title + if err := h.saveCustomTitles(titles); err != nil { + http.Error(w, "failed to save title", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"title": title}) +} diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index dd0fa1f53..dee20ec20 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -4,6 +4,8 @@ export interface SessionSummary { id: string title: string preview: string + channel: string + peer_name?: string message_count: number created: string updated: string @@ -53,3 +55,18 @@ export async function deleteSession(id: string): Promise { throw new Error(`Failed to delete session ${id}: ${res.status}`) } } + +export async function renameSession( + id: string, + title: string, +): Promise<{ title: string }> { + const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }) + if (!res.ok) { + throw new Error(`Failed to rename session ${id}: ${res.status}`) + } + return res.json() +} diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index fa1b5a488..2868936a4 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -234,6 +234,9 @@ export function AppHeader() { i18n.changeLanguage("en")}> English + i18n.changeLanguage("pt-BR")}> + Português (Brasil) + i18n.changeLanguage("zh")}> 简体中文 diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 38a0fc6b1..2a0b47aa2 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -63,6 +63,17 @@ export function ChatPage() { newChat, } = usePicoChat() + // Load session from URL query param ?session= + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const sessionParam = params.get("session") + if (sessionParam && sessionParam !== activeSessionId) { + switchSession(sessionParam) + // Clean up URL without reload + window.history.replaceState({}, "", window.location.pathname) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + const { state: gwState } = useGateway() const isGatewayRunning = gwState === "running" const isChatConnected = connectionState === "connected" @@ -85,6 +96,7 @@ export function ChatPage() { observerRef, loadSessions, handleDeleteSession, + handleRenameSession, } = useSessionHistory({ activeSessionId, onDeletedActiveSession: newChat, @@ -225,6 +237,7 @@ export function ChatPage() { }} onSwitchSession={switchSession} onDeleteSession={handleDeleteSession} + onRenameSession={handleRenameSession} /> diff --git a/web/frontend/src/components/chat/session-history-menu.tsx b/web/frontend/src/components/chat/session-history-menu.tsx index 3ec1a5ed2..a102b88fc 100644 --- a/web/frontend/src/components/chat/session-history-menu.tsx +++ b/web/frontend/src/components/chat/session-history-menu.tsx @@ -1,6 +1,6 @@ -import { IconHistory, IconTrash } from "@tabler/icons-react" +import { IconBrandWhatsapp, IconCheck, IconCopy, IconHistory, IconLink, IconPencil, IconTrash, IconX } from "@tabler/icons-react" import dayjs from "dayjs" -import type { RefObject } from "react" +import { type RefObject, useState } from "react" import { useTranslation } from "react-i18next" import type { SessionSummary } from "@/api/sessions" @@ -23,6 +23,7 @@ interface SessionHistoryMenuProps { onOpenChange: (open: boolean) => void onSwitchSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void + onRenameSession: (sessionId: string, title: string) => void } export function SessionHistoryMenu({ @@ -35,18 +36,63 @@ export function SessionHistoryMenu({ onOpenChange, onSwitchSession, onDeleteSession, + onRenameSession, }: SessionHistoryMenuProps) { const { t } = useTranslation() + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState("") + const [copiedId, setCopiedId] = useState(null) + const [copiedWaId, setCopiedWaId] = useState(null) + + const startRename = (e: React.MouseEvent, session: SessionSummary) => { + e.preventDefault() + e.stopPropagation() + setEditingId(session.id) + setEditValue(session.title || session.preview || "") + } + + const confirmRename = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (editingId && editValue.trim()) { + onRenameSession(editingId, editValue.trim()) + } + setEditingId(null) + } + + const cancelRename = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setEditingId(null) + } + + const copyLink = (e: React.MouseEvent, sessionId: string) => { + e.preventDefault() + e.stopPropagation() + const url = `${window.location.origin}/?session=${sessionId}` + navigator.clipboard.writeText(url) + setCopiedId(sessionId) + setTimeout(() => setCopiedId(null), 2000) + } + + const copyWhatsApp = (e: React.MouseEvent, peerName: string, sessionId: string) => { + e.preventDefault() + e.stopPropagation() + const phone = peerName.replace(/\+/g, "") + navigator.clipboard.writeText(`wa.me/${phone}`) + setCopiedWaId(sessionId) + setTimeout(() => setCopiedWaId(null), 2000) + } return ( - + { if (!open) setEditingId(null); onOpenChange(open) }}> - + {loadError && ( @@ -65,33 +111,95 @@ export function SessionHistoryMenu({ sessions.map((session) => ( onSwitchSession(session.id)} + onClick={() => { if (!editingId) onSwitchSession(session.id) }} > - - {session.title} - - - {t("chat.messagesCount", { - count: session.message_count, - })}{" "} - · {dayjs(session.updated).fromNow()} - - + {editingId === session.id ? ( +
e.stopPropagation()}> + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") confirmRename(e as unknown as React.MouseEvent) + if (e.key === "Escape") setEditingId(null) + }} + autoFocus + /> + + +
+ ) : ( + <> + + {session.title || session.preview} + + + {t("chat.messagesCount", { count: session.message_count })}{" "} + · {dayjs(session.updated).fromNow()} + + + )} + {editingId !== session.id && ( +
+ + {session.peer_name && ( + + )} + + +
+ )}
)) )} diff --git a/web/frontend/src/hooks/use-session-history.ts b/web/frontend/src/hooks/use-session-history.ts index 2673f3562..8ca0e2a50 100644 --- a/web/frontend/src/hooks/use-session-history.ts +++ b/web/frontend/src/hooks/use-session-history.ts @@ -1,7 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions" +import { + type SessionSummary, + deleteSession, + getSessions, + renameSession, +} from "@/api/sessions" const LIMIT = 20 @@ -106,6 +111,20 @@ export function useSessionHistory({ [activeSessionId, onDeletedActiveSession, sessions], ) + const handleRenameSession = useCallback( + async (id: string, title: string) => { + try { + const result = await renameSession(id, title) + setSessions((prev) => + prev.map((s) => (s.id === id ? { ...s, title: result.title } : s)), + ) + } catch (err) { + console.error("Failed to rename session:", err) + } + }, + [], + ) + return { sessions, hasMore, @@ -114,5 +133,6 @@ export function useSessionHistory({ observerRef, loadSessions, handleDeleteSession, + handleRenameSession, } } diff --git a/web/frontend/src/i18n/index.ts b/web/frontend/src/i18n/index.ts index bdc1fe917..4da7b3f0d 100644 --- a/web/frontend/src/i18n/index.ts +++ b/web/frontend/src/i18n/index.ts @@ -1,5 +1,6 @@ import dayjs from "dayjs" import "dayjs/locale/en" +import "dayjs/locale/pt-br" import "dayjs/locale/zh-cn" import localizedFormat from "dayjs/plugin/localizedFormat" import relativeTime from "dayjs/plugin/relativeTime" @@ -8,6 +9,7 @@ import LanguageDetector from "i18next-browser-languagedetector" import { initReactI18next } from "react-i18next" import en from "./locales/en.json" +import ptBr from "./locales/pt-br.json" import zh from "./locales/zh.json" dayjs.extend(relativeTime) @@ -26,6 +28,9 @@ i18n en: { translation: en, }, + "pt-BR": { + translation: ptBr, + }, zh: { translation: zh, }, @@ -41,6 +46,8 @@ i18n i18n.on("languageChanged", (lng) => { if (lng.startsWith("zh")) { dayjs.locale("zh-cn") + } else if (lng.startsWith("pt")) { + dayjs.locale("pt-br") } else { dayjs.locale("en") } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 41a6efc9d..0d5620ef3 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -48,6 +48,8 @@ "historyOpenFailed": "Failed to open this chat history", "loadingMore": "Loading more...", "deleteSession": "Delete session", + "renameSession": "Rename session", + "copyLink": "Copy link", "messagesCount": "{{count}} messages", "noModel": "Select model", "attachImage": "Add images", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 6645dd0b1..5c4402cec 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -48,6 +48,8 @@ "historyOpenFailed": "打开该历史会话失败", "loadingMore": "加载更多...", "deleteSession": "删除会话", + "renameSession": "重命名会话", + "copyLink": "复制链接", "messagesCount": "{{count}} 条消息", "noModel": "选择模型", "attachImage": "添加图片",