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", h.handleListSessions)
|
||||||
mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession)
|
mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession)
|
||||||
mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession)
|
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.
|
// 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)
|
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}`)
|
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,
|
observerRef,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
|
handleRenameSession,
|
||||||
} = useSessionHistory({
|
} = useSessionHistory({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
onDeletedActiveSession: newChat,
|
onDeletedActiveSession: newChat,
|
||||||
|
|
@ -130,6 +131,7 @@ export function ChatPage() {
|
||||||
}}
|
}}
|
||||||
onSwitchSession={switchSession}
|
onSwitchSession={switchSession}
|
||||||
onDeleteSession={handleDeleteSession}
|
onDeleteSession={handleDeleteSession}
|
||||||
|
onRenameSession={handleRenameSession}
|
||||||
/>
|
/>
|
||||||
</PageHeader>
|
</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 dayjs from "dayjs"
|
||||||
import type { RefObject } from "react"
|
import { type KeyboardEvent, type RefObject, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { SessionSummary } from "@/api/sessions"
|
import type { SessionSummary } from "@/api/sessions"
|
||||||
|
|
@ -23,6 +23,7 @@ interface SessionHistoryMenuProps {
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSwitchSession: (sessionId: string) => void
|
onSwitchSession: (sessionId: string) => void
|
||||||
onDeleteSession: (sessionId: string) => void
|
onDeleteSession: (sessionId: string) => void
|
||||||
|
onRenameSession: (sessionId: string, title: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionHistoryMenu({
|
export function SessionHistoryMenu({
|
||||||
|
|
@ -35,8 +36,41 @@ export function SessionHistoryMenu({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSwitchSession,
|
onSwitchSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
|
onRenameSession,
|
||||||
}: SessionHistoryMenuProps) {
|
}: SessionHistoryMenuProps) {
|
||||||
const { t } = useTranslation()
|
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 (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
|
|
@ -46,7 +80,20 @@ export function SessionHistoryMenu({
|
||||||
<span className="hidden sm:inline">{t("chat.history")}</span>
|
<span className="hidden sm:inline">{t("chat.history")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</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]">
|
<ScrollArea className="max-h-[300px]">
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem disabled>
|
||||||
|
|
@ -65,20 +112,48 @@ export function SessionHistoryMenu({
|
||||||
sessions.map((session) => (
|
sessions.map((session) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={session.id}
|
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" : ""
|
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">
|
<span className="line-clamp-1 text-sm font-medium">
|
||||||
{session.title || session.preview}
|
{session.title || session.preview}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{t("chat.messagesCount", {
|
{t("chat.messagesCount", {
|
||||||
count: session.message_count,
|
count: session.message_count,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
· {dayjs(session.updated).fromNow()}
|
· {dayjs(session.updated).fromNow()}
|
||||||
</span>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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
|
const LIMIT = 20
|
||||||
|
|
||||||
|
|
@ -100,6 +105,17 @@ export function useSessionHistory({
|
||||||
[activeSessionId, onDeletedActiveSession],
|
[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 {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
|
@ -108,5 +124,6 @@ export function useSessionHistory({
|
||||||
observerRef,
|
observerRef,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
|
handleRenameSession,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"historyOpenFailed": "Failed to open this chat history",
|
"historyOpenFailed": "Failed to open this chat history",
|
||||||
"loadingMore": "Loading more...",
|
"loadingMore": "Loading more...",
|
||||||
"deleteSession": "Delete session",
|
"deleteSession": "Delete session",
|
||||||
|
"renameSession": "Rename session",
|
||||||
"messagesCount": "{{count}} messages",
|
"messagesCount": "{{count}} messages",
|
||||||
"noModel": "Select model",
|
"noModel": "Select model",
|
||||||
"empty": {
|
"empty": {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"historyOpenFailed": "打开该历史会话失败",
|
"historyOpenFailed": "打开该历史会话失败",
|
||||||
"loadingMore": "加载更多...",
|
"loadingMore": "加载更多...",
|
||||||
"deleteSession": "删除会话",
|
"deleteSession": "删除会话",
|
||||||
|
"renameSession": "重命名会话",
|
||||||
"messagesCount": "{{count}} 条消息",
|
"messagesCount": "{{count}} 条消息",
|
||||||
"noModel": "选择模型",
|
"noModel": "选择模型",
|
||||||
"empty": {
|
"empty": {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue