Merge PR #1487
This commit is contained in:
commit
7661d035c9
7 changed files with 197 additions and 9 deletions
|
|
@ -21,6 +21,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.
|
||||
|
|
@ -504,3 +505,83 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRenameSession updates the title/summary of a specific 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 req struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
|
||||
dir, err := h.sessionsDir()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
|
||||
jsonlPath := base + ".jsonl"
|
||||
metaPath := base + ".meta.json"
|
||||
legacyPath := base + ".json"
|
||||
|
||||
// Try JSONL session first (new format)
|
||||
if _, statErr := os.Stat(jsonlPath); statErr == nil {
|
||||
meta, readErr := h.readSessionMeta(metaPath, picoSessionPrefix+sessionID)
|
||||
if readErr != nil {
|
||||
http.Error(w, "failed to read session metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
meta.Summary = req.Title
|
||||
data, marshalErr := json.Marshal(meta)
|
||||
if marshalErr != nil {
|
||||
http.Error(w, "failed to marshal metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if writeErr := os.WriteFile(metaPath, data, 0o644); writeErr != nil {
|
||||
http.Error(w, "failed to write metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Try legacy JSON session
|
||||
if _, statErr := os.Stat(legacyPath); statErr == nil {
|
||||
data, readErr := os.ReadFile(legacyPath)
|
||||
if readErr != nil {
|
||||
http.Error(w, "failed to read session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var sess sessionFile
|
||||
if parseErr := json.Unmarshal(data, &sess); parseErr != nil {
|
||||
http.Error(w, "failed to parse session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sess.Summary = req.Title
|
||||
data, marshalErr := json.Marshal(sess)
|
||||
if marshalErr != nil {
|
||||
http.Error(w, "failed to marshal session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if writeErr := os.WriteFile(legacyPath, data, 0o644); writeErr != nil {
|
||||
http.Error(w, "failed to write session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,3 +49,14 @@ export async function deleteSession(id: string): Promise<void> {
|
|||
throw new Error(`Failed to delete session ${id}: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameSession(id: string, title: string): Promise<void> {
|
||||
const res = await fetch(`/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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export function ChatPage() {
|
|||
observerRef,
|
||||
loadSessions,
|
||||
handleDeleteSession,
|
||||
handleRenameSession,
|
||||
} = useSessionHistory({
|
||||
activeSessionId,
|
||||
onDeletedActiveSession: newChat,
|
||||
|
|
@ -130,6 +131,7 @@ export function ChatPage() {
|
|||
}}
|
||||
onSwitchSession={switchSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={handleRenameSession}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { IconHistory, IconTrash } from "@tabler/icons-react"
|
||||
import { IconHistory, IconPencil, IconTrash } from "@tabler/icons-react"
|
||||
import dayjs from "dayjs"
|
||||
import type { RefObject } from "react"
|
||||
import { type KeyboardEvent, 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,8 +36,41 @@ export function SessionHistoryMenu({
|
|||
onOpenChange,
|
||||
onSwitchSession,
|
||||
onDeleteSession,
|
||||
onRenameSession,
|
||||
}: SessionHistoryMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState("")
|
||||
|
||||
const startRename = (session: SessionSummary, e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setRenamingId(session.id)
|
||||
setRenameValue(session.title || session.preview)
|
||||
}
|
||||
|
||||
const commitRename = (sessionId: string) => {
|
||||
const trimmed = renameValue.trim()
|
||||
if (trimmed) {
|
||||
onRenameSession(sessionId, trimmed)
|
||||
}
|
||||
setRenamingId(null)
|
||||
}
|
||||
|
||||
const handleRenameKeyDown = (
|
||||
e: KeyboardEvent<HTMLInputElement>,
|
||||
sessionId: string,
|
||||
) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
commitRename(sessionId)
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setRenamingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
|
|
@ -46,7 +80,20 @@ export function SessionHistoryMenu({
|
|||
<span className="hidden sm:inline">{t("chat.history")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-72"
|
||||
onInteractOutside={(e) => {
|
||||
// Prevent dropdown from closing while user is typing in the rename input
|
||||
if (renamingId) e.preventDefault()
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (renamingId) {
|
||||
e.preventDefault()
|
||||
setRenamingId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
{loadError && (
|
||||
<DropdownMenuItem disabled>
|
||||
|
|
@ -65,20 +112,48 @@ export function SessionHistoryMenu({
|
|||
sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-8 ${
|
||||
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-16 ${
|
||||
session.id === activeSessionId ? "bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => onSwitchSession(session.id)}
|
||||
onClick={() => {
|
||||
if (renamingId === session.id) return
|
||||
onSwitchSession(session.id)
|
||||
}}
|
||||
>
|
||||
{renamingId === session.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="bg-background border-input focus:ring-ring w-full rounded border px-1 py-0.5 text-sm font-medium outline-none focus:ring-1"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => handleRenameKeyDown(e, session.id)}
|
||||
onBlur={() => commitRename(session.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="line-clamp-1 text-sm font-medium">
|
||||
{session.title || session.preview}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.messagesCount", {
|
||||
count: session.message_count,
|
||||
})}{" "}
|
||||
· {dayjs(session.updated).fromNow()}
|
||||
</span>
|
||||
|
||||
{/* Rename button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("chat.renameSession")}
|
||||
className="text-muted-foreground hover:bg-accent absolute top-1/2 right-8 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => startRename(session, e)}
|
||||
>
|
||||
<IconPencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -100,6 +105,17 @@ export function useSessionHistory({
|
|||
[activeSessionId, onDeletedActiveSession],
|
||||
)
|
||||
|
||||
const handleRenameSession = useCallback(async (id: string, title: string) => {
|
||||
try {
|
||||
await renameSession(id, title)
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, title } : s)),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Failed to rename session:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
sessions,
|
||||
hasMore,
|
||||
|
|
@ -108,5 +124,6 @@ export function useSessionHistory({
|
|||
observerRef,
|
||||
loadSessions,
|
||||
handleDeleteSession,
|
||||
handleRenameSession,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"historyOpenFailed": "Failed to open this chat history",
|
||||
"loadingMore": "Loading more...",
|
||||
"deleteSession": "Delete session",
|
||||
"renameSession": "Rename session",
|
||||
"messagesCount": "{{count}} messages",
|
||||
"noModel": "Select model",
|
||||
"empty": {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"historyOpenFailed": "打开该历史会话失败",
|
||||
"loadingMore": "加载更多...",
|
||||
"deleteSession": "删除会话",
|
||||
"renameSession": "重命名会话",
|
||||
"messagesCount": "{{count}} 条消息",
|
||||
"noModel": "选择模型",
|
||||
"empty": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue