mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-09 15:58:41 +00:00
Sort chat recents by last activity (#6844)
* show chat by by last activity * Update chat thread updated_at logic and enhance sidebar chat item handling * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
37075c5422
commit
07ecdb34c0
9 changed files with 252 additions and 13 deletions
|
|
@ -56,6 +56,7 @@ class ChatThread(BaseModel):
|
|||
projectId: Optional[str] = None
|
||||
archived: bool = False
|
||||
createdAt: int
|
||||
updatedAt: Optional[int] = None
|
||||
openaiCodeExecContainerId: Optional[str] = None
|
||||
anthropicCodeExecContainerId: Optional[str] = None
|
||||
forkedFromThreadId: Optional[str] = None
|
||||
|
|
@ -70,6 +71,7 @@ class ChatThreadPatch(BaseModel):
|
|||
projectId: Optional[str] = None
|
||||
archived: Optional[bool] = None
|
||||
createdAt: Optional[int] = None
|
||||
updatedAt: Optional[int] = None
|
||||
openaiCodeExecContainerId: Optional[str] = None
|
||||
anthropicCodeExecContainerId: Optional[str] = None
|
||||
|
||||
|
|
@ -252,7 +254,7 @@ async def patch_thread(
|
|||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
patch = payload.model_dump(exclude_unset = True)
|
||||
for field in ("title", "modelType", "modelId", "archived", "createdAt"):
|
||||
for field in ("title", "modelType", "modelId", "archived", "createdAt", "updatedAt"):
|
||||
if field in patch and patch[field] is None:
|
||||
raise HTTPException(status_code = 400, detail = f"{field} cannot be null")
|
||||
if patch.get("projectId") and get_chat_project(patch["projectId"]) is None:
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
project_id TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER,
|
||||
openai_code_exec_container_id TEXT,
|
||||
anthropic_code_exec_container_id TEXT,
|
||||
forked_from_thread_id TEXT,
|
||||
|
|
@ -261,6 +262,24 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
conn.execute("ALTER TABLE chat_threads ADD COLUMN forked_from_thread_id TEXT")
|
||||
if "forked_from_message_id" not in chat_thread_cols:
|
||||
conn.execute("ALTER TABLE chat_threads ADD COLUMN forked_from_message_id TEXT")
|
||||
if "updated_at" not in chat_thread_cols:
|
||||
conn.execute("ALTER TABLE chat_threads ADD COLUMN updated_at INTEGER")
|
||||
# Floor at created_at: forked threads copy older ancestor messages,
|
||||
# so the fork's creation time must win over the branch message times.
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE chat_threads SET updated_at = MAX(
|
||||
COALESCE(
|
||||
(
|
||||
SELECT MAX(m.created_at) FROM chat_messages m
|
||||
WHERE m.thread_id = chat_threads.id
|
||||
),
|
||||
created_at
|
||||
),
|
||||
created_at
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
|
|
@ -992,6 +1011,9 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict:
|
|||
"projectId": data.get("project_id") or None,
|
||||
"archived": bool(data["archived"]),
|
||||
"createdAt": data["created_at"],
|
||||
"updatedAt": data.get("updated_at")
|
||||
if data.get("updated_at") is not None
|
||||
else data["created_at"],
|
||||
"openaiCodeExecContainerId": data.get("openai_code_exec_container_id"),
|
||||
"anthropicCodeExecContainerId": data.get("anthropic_code_exec_container_id"),
|
||||
"forkedFromThreadId": data.get("forked_from_thread_id"),
|
||||
|
|
@ -1039,8 +1061,8 @@ def upsert_chat_thread(thread: dict) -> dict:
|
|||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_threads
|
||||
(id, title, model_type, model_id, pair_id, project_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id, forked_from_thread_id, forked_from_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, title, model_type, model_id, pair_id, project_id, archived, created_at, updated_at, openai_code_exec_container_id, anthropic_code_exec_container_id, forked_from_thread_id, forked_from_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
model_type = excluded.model_type,
|
||||
|
|
@ -1049,6 +1071,7 @@ def upsert_chat_thread(thread: dict) -> dict:
|
|||
project_id = excluded.project_id,
|
||||
archived = excluded.archived,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = COALESCE(excluded.updated_at, chat_threads.updated_at),
|
||||
openai_code_exec_container_id = excluded.openai_code_exec_container_id,
|
||||
anthropic_code_exec_container_id = excluded.anthropic_code_exec_container_id,
|
||||
forked_from_thread_id = excluded.forked_from_thread_id,
|
||||
|
|
@ -1063,6 +1086,7 @@ def upsert_chat_thread(thread: dict) -> dict:
|
|||
thread.get("projectId"),
|
||||
1 if thread.get("archived") else 0,
|
||||
int(thread["createdAt"]),
|
||||
int(thread["updatedAt"]) if thread.get("updatedAt") is not None else None,
|
||||
thread.get("openaiCodeExecContainerId"),
|
||||
thread.get("anthropicCodeExecContainerId"),
|
||||
thread.get("forkedFromThreadId"),
|
||||
|
|
@ -1084,6 +1108,7 @@ def update_chat_thread(id: str, patch: dict) -> Optional[dict]:
|
|||
"projectId": ("project_id", patch.get("projectId")),
|
||||
"archived": ("archived", 1 if patch.get("archived") else 0),
|
||||
"createdAt": ("created_at", patch.get("createdAt")),
|
||||
"updatedAt": ("updated_at", patch.get("updatedAt")),
|
||||
"openaiCodeExecContainerId": (
|
||||
"openai_code_exec_container_id",
|
||||
patch.get("openaiCodeExecContainerId"),
|
||||
|
|
@ -1155,7 +1180,8 @@ def list_chat_threads(
|
|||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM chat_threads {where} ORDER BY created_at DESC",
|
||||
f"SELECT * FROM chat_threads {where} "
|
||||
"ORDER BY COALESCE(updated_at, created_at) DESC, created_at DESC",
|
||||
values,
|
||||
).fetchall()
|
||||
return [_chat_thread_from_row(row) for row in rows]
|
||||
|
|
@ -1394,6 +1420,44 @@ def _raise_if_chat_message_thread_conflicts(
|
|||
)
|
||||
|
||||
|
||||
def _bump_chat_thread_updated_at(
|
||||
conn: sqlite3.Connection, thread_id: str, message_created_at: int
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE chat_threads
|
||||
SET updated_at = MAX(COALESCE(updated_at, created_at), ?)
|
||||
WHERE id = ?
|
||||
""",
|
||||
(message_created_at, thread_id),
|
||||
)
|
||||
|
||||
|
||||
def _recompute_chat_thread_updated_at(conn: sqlite3.Connection, thread_id: str) -> None:
|
||||
"""Set updated_at from the remaining messages, floored at created_at.
|
||||
|
||||
Unlike the ratchet-only bump, this can lower updated_at -- needed after
|
||||
pruning, which may delete the thread's newest message.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE chat_threads
|
||||
SET updated_at = MAX(
|
||||
COALESCE(
|
||||
(
|
||||
SELECT MAX(m.created_at) FROM chat_messages m
|
||||
WHERE m.thread_id = chat_threads.id
|
||||
),
|
||||
created_at
|
||||
),
|
||||
created_at
|
||||
)
|
||||
WHERE id = ?
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
|
||||
def upsert_chat_message(message: dict) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
|
|
@ -1432,6 +1496,7 @@ def upsert_chat_message(message: dict) -> dict:
|
|||
int(message["createdAt"]),
|
||||
),
|
||||
)
|
||||
_bump_chat_thread_updated_at(conn, message["threadId"], int(message["createdAt"]))
|
||||
conn.commit()
|
||||
return message
|
||||
except Exception:
|
||||
|
|
@ -1484,6 +1549,12 @@ def sync_chat_messages(
|
|||
for m in messages
|
||||
],
|
||||
)
|
||||
if prune_missing:
|
||||
_recompute_chat_thread_updated_at(conn, thread_id)
|
||||
elif messages:
|
||||
_bump_chat_thread_updated_at(
|
||||
conn, thread_id, max(int(m["createdAt"]) for m in messages)
|
||||
)
|
||||
conn.commit()
|
||||
return list_chat_messages(thread_id)
|
||||
except ChatMessageConflictError:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
|
@ -11,6 +12,7 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
from storage import studio_db
|
||||
from utils.paths import studio_db_path
|
||||
|
||||
|
||||
def _reset_studio_db(
|
||||
|
|
@ -108,6 +110,138 @@ def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
|
|||
assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}]
|
||||
|
||||
|
||||
def test_chat_thread_updated_at_bumps_on_message_writes(tmp_path, monkeypatch):
|
||||
_reset_studio_db(tmp_path, monkeypatch)
|
||||
thread = studio_db.upsert_chat_thread(_thread())
|
||||
assert thread["updatedAt"] == thread["createdAt"]
|
||||
|
||||
studio_db.upsert_chat_message(_message("msg-1", 1_700_000_000_500, "hi"))
|
||||
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
|
||||
|
||||
studio_db.upsert_chat_message(_message("msg-0", 1_600_000_000_000, "old"))
|
||||
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
|
||||
|
||||
studio_db.sync_chat_messages(
|
||||
"thread-1",
|
||||
[_message("msg-2", 1_700_000_001_000, "newer")],
|
||||
)
|
||||
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_001_000
|
||||
|
||||
|
||||
def test_chat_thread_updated_at_recomputed_when_pruning(tmp_path, monkeypatch):
|
||||
_reset_studio_db(tmp_path, monkeypatch)
|
||||
thread = studio_db.upsert_chat_thread(_thread())
|
||||
studio_db.sync_chat_messages(
|
||||
"thread-1",
|
||||
[
|
||||
_message("msg-1", 1_700_000_000_500, "older"),
|
||||
_message("msg-2", 1_700_000_001_000, "newest"),
|
||||
],
|
||||
prune_missing = True,
|
||||
)
|
||||
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_001_000
|
||||
|
||||
# Pruning the newest message must lower updated_at to the remaining one.
|
||||
studio_db.sync_chat_messages(
|
||||
"thread-1",
|
||||
[_message("msg-1", 1_700_000_000_500, "older")],
|
||||
prune_missing = True,
|
||||
)
|
||||
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
|
||||
|
||||
# Pruning every message falls back to created_at.
|
||||
studio_db.sync_chat_messages("thread-1", [], prune_missing = True)
|
||||
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == thread["createdAt"]
|
||||
|
||||
|
||||
def test_chat_thread_updated_at_survives_thread_resave(tmp_path, monkeypatch):
|
||||
_reset_studio_db(tmp_path, monkeypatch)
|
||||
studio_db.upsert_chat_thread(_thread())
|
||||
studio_db.upsert_chat_message(_message("msg-1", 1_700_000_000_500, "hi"))
|
||||
|
||||
studio_db.upsert_chat_thread(_thread())
|
||||
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500
|
||||
|
||||
|
||||
def test_list_chat_threads_orders_by_last_activity(tmp_path, monkeypatch):
|
||||
_reset_studio_db(tmp_path, monkeypatch)
|
||||
older = _thread("thread-old")
|
||||
older["createdAt"] = 1_700_000_000_000
|
||||
newer = _thread("thread-new")
|
||||
newer["createdAt"] = 1_700_000_100_000
|
||||
studio_db.upsert_chat_thread(older)
|
||||
studio_db.upsert_chat_thread(newer)
|
||||
assert [t["id"] for t in studio_db.list_chat_threads()] == ["thread-new", "thread-old"]
|
||||
|
||||
studio_db.upsert_chat_message(
|
||||
_message("msg-1", 1_700_000_200_000, "hi", thread_id = "thread-old")
|
||||
)
|
||||
assert [t["id"] for t in studio_db.list_chat_threads()] == ["thread-old", "thread-new"]
|
||||
|
||||
|
||||
def test_chat_threads_updated_at_migration_backfills_from_messages(tmp_path, monkeypatch):
|
||||
_reset_studio_db(tmp_path, monkeypatch)
|
||||
db_path = studio_db_path()
|
||||
db_path.parent.mkdir(parents = True, exist_ok = True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE chat_threads (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT,
|
||||
pair_id TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content_json TEXT NOT NULL,
|
||||
attachments_json TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
|
||||
("thread-with-msgs", "Old", "base", 1_700_000_000_000),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
|
||||
("thread-empty", "Empty", "base", 1_700_000_050_000),
|
||||
)
|
||||
# Fork-like thread: copied ancestor messages predate the thread itself.
|
||||
conn.execute(
|
||||
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
|
||||
("thread-fork", "Fork", "base", 1_700_000_100_000),
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO chat_messages (id, thread_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("m1", "thread-with-msgs", "user", "[]", 1_700_000_001_000),
|
||||
("m2", "thread-with-msgs", "assistant", "[]", 1_700_000_002_000),
|
||||
("m3", "thread-fork", "user", "[]", 1_700_000_001_000),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert studio_db.get_chat_thread("thread-with-msgs")["updatedAt"] == 1_700_000_002_000
|
||||
assert studio_db.get_chat_thread("thread-empty")["updatedAt"] == 1_700_000_050_000
|
||||
assert studio_db.get_chat_thread("thread-fork")["updatedAt"] == 1_700_000_100_000
|
||||
|
||||
|
||||
def test_chat_projects_delete_cascades_threads_and_messages(tmp_path, monkeypatch):
|
||||
_reset_studio_db(tmp_path, monkeypatch)
|
||||
project = studio_db.upsert_chat_project(_project())
|
||||
|
|
|
|||
|
|
@ -359,7 +359,11 @@ export function AppSidebar() {
|
|||
const activeProjectId = isChatRoute
|
||||
? ((search.project as string | undefined) ?? null)
|
||||
: null;
|
||||
const { items: allChatItems } = useChatSidebarItems({
|
||||
const {
|
||||
items: allChatItems,
|
||||
archivedItems: archivedChatItems,
|
||||
loaded: chatItemsLoaded,
|
||||
} = useChatSidebarItems({
|
||||
enabled: !isStudioRoute,
|
||||
requireMessages: false,
|
||||
});
|
||||
|
|
@ -1306,6 +1310,16 @@ export function AppSidebar() {
|
|||
renderChatSidebarItem(item, "recent"),
|
||||
)}
|
||||
</SidebarMenu>
|
||||
{/* "No chats yet" only when there is truly no history:
|
||||
project-scoped and archived threads leave Recents empty
|
||||
but still count as existing chats. */}
|
||||
{chatItemsLoaded &&
|
||||
allChatItems.length === 0 &&
|
||||
archivedChatItems.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("shell.navigation.noChatsYet")}
|
||||
</p>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
|
|
|
|||
|
|
@ -27,16 +27,21 @@ export interface SidebarItem {
|
|||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
isFork?: boolean;
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
function lastActivityAt(thread: ThreadRecord): number {
|
||||
return thread.updatedAt ?? thread.createdAt;
|
||||
}
|
||||
|
||||
export function groupThreads(
|
||||
threads: ThreadRecord[],
|
||||
archived = false,
|
||||
): SidebarItem[] {
|
||||
const items: SidebarItem[] = [];
|
||||
const seenPairs = new Set<string>();
|
||||
const pairItems = new Map<string, SidebarItem>();
|
||||
|
||||
for (const t of threads) {
|
||||
// Coerce archived to a boolean before comparing. Legacy threads (from the
|
||||
|
|
@ -48,30 +53,35 @@ export function groupThreads(
|
|||
continue;
|
||||
}
|
||||
if (t.pairId) {
|
||||
if (seenPairs.has(t.pairId)) {
|
||||
const existing = pairItems.get(t.pairId);
|
||||
if (existing) {
|
||||
existing.updatedAt = Math.max(existing.updatedAt, lastActivityAt(t));
|
||||
continue;
|
||||
}
|
||||
seenPairs.add(t.pairId);
|
||||
items.push({
|
||||
const item: SidebarItem = {
|
||||
type: "compare",
|
||||
id: t.pairId,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: lastActivityAt(t),
|
||||
projectId: t.projectId ?? null,
|
||||
});
|
||||
};
|
||||
pairItems.set(t.pairId, item);
|
||||
items.push(item);
|
||||
} else if (!t.pairId) {
|
||||
items.push({
|
||||
type: "single",
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: lastActivityAt(t),
|
||||
isFork: Boolean(t.forkedFromThreadId),
|
||||
projectId: t.projectId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return items.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
// Streaming fires CHAT_HISTORY_UPDATED_EVENT per chunk. Debounce so each quiet
|
||||
|
|
@ -84,6 +94,7 @@ export function useChatSidebarItems(options?: {
|
|||
requireMessages?: boolean;
|
||||
}) {
|
||||
const [allThreads, setAllThreads] = useState<ThreadRecord[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const enabled = options?.enabled ?? true;
|
||||
const requireMessages = options?.requireMessages ?? true;
|
||||
|
||||
|
|
@ -111,6 +122,7 @@ export function useChatSidebarItems(options?: {
|
|||
// were in flight, or if the effect was torn down.
|
||||
if (cancelled || seq !== requestSeq) return;
|
||||
setAllThreads(threads);
|
||||
setLoaded(true);
|
||||
} catch (error) {
|
||||
if (isExpectedBackgroundChatStorageError(error)) {
|
||||
return;
|
||||
|
|
@ -144,7 +156,7 @@ export function useChatSidebarItems(options?: {
|
|||
const archivedItems = groupThreads(allThreads ?? [], true);
|
||||
const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint));
|
||||
|
||||
return { items, archivedItems, canCompare };
|
||||
return { items, archivedItems, canCompare, loaded };
|
||||
}
|
||||
|
||||
function cancelIfRunning(threadId: string): void {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface ThreadRecord {
|
|||
projectId?: string | null;
|
||||
archived: boolean;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
/**
|
||||
* OpenAI shell tool container id from a prior response. When set, the
|
||||
* next turn reuses it via `environment.type="container_reference"` so
|
||||
|
|
|
|||
|
|
@ -590,7 +590,10 @@ export async function listStoredChatThreads(
|
|||
}
|
||||
return Array.from(byId.values())
|
||||
.filter((thread) => matchesThreadListArgs(thread, args))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listStoredChatThreadsWithMessages(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const en = {
|
|||
recipes: "Recipes",
|
||||
export: "Export",
|
||||
recents: "Recents",
|
||||
noChatsYet: "No chats yet",
|
||||
settings: "Settings",
|
||||
api: "API",
|
||||
lightMode: "Light Mode",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const zhCN = {
|
|||
recipes: "配方",
|
||||
export: "导出",
|
||||
recents: "最近",
|
||||
noChatsYet: "暂无对话",
|
||||
settings: "设置",
|
||||
api: "API",
|
||||
lightMode: "浅色模式",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue