Merge pull request #777 from elammertsma/dev

Automatic chat naming, chat renaming, name in breadcrumbs
This commit is contained in:
Rohan Verma 2026-02-05 10:47:41 -08:00 committed by GitHub
commit dbc9a4e0cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 354 additions and 75 deletions

View file

@ -104,3 +104,33 @@ SUMMARY_PROMPT = (
SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["document"], template=SUMMARY_PROMPT
)
# =============================================================================
# Chat Title Generation Prompt
# =============================================================================
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation.
<rules>
- The title MUST be between 1 and 6 words
- The title MUST be on a single line
- Capture the main topic or intent of the conversation
- Do NOT use quotes, punctuation, or formatting
- Do NOT include words like "Chat about" or "Discussion of"
- Return ONLY the title, nothing else
</rules>
<user_query>
{user_query}
</user_query>
<assistant_response>
{assistant_response}
</assistant_response>
Title:"""
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["user_query", "assistant_response"],
template=TITLE_GENERATION_PROMPT,
)

View file

@ -886,30 +886,8 @@ async def append_message(
# Update thread's updated_at timestamp
thread.updated_at = datetime.now(UTC)
# Auto-generate title from first user message if title is still default
if thread.title == "New Chat" and role_str == "user":
# Extract text content for title
content = message.content
if isinstance(content, str):
title_text = content
elif isinstance(content, list):
# Find first text content
title_text = ""
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
title_text = part.get("text", "")
break
elif isinstance(part, str):
title_text = part
break
else:
title_text = str(content)
# Truncate title
if title_text:
thread.title = title_text[:100] + (
"..." if len(title_text) > 100 else ""
)
# Note: Title generation now happens in stream_new_chat.py after the first response
# using LLM to generate a descriptive title (with truncation as fallback)
await session.commit()
await session.refresh(db_message)

View file

@ -479,6 +479,31 @@ class VercelStreamingService:
},
)
def format_thread_title_update(self, thread_id: int, title: str) -> str:
"""
Format a thread title update notification (SurfSense specific).
This is sent after the first response in a thread to update the
auto-generated title based on the conversation content.
Args:
thread_id: The ID of the thread being updated
title: The new title for the thread
Returns:
str: SSE formatted thread title update data part
Example output:
data: {"type":"data-thread-title-update","data":{"threadId":123,"title":"New Title"}}
"""
return self.format_data(
"thread-title-update",
{
"threadId": thread_id,
"title": title,
},
)
# =========================================================================
# Error Part
# =========================================================================

View file

@ -32,6 +32,7 @@ from app.services.chat_session_state_service import (
clear_ai_responding,
set_ai_responding,
)
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService
from app.utils.content_utils import bootstrap_history_from_db
@ -1208,6 +1209,59 @@ async def stream_new_chat(
if completion_event:
yield completion_event
# Generate LLM title for new chats after first response
# Check if this is the first assistant response by counting existing assistant messages
from app.db import NewChatMessage, NewChatThread
from sqlalchemy import func
assistant_count_result = await session.execute(
select(func.count(NewChatMessage.id)).filter(
NewChatMessage.thread_id == chat_id,
NewChatMessage.role == "assistant",
)
)
assistant_message_count = assistant_count_result.scalar() or 0
# Only generate title on the first response (no prior assistant messages)
if assistant_message_count == 0:
generated_title = None
try:
# Generate title using the same LLM
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
# Truncate inputs to avoid context length issues
truncated_query = user_query[:500]
truncated_response = accumulated_text[:1000]
title_result = await title_chain.ainvoke({
"user_query": truncated_query,
"assistant_response": truncated_response,
})
# Extract and clean the title
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
# Validate the title (reasonable length)
if raw_title and len(raw_title) <= 100:
# Remove any quotes or extra formatting
generated_title = raw_title.strip('"\'')
except Exception:
generated_title = None
# Only update if LLM succeeded (keep truncated prompt title as fallback)
if generated_title:
# Fetch thread and update title
thread_result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
thread = thread_result.scalars().first()
if thread:
thread.title = generated_title
await session.commit()
# Notify frontend of the title update
yield streaming_service.format_thread_title_update(
chat_id, generated_title
)
# Finish the step and message
yield streaming_service.format_finish_step()
yield streaming_service.format_finish()

View file

@ -437,7 +437,10 @@ export default function NewChatPage() {
let isNewThread = false;
if (!currentThreadId) {
try {
const newThread = await createThread(searchSpaceId, "New Chat");
// Create thread with truncated prompt as initial title
const initialTitle =
userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : "");
const newThread = await createThread(searchSpaceId, initialTitle);
currentThreadId = newThread.id;
setThreadId(currentThreadId);
// Set currentThread so ChatHeader can show share button immediately
@ -827,6 +830,26 @@ export default function NewChatPage() {
break;
}
case "data-thread-title-update": {
// Handle thread title update from LLM-generated title
const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) {
// Update current thread state with new title
setCurrentThread((prev) =>
prev ? { ...prev, title: titleData.title } : prev
);
// Invalidate thread list to refresh sidebar
queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId)],
});
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)],
});
}
break;
}
case "error":
throw new Error(parsed.errorText || "Server error");
}

View file

@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils";
export interface SettingsNavItem {
@ -148,6 +149,11 @@ export function UserSettingsSidebar({
);
})}
</nav>
{/* Version display */}
<div className="mt-auto border-t px-6 py-3">
<p className="text-xs text-muted-foreground/50">v{APP_VERSION}</p>
</div>
</aside>
</>
);

View file

@ -14,6 +14,7 @@ import {
} from "@/components/ui/breadcrumb";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
import { getThreadFull } from "@/lib/chat/thread-persistence";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface {
@ -34,6 +35,16 @@ export function DashboardBreadcrumb() {
enabled: !!searchSpaceId,
});
// Extract chat thread ID from pathname for chat pages
const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
// Fetch thread details when on a chat page with a thread ID
const { data: threadData } = useQuery({
queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
queryFn: () => getThreadFull(Number(chatThreadId)),
enabled: !!chatThreadId && !!searchSpaceId,
});
// State to store document title for editor breadcrumb
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
@ -144,10 +155,11 @@ export function DashboardBreadcrumb() {
}
// Handle new-chat sub-sections (thread IDs)
// Don't show thread ID in breadcrumb - users identify chats by content, not by ID
// Show the chat title if available, otherwise fall back to "Chat"
if (section === "new-chat") {
const chatLabel = threadData?.title || t("chat") || "Chat";
breadcrumbs.push({
label: t("chat") || "Chat",
label: chatLabel,
});
return breadcrumbs;
}

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -21,6 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
@ -207,6 +208,12 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
// Rename dialog state
const [showRenameChatDialog, setShowRenameChatDialog] = useState(false);
const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null);
const [newChatTitle, setNewChatTitle] = useState("");
const [isRenamingChat, setIsRenamingChat] = useState(false);
// Delete/Leave search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
@ -421,6 +428,12 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true);
}, []);
const handleChatRename = useCallback((chat: ChatItem) => {
setChatToRename({ id: chat.id, name: chat.name });
setNewChatTitle(chat.name);
setShowRenameChatDialog(true);
}, []);
const handleChatArchive = useCallback(
async (chat: ChatItem) => {
const newArchivedState = !chat.archived;
@ -501,6 +514,29 @@ export function LayoutDataProvider({
}
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
// Rename handler
const confirmRenameChat = useCallback(async () => {
if (!chatToRename || !newChatTitle.trim()) return;
setIsRenamingChat(true);
try {
await updateThread(chatToRename.id, { title: newChatTitle.trim() });
toast.success(tSidebar("chat_renamed") || "Chat renamed");
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] });
} catch (error) {
console.error("Error renaming thread:", error);
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
} finally {
setIsRenamingChat(false);
setShowRenameChatDialog(false);
setChatToRename(null);
setNewChatTitle("");
}
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
// Page usage
const pageUsage = user
? {
@ -529,6 +565,7 @@ export function LayoutDataProvider({
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatRename={handleChatRename}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats}
@ -620,6 +657,57 @@ export function LayoutDataProvider({
</DialogContent>
</Dialog>
{/* Rename Chat Dialog */}
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<PencilIcon className="h-5 w-5" />
<span>{tSidebar("rename_chat") || "Rename Chat"}</span>
</DialogTitle>
<DialogDescription>
{tSidebar("rename_chat_description") || "Enter a new name for this conversation."}
</DialogDescription>
</DialogHeader>
<Input
value={newChatTitle}
onChange={(e) => setNewChatTitle(e.target.value)}
placeholder={tSidebar("chat_title_placeholder") || "Chat title"}
onKeyDown={(e) => {
if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) {
confirmRenameChat();
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowRenameChatDialog(false)}
disabled={isRenamingChat}
>
{tCommon("cancel")}
</Button>
<Button
onClick={confirmRenameChat}
disabled={isRenamingChat || !newChatTitle.trim()}
className="gap-2"
>
{isRenamingChat ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{tSidebar("renaming") || "Renaming..."}
</>
) : (
<>
<PencilIcon className="h-4 w-4" />
{tSidebar("rename") || "Rename"}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md">

View file

@ -54,6 +54,7 @@ interface LayoutShellProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -90,6 +91,7 @@ export function LayoutShell({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -147,6 +149,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
@ -215,6 +218,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}

View file

@ -1,6 +1,6 @@
"use client";
import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@ -17,6 +17,7 @@ interface ChatListItemProps {
isActive?: boolean;
archived?: boolean;
onClick?: () => void;
onRename?: () => void;
onArchive?: () => void;
onDelete?: () => void;
}
@ -26,6 +27,7 @@ export function ChatListItem({
isActive,
archived,
onClick,
onRename,
onArchive,
onDelete,
}: ChatListItemProps) {
@ -57,15 +59,26 @@ export function ChatListItem({
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
{archived ? (
<DropdownMenuContent align="end" side="right">
{onRename && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<PencilIcon className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
{archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>

View file

@ -24,6 +24,7 @@ interface MobileSidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -64,6 +65,7 @@ export function MobileSidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -142,6 +144,7 @@ export function MobileSidebar({
onOpenChange(false);
}}
onChatSelect={handleChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}

View file

@ -25,6 +25,7 @@ interface SidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -51,6 +52,7 @@ export function Sidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -163,6 +165,7 @@ export function Sidebar({
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
@ -215,6 +218,7 @@ export function Sidebar({
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>

View file

@ -17,6 +17,7 @@ interface PricingPlan {
price: string;
yearlyPrice: string;
period: string;
billingText?: string;
features: string[];
description: string;
buttonText: string;
@ -35,7 +36,7 @@ export function Pricing({
title = "Simple, Transparent Pricing",
description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
}: PricingProps) {
const [isMonthly, setIsMonthly] = useState(true);
const [isMonthly, setIsMonthly] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const switchRef = useRef<HTMLButtonElement>(null);
@ -183,7 +184,7 @@ export function Pricing({
</div>
<p className="text-xs leading-5 text-muted-foreground">
{isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"}
{plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}
</p>
<ul className="mt-5 gap-2 flex flex-col">

View file

@ -4,44 +4,47 @@ import { Pricing } from "@/components/pricing";
const demoPlans = [
{
name: "COMMUNITY",
name: "FREE",
price: "0",
yearlyPrice: "0",
period: "forever",
period: "",
billingText: "Includes 30 day PRO trial",
features: [
"Community support",
"Supports 100+ LLMs",
"Supports OpenAI spec and LiteLLM",
"Supports local vLLM or Ollama setups",
"6000+ embedding models",
"Open source on GitHub",
"Upload and chat with 300+ pages of content",
"Connects with 8 popular sources, like Drive and Notion.",
"Includes limited access to ChatGPT, Claude, and DeepSeek models",
"Supports 100+ more LLMs, including Gemini, Llama and many more.",
"50+ File extensions supported.",
"Podcasts support with local TTS providers.",
"Connects with 15+ external sources, like Drive and Notion.",
"Generate podcasts in seconds.",
"Cross-Browser Extension for dynamic webpages including authenticated content",
"Role-based access control (RBAC)",
"Collaboration and team features",
"Community support on Discord",
],
description: "Open source version with powerful features",
buttonText: "Dive In",
href: "/docs",
description: "Powerful features with some limitations",
buttonText: "Get Started",
href: "/",
isPopular: false,
},
{
name: "CLOUD",
price: "0",
yearlyPrice: "0",
period: "in beta",
name: "PRO",
price: "10",
yearlyPrice: "10",
period: "user / month",
billingText: "billed annually",
features: [
"Everything in Community",
"Email support",
"Get started in seconds",
"Instant access to new features",
"Easy access from anywhere",
"Remote team management and collaboration",
"Everything in Free",
"Upload and chat with 5,000+ pages of content",
"Connects with 15+ external sources, like Slack and Airtable.",
"Includes extended access to ChatGPT, Claude, and DeepSeek models",
"Collaboration and commenting features",
"Shared BYOK (Bring Your Own Key)",
"Team and role management",
"Planned: Centralized billing",
"Priority support",
],
description: "Instant access for individuals and teams",
buttonText: "Get Started",
href: "/",
description: "The AIknowledge base for individuals and teams",
buttonText: "Upgrade",
href: "/contact",
isPopular: true,
},
{
@ -49,18 +52,21 @@ const demoPlans = [
price: "Contact Us",
yearlyPrice: "Contact Us",
period: "",
billingText: "",
features: [
"Everything in Community",
"Priority support",
"Everything in Pro",
"Connect and chat with virtually unlimited pages of content",
"Limit models and/or providers",
"On-prem or VPC deployment",
"Planned: Audit logs and compliance",
"Planned: SSO, OIDC & SAML",
"Planned: Role-based access control (RBAC)",
"White-glove setup and deployment",
"Monthly managed updates and maintenance",
"On-prem or VPC deployment",
"Audit logs and compliance",
"SSO, OIDC & SAML",
"SLA guarantee",
"Uptime guarantee on VPC",
"SLA commitments",
"Dedicated support",
],
description: "Professional, customized setup for large organizations",
description: "Customized setup for large organizations",
buttonText: "Contact Sales",
href: "/contact",
isPopular: false,

View file

@ -178,6 +178,18 @@ export const LLM_MODELS: LLMModel[] = [
},
// Google (Gemini)
{
value: "gemini-3-flash-preview",
label: "Gemini 3 Flash",
provider: "GOOGLE",
contextWindow: "1M",
},
{
value: "gemini-3-pro-preview",
label: "Gemini 3 Pro",
provider: "GOOGLE",
contextWindow: "1M",
},
{
value: "gemini-2.5-flash",
label: "Gemini 2.5 Flash",

View file

@ -9,6 +9,8 @@
* as it may prevent the sed replacement from working correctly.
*/
import packageJson from "../package.json";
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
@ -28,6 +30,10 @@ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
// Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted";
// App version - defaults to package.json version
// Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version;
// Helper to check if local auth is enabled
export const isLocalAuth = () => AUTH_TYPE === "LOCAL";

View file

@ -676,6 +676,13 @@
"unarchive": "Restore",
"chat_archived": "Chat archived",
"chat_unarchived": "Chat restored",
"chat_renamed": "Chat renamed",
"error_renaming_chat": "Failed to rename chat",
"rename": "Rename",
"rename_chat": "Rename Chat",
"rename_chat_description": "Enter a new name for this conversation.",
"chat_title_placeholder": "Chat title",
"renaming": "Renaming...",
"no_archived_chats": "No archived chats",
"error_archiving_chat": "Failed to archive chat",
"new_chat": "New chat",

View file

@ -661,6 +661,13 @@
"unarchive": "恢复",
"chat_archived": "对话已归档",
"chat_unarchived": "对话已恢复",
"chat_renamed": "对话已重命名",
"error_renaming_chat": "重命名对话失败",
"rename": "重命名",
"rename_chat": "重命名对话",
"rename_chat_description": "为此对话输入新名称。",
"chat_title_placeholder": "对话标题",
"renaming": "重命名中...",
"no_archived_chats": "暂无已归档对话",
"error_archiving_chat": "归档对话失败",
"new_chat": "新对话",