feat: add chat history management actions
This commit is contained in:
@@ -264,11 +264,14 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 0; background: var(--color-surface-dark); color: var(--color-on-dark); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 140ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-6); border-radius: var(--radius-md); font-weight: 500; }
|
||||
.sidebar-label { display: block; padding: 0 var(--space-3) var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
|
||||
.session-list { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.session-list button { position: relative; width: 100%; display: grid; gap: 2px; border: 0; background: transparent; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; min-height: 52px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border-radius: var(--radius-md); color: var(--color-ink-secondary); }
|
||||
.session-list button.is-active { color: var(--color-ink); background: var(--color-selected); }
|
||||
.session-list button.is-active::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--color-action); }
|
||||
.session-list button > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; }
|
||||
.session-list button small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; }
|
||||
.session-row { position: relative; display: grid; gap: 4px; border-radius: var(--radius-md); color: var(--color-ink-secondary); }
|
||||
.session-row > button { position: relative; width: 100%; display: grid; gap: 2px; border: 0; background: transparent; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; min-height: 52px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border-radius: var(--radius-md); color: inherit; }
|
||||
.session-row.is-active { color: var(--color-ink); background: var(--color-selected); }
|
||||
.session-row.is-active::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--color-action); }
|
||||
.session-row > button > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; }
|
||||
.session-row > button small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; }
|
||||
.session-actions { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 var(--space-2) var(--space-2) var(--space-4); }
|
||||
.session-actions button { min-height: 26px; width: auto; padding: 0 7px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas); color: var(--color-ink-secondary); font-size: 11px; }
|
||||
.sidebar-footer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--color-border); }
|
||||
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
|
||||
.profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; }
|
||||
|
||||
+82
-19
@@ -667,6 +667,8 @@ export default function Home() {
|
||||
const [redeeming, setRedeeming] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
|
||||
const [archivedSessionIds, setArchivedSessionIds] = useState<string[]>([]);
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
|
||||
const [activeSessionId, setActiveSessionId] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -710,16 +712,32 @@ export default function Home() {
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
|
||||
const visibleSessions = sessions
|
||||
.filter((session) => !archivedSessionIds.includes(session.id))
|
||||
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
|
||||
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
|
||||
const isLoading = pendingSessionId === activeSession?.id;
|
||||
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
|
||||
const accountId = account?.user.id;
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? [];
|
||||
const accountId = account?.user.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
const prefix = `jyotisha-session-controls:${accountId}:`;
|
||||
setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]"));
|
||||
setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]"));
|
||||
}, [accountId, hydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
const prefix = `jyotisha-session-controls:${accountId}:`;
|
||||
localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds));
|
||||
localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds));
|
||||
}, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]);
|
||||
const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? [];
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
setChartLibrary([]);
|
||||
@@ -1121,6 +1139,45 @@ export default function Home() {
|
||||
if (insertError) throw new Error(`云端同步失败:${insertError.message}`);
|
||||
}
|
||||
|
||||
async function renameSession(session: ChatSession) {
|
||||
const title = window.prompt("重命名聊天记录", session.title)?.trim();
|
||||
if (!title || title === session.title) return;
|
||||
const nextSession = { ...session, title, updatedAt: timestamp() };
|
||||
updateSession(session.id, () => nextSession);
|
||||
try {
|
||||
await persistSession(nextSession);
|
||||
} catch (caught) {
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(session: ChatSession) {
|
||||
if (!account || !window.confirm(`删除“${session.title}”?此操作不可恢复。`)) return;
|
||||
const previousSessions = sessions;
|
||||
const nextSessions = sessions.filter((item) => item.id !== session.id);
|
||||
setSessions(nextSessions);
|
||||
setPinnedSessionIds((current) => current.filter((id) => id !== session.id));
|
||||
setArchivedSessionIds((current) => current.filter((id) => id !== session.id));
|
||||
if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? "");
|
||||
try {
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
const { error } = await supabase.from("chat_sessions").delete().eq("id", session.id).eq("user_id", account.user.id);
|
||||
if (error) throw error;
|
||||
} catch (caught) {
|
||||
setSessions(previousSessions);
|
||||
setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function togglePinnedSession(sessionId: string) {
|
||||
setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
|
||||
}
|
||||
|
||||
function toggleArchivedSession(sessionId: string) {
|
||||
setArchivedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
|
||||
if (activeSessionId === sessionId) setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? "");
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
if (!account || !modelCatalog || creatingSession) return;
|
||||
setMobileSidebarOpen(false);
|
||||
@@ -1934,23 +1991,29 @@ export default function Home() {
|
||||
<nav className="session-nav" aria-label="聊天记录">
|
||||
<span className="sidebar-label">聊天记录</span>
|
||||
<div className="session-list">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
className={session.id === activeSession?.id ? "is-active" : ""}
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSessionId(session.id);
|
||||
setDraft("");
|
||||
setComposerNotice("");
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
disabled={Boolean(pendingSessionId) || cancellationPending}
|
||||
aria-current={session.id === activeSession?.id ? "page" : undefined}
|
||||
>
|
||||
<span>{session.title}</span>
|
||||
{session.messages.length > 0 && <small>{session.messages.length} 条消息</small>}
|
||||
</button>
|
||||
{visibleSessions.map((session) => (
|
||||
<div className={`session-row ${session.id === activeSession?.id ? "is-active" : ""}`} key={session.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSessionId(session.id);
|
||||
setDraft("");
|
||||
setComposerNotice("");
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
disabled={Boolean(pendingSessionId) || cancellationPending}
|
||||
aria-current={session.id === activeSession?.id ? "page" : undefined}
|
||||
>
|
||||
<span>{pinnedSessionIds.includes(session.id) ? "★ " : ""}{session.title}</span>
|
||||
{session.messages.length > 0 && <small>{session.messages.length} 条消息</small>}
|
||||
</button>
|
||||
<div className="session-actions" aria-label={`${session.title} 操作`}>
|
||||
<button type="button" onClick={() => togglePinnedSession(session.id)}>{pinnedSessionIds.includes(session.id) ? "取消置顶" : "置顶"}</button>
|
||||
<button type="button" onClick={() => void renameSession(session)}>重命名</button>
|
||||
<button type="button" onClick={() => toggleArchivedSession(session.id)}>归档</button>
|
||||
<button type="button" onClick={() => void deleteSession(session)}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PAGE = Path("frontend/src/app/page.tsx")
|
||||
|
||||
|
||||
def test_chat_history_management_actions_are_exposed() -> None:
|
||||
source = PAGE.read_text(encoding="utf-8")
|
||||
for expected in (
|
||||
"renameSession",
|
||||
"deleteSession",
|
||||
"togglePinnedSession",
|
||||
"toggleArchivedSession",
|
||||
"置顶",
|
||||
"重命名",
|
||||
"归档",
|
||||
"删除",
|
||||
):
|
||||
assert expected in source
|
||||
Reference in New Issue
Block a user