mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
1 Commits
fix/zoom-r
...
fix/mcp-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f09191d2 |
9
.github/CONTRIBUTING.md
vendored
9
.github/CONTRIBUTING.md
vendored
@@ -61,15 +61,6 @@ For **significant changes** (new features, architecture changes, large refactors
|
||||
|
||||
CI will run the full test suite on your PR.
|
||||
|
||||
## Using AI Tools
|
||||
|
||||
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
|
||||
|
||||
1. **Review the code** — understand what was generated, don't just commit blindly
|
||||
2. **Write a PR description** — explain what changed and why
|
||||
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
|
||||
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
|
||||
|
||||
## Code Review
|
||||
|
||||
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -75,4 +75,3 @@ ai-models.json
|
||||
|
||||
# local backups
|
||||
*.bak
|
||||
.gstack/
|
||||
|
||||
@@ -11,16 +11,10 @@ import {
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { isIndexedDBUsable } from "@/lib/session-storage"
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
} = useDiagram()
|
||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
||||
useDiagram()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||
@@ -32,8 +26,6 @@ export default function Home() {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [isElectron, setIsElectron] = useState(false)
|
||||
const [canPersist, setCanPersist] = useState(false)
|
||||
const [canPersistChecked, setCanPersistChecked] = useState(false)
|
||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||
)
|
||||
@@ -84,11 +76,6 @@ export default function Home() {
|
||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const usable = await isIndexedDBUsable()
|
||||
setCanPersist(usable)
|
||||
setCanPersistChecked(true)
|
||||
})()
|
||||
setIsLoaded(true)
|
||||
}, [pathname, router])
|
||||
|
||||
@@ -97,17 +84,6 @@ export default function Home() {
|
||||
onDrawioLoad()
|
||||
}, [onDrawioLoad])
|
||||
|
||||
const handleDrawioAutoSave = useCallback(
|
||||
(data: { xml?: string }) => {
|
||||
handleDiagramAutoSave(data)
|
||||
// Only suppress modified state when persistence is available
|
||||
if (canPersist) {
|
||||
drawioRef.current?.status({ message: "", modified: false })
|
||||
}
|
||||
},
|
||||
[canPersist, drawioRef, handleDiagramAutoSave],
|
||||
)
|
||||
|
||||
const handleDarkModeChange = () => {
|
||||
const newValue = !darkMode
|
||||
setDarkMode(newValue)
|
||||
@@ -191,32 +167,20 @@ export default function Home() {
|
||||
}`}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||
{isLoaded && canPersistChecked && (
|
||||
{isLoaded && (
|
||||
<div
|
||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||
>
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||
ref={drawioRef}
|
||||
autosave
|
||||
onAutoSave={handleDrawioAutoSave}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
baseUrl={drawioBaseUrl}
|
||||
configuration={
|
||||
canPersist
|
||||
? { confirmExit: false }
|
||||
: undefined
|
||||
}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: false,
|
||||
libraries: false,
|
||||
// Disable modified tracking only when persistence is available
|
||||
...(canPersist && {
|
||||
modified: false,
|
||||
keepmodified: false,
|
||||
}),
|
||||
saveAndExit: false,
|
||||
noSaveBtn: true,
|
||||
noExitBtn: true,
|
||||
|
||||
@@ -436,18 +436,7 @@ ${userInputText}
|
||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
||||
// Some providers (e.g. MiniMax) don't support multiple system messages
|
||||
// Merge them into a single system message for compatibility
|
||||
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
||||
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
|
||||
const isCustomOpenAIEndpoint =
|
||||
resolvedProvider === "openai" &&
|
||||
!!(
|
||||
baseUrl ||
|
||||
process.env.OPENAI_BASE_URL ||
|
||||
(serverModelConfig.baseUrlEnv &&
|
||||
process.env[serverModelConfig.baseUrlEnv])
|
||||
)
|
||||
const isSingleSystemProvider =
|
||||
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
||||
const isSingleSystemProvider = SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider)
|
||||
|
||||
const xmlContext = `${
|
||||
previousXml
|
||||
|
||||
@@ -345,12 +345,11 @@ export async function POST(req: Request) {
|
||||
break
|
||||
}
|
||||
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
||||
// GLM, Qwen, Kimi, Qiniu - OpenAI compatible
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita": {
|
||||
case "qiniu": {
|
||||
const baseURL =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BookmarkPlus,
|
||||
Download,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
} from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
@@ -238,7 +236,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
@@ -442,7 +439,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
return (
|
||||
<form
|
||||
id="chat-form"
|
||||
onSubmit={onSubmit}
|
||||
className={`w-full transition-all duration-200 ${
|
||||
isDragging
|
||||
@@ -542,18 +538,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
disabled={isDisabled || !input.trim()}
|
||||
tooltipContent={dict.templates.saveAsTemplate}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -626,12 +610,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
isExtracting={isExtractingUrl}
|
||||
/>
|
||||
)}
|
||||
<TemplateCreateDialog
|
||||
open={showSaveAsTemplate}
|
||||
onOpenChange={setShowSaveAsTemplate}
|
||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||
initialPrompt={input.trim()}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
import {
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning"
|
||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||
@@ -161,10 +159,6 @@ interface ChatMessageDisplayProps {
|
||||
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
||||
validationStates?: Record<string, ValidationState>
|
||||
onImproveWithSuggestions?: (feedback: string) => void
|
||||
onSendTemplate?: (
|
||||
template: import("@/lib/template-storage").Template,
|
||||
) => void
|
||||
currentInput?: string
|
||||
}
|
||||
|
||||
export function ChatMessageDisplay({
|
||||
@@ -184,8 +178,6 @@ export function ChatMessageDisplay({
|
||||
loadedMessageIdsRef,
|
||||
validationStates = {},
|
||||
onImproveWithSuggestions,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
}: ChatMessageDisplayProps) {
|
||||
const dict = useDictionary()
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
@@ -245,10 +237,6 @@ export function ChatMessageDisplay({
|
||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
// Track "Save as Template" dialog
|
||||
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
|
||||
const setCopyState = (
|
||||
messageId: string,
|
||||
@@ -662,8 +650,6 @@ export function ChatMessageDisplay({
|
||||
onDeleteSession={onDeleteSession}
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
dict={dict}
|
||||
/>
|
||||
) : messages.length === 0 ? null : (
|
||||
@@ -761,42 +747,8 @@ export function ChatMessageDisplay({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Save as Template button - only for user messages */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSaveAsTemplateMessageId(
|
||||
message.id,
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||
title={
|
||||
dict.templates
|
||||
?.saveAsTemplate ||
|
||||
"Save as Template"
|
||||
}
|
||||
>
|
||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save as Template Dialog */}
|
||||
{saveAsTemplateMessageId === message.id && (
|
||||
<TemplateCreateDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
setSaveAsTemplateMessageId(null)
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSaveAsTemplateMessageId(null)
|
||||
}}
|
||||
initialPrompt={getUserOriginalText(
|
||||
message,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
{/* Reasoning blocks - displayed first for assistant messages */}
|
||||
{message.role === "assistant" &&
|
||||
|
||||
@@ -138,22 +138,26 @@ export default function ChatPanel({
|
||||
const onFetchChart = (saveToHistory = true) => {
|
||||
return Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
resolverRef.current = resolve
|
||||
if (resolverRef && "current" in resolverRef) {
|
||||
resolverRef.current = resolve
|
||||
}
|
||||
if (saveToHistory) {
|
||||
onExport()
|
||||
} else {
|
||||
handleExportWithoutHistory()
|
||||
}
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
const currentResolver = resolverRef.current
|
||||
setTimeout(() => {
|
||||
if (resolverRef.current === currentResolver) {
|
||||
resolverRef.current = null
|
||||
}
|
||||
reject(new Error("Chart export timed out after 10 seconds"))
|
||||
}, 10000)
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
"Chart export timed out after 10 seconds",
|
||||
),
|
||||
),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -601,7 +605,7 @@ export default function ChatPanel({
|
||||
|
||||
try {
|
||||
const currentSession = sessionManager.currentSession
|
||||
if (currentSession) {
|
||||
if (currentSession && currentSession.messages.length > 0) {
|
||||
// Restore from session manager (IndexedDB)
|
||||
justLoadedSessionRef.current = true
|
||||
syncUIWithSession(currentSession)
|
||||
@@ -638,7 +642,7 @@ export default function ChatPanel({
|
||||
lastSyncedSessionIdRef.current = newSessionId
|
||||
|
||||
// Sync UI with new session
|
||||
if (newSession) {
|
||||
if (newSession && newSession.messages.length > 0) {
|
||||
justLoadedSessionRef.current = true
|
||||
syncUIWithSession(newSession)
|
||||
} else if (!newSession) {
|
||||
@@ -693,7 +697,7 @@ export default function ChatPanel({
|
||||
// Debounce: save after 1 second of no changes
|
||||
localStorageDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
if (messages.length > 0 || hasDiagramNow) {
|
||||
if (messages.length > 0) {
|
||||
const sessionData = await buildSessionData({
|
||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||
@@ -715,7 +719,6 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
chartXML,
|
||||
messages,
|
||||
status,
|
||||
sessionIsAvailable,
|
||||
@@ -746,8 +749,7 @@ export default function ChatPanel({
|
||||
const handleVisibilityChange = async () => {
|
||||
if (
|
||||
document.visibilityState === "hidden" &&
|
||||
(messagesRef.current.length > 0 ||
|
||||
isRealDiagram(chartXMLRef.current))
|
||||
messagesRef.current.length > 0
|
||||
) {
|
||||
try {
|
||||
// Attempt to save session - browser may not wait for completion
|
||||
@@ -973,25 +975,6 @@ export default function ChatPanel({
|
||||
pathname,
|
||||
])
|
||||
|
||||
// Handle sending a template directly (called from TemplatePanel)
|
||||
const handleSendTemplate = useCallback(
|
||||
async (template: { prompt: string }) => {
|
||||
flushSync(() => {
|
||||
setInput(template.prompt)
|
||||
setFiles([])
|
||||
setUrlData(new Map())
|
||||
})
|
||||
|
||||
const formElement = document.getElementById(
|
||||
"chat-form",
|
||||
) as HTMLFormElement | null
|
||||
if (formElement) {
|
||||
formElement.requestSubmit()
|
||||
}
|
||||
},
|
||||
[setInput, setFiles, setUrlData],
|
||||
)
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
@@ -1395,8 +1378,6 @@ export default function ChatPanel({
|
||||
loadedMessageIdsRef={loadedMessageIdsRef}
|
||||
validationStates={validationStates}
|
||||
onImproveWithSuggestions={handleImproveWithSuggestions}
|
||||
onSendTemplate={handleSendTemplate}
|
||||
currentInput={input}
|
||||
/>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import { useState } from "react"
|
||||
import ExamplePanel from "@/components/chat-example-panel"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import {
|
||||
@@ -22,8 +21,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { Template } from "@/lib/template-storage"
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string
|
||||
@@ -38,8 +35,6 @@ interface ChatLobbyProps {
|
||||
onDeleteSession?: (id: string) => void
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
dict: {
|
||||
sessionHistory?: {
|
||||
recentChats?: string
|
||||
@@ -49,10 +44,6 @@ interface ChatLobbyProps {
|
||||
deleteTitle?: string
|
||||
deleteDescription?: string
|
||||
}
|
||||
templates?: {
|
||||
title?: string
|
||||
myTemplates?: string
|
||||
}
|
||||
examples?: {
|
||||
quickExamples?: string
|
||||
}
|
||||
@@ -84,233 +75,164 @@ function formatSessionDate(
|
||||
})
|
||||
}
|
||||
|
||||
function getPanelVisibility() {
|
||||
if (typeof window === "undefined")
|
||||
return { recentChats: true, myTemplates: true, quickExamples: true }
|
||||
return {
|
||||
recentChats:
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
myTemplates:
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
quickExamples:
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatLobby({
|
||||
sessions,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
setInput,
|
||||
setFiles,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
dict,
|
||||
}: ChatLobbyProps) {
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(false)
|
||||
// Track whether examples section is expanded (collapsed by default when there's history)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||
// Delete confirmation dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||
// Search filter for history
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Listen for panel visibility changes from settings
|
||||
useEffect(() => {
|
||||
const handler = () => setPanelVisibility(getPanelVisibility())
|
||||
window.addEventListener("panelVisibilityChange", handler)
|
||||
return () =>
|
||||
window.removeEventListener("panelVisibilityChange", handler)
|
||||
}, [])
|
||||
|
||||
const hasHistory = sessions.length > 0
|
||||
|
||||
if (!hasHistory) {
|
||||
if (panelVisibility.myTemplates) {
|
||||
return (
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (panelVisibility.quickExamples) {
|
||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
}
|
||||
return null
|
||||
// Show full examples when no history
|
||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
}
|
||||
|
||||
// Show history + collapsible examples when there are sessions
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
{/* Recent Chats Section */}
|
||||
{panelVisibility.recentChats && (
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||
</p>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
dict.sessionHistory?.searchPlaceholder ||
|
||||
"Search chats..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions
|
||||
.filter((session) =>
|
||||
session.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map((session) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
onSelectSession(session.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.thumbnailDataUrl ? (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||
<Image
|
||||
src={session.thumbnailDataUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSessionDate(
|
||||
session.updatedAt,
|
||||
dict.sessionHistory,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSessionToDelete(session.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter((s) =>
|
||||
s.title
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||
</p>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
dict.sessionHistory?.searchPlaceholder ||
|
||||
"Search chats..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions
|
||||
.filter((session) =>
|
||||
session.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
).length === 0 &&
|
||||
searchQuery && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{dict.sessionHistory?.noResults ||
|
||||
"No chats found"}
|
||||
</p>
|
||||
)}
|
||||
)
|
||||
.map((session) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectSession(session.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.thumbnailDataUrl ? (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||
<Image
|
||||
src={session.thumbnailDataUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSessionDate(
|
||||
session.updatedAt,
|
||||
dict.sessionHistory,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSessionToDelete(session.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter((s) =>
|
||||
s.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
).length === 0 &&
|
||||
searchQuery && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{dict.sessionHistory?.noResults ||
|
||||
"No chats found"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Examples Section */}
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.examples?.quickExamples || "Quick Examples"}
|
||||
</span>
|
||||
{examplesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{examplesExpanded && (
|
||||
<div className="mt-2">
|
||||
<ExamplePanel
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible My Templates Section */}
|
||||
{panelVisibility.myTemplates && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.templates?.myTemplates || "My Templates"}
|
||||
</span>
|
||||
{templatesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{templatesExpanded && (
|
||||
<div className="mt-2">
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Quick Examples Section */}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.examples?.quickExamples || "Quick Examples"}
|
||||
</span>
|
||||
{examplesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{examplesExpanded && (
|
||||
<div className="mt-2">
|
||||
<ExamplePanel
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Plus } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
createTemplate,
|
||||
type TemplateCreateInput,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
interface TemplateCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function TemplateCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
initialPrompt = "",
|
||||
}: TemplateCreateDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset form when dialog opens with the latest initialPrompt
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setPrompt(initialPrompt)
|
||||
setPinned(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, initialPrompt])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const input: TemplateCreateInput = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const template = await createTemplate(input)
|
||||
if (template) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.createFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create template:", err)
|
||||
setError(dict.templates.createFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
{dict.templates.createTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.createDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt" className="text-foreground">
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-foreground">
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.templates.createButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Edit2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||
|
||||
interface TemplateEditDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
template: Template | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function TemplateEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateEditDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form when template changes
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!template) return
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || template.title,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const updated = await updateTemplate(template.id, updates)
|
||||
if (updated) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.updateFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update template:", err)
|
||||
setError(dict.templates.updateFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="w-5 h-5" />
|
||||
{dict.templates.editTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.editDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-prompt"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-title"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="edit-pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit-pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Bookmark,
|
||||
Copy,
|
||||
Download,
|
||||
Edit2,
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
exportTemplates,
|
||||
getAllTemplates,
|
||||
importTemplates,
|
||||
incrementClickCount,
|
||||
incrementRunCount,
|
||||
searchTemplates,
|
||||
type Template,
|
||||
updateTemplate,
|
||||
validateImportData,
|
||||
} from "@/lib/template-storage"
|
||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||
|
||||
interface TemplatePanelProps {
|
||||
setInput: (input: string) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
}
|
||||
|
||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||
if (!timestamp) return neverUsedText
|
||||
const now = Date.now()
|
||||
const diffMs = now - timestamp
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||
if (diffMins < 1) return rtf.format(0, "minute")
|
||||
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||
} catch {
|
||||
// Fallback if Intl.RelativeTimeFormat is not available
|
||||
if (diffMins < 1) return "<1m ago"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export function TemplatePanel({
|
||||
setInput,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
}: TemplatePanelProps) {
|
||||
const dict = useDictionary()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
|
||||
null,
|
||||
)
|
||||
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
|
||||
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [importMessage, setImportMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
const result = await getAllTemplates()
|
||||
setTemplates(result)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Filter templates by search query
|
||||
const filteredTemplates = searchQuery.trim()
|
||||
? searchTemplates(templates, searchQuery)
|
||||
: templates
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
}, [loadTemplates])
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
const handleEdit = (template: Template) => {
|
||||
setTemplateToEdit(template)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDuplicate = async (template: Template) => {
|
||||
const duplicated = await duplicateTemplate(
|
||||
template.id,
|
||||
dict.templates.copySuffix || "(copy)",
|
||||
)
|
||||
if (duplicated) {
|
||||
loadTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteClick = (template: Template) => {
|
||||
setTemplateToDelete(template)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!templateToDelete) return
|
||||
const success = await deleteTemplate(templateToDelete.id)
|
||||
if (success) {
|
||||
loadTemplates()
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setTemplateToDelete(null)
|
||||
}
|
||||
|
||||
const handleTogglePin = async (template: Template) => {
|
||||
const updated = await updateTemplate(template.id, {
|
||||
pinned: !template.pinned,
|
||||
})
|
||||
if (updated) {
|
||||
loadTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template card click - send directly or show confirmation
|
||||
const handleTemplateClick = async (template: Template) => {
|
||||
// If there's unsent content in the input, show confirmation dialog
|
||||
if (currentInput.trim()) {
|
||||
setTemplateToSend(template)
|
||||
setConfirmSendDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// No unsent content, send directly
|
||||
await sendTemplate(template)
|
||||
}
|
||||
|
||||
// Actually send the template
|
||||
const sendTemplate = async (template: Template) => {
|
||||
// Increment click count only when actually sending
|
||||
await incrementClickCount(template.id)
|
||||
if (onSendTemplate) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
// Call the send callback
|
||||
onSendTemplate(template)
|
||||
} else {
|
||||
// Fallback: just fill the input if no send callback provided
|
||||
setInput(template.prompt)
|
||||
}
|
||||
setConfirmSendDialogOpen(false)
|
||||
setTemplateToSend(null)
|
||||
}
|
||||
|
||||
// Handle confirmation dialog - user confirmed to send template
|
||||
const handleConfirmSend = async () => {
|
||||
if (!templateToSend) return
|
||||
await sendTemplate(templateToSend)
|
||||
}
|
||||
|
||||
// Handle cancel - close dialog without sending
|
||||
const handleCancelSend = () => {
|
||||
setConfirmSendDialogOpen(false)
|
||||
setTemplateToSend(null)
|
||||
}
|
||||
|
||||
// Export templates to JSON file
|
||||
const handleExport = () => {
|
||||
if (templates.length === 0) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.exportEmpty || "No templates to export",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const exportData = exportTemplates(templates)
|
||||
const json = JSON.stringify(exportData, null, 2)
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
setImportMessage({
|
||||
type: "success",
|
||||
text: dict.templates.exportSuccess.replace(
|
||||
"{count}",
|
||||
String(templates.length),
|
||||
),
|
||||
})
|
||||
setTimeout(() => setImportMessage(null), 3000)
|
||||
} catch (error) {
|
||||
console.error("Failed to export templates:", error)
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Import templates from JSON file
|
||||
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text:
|
||||
dict.templates.importNoFile || "Please select a JSON file",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
|
||||
// Validate import data
|
||||
const validation = validateImportData(data)
|
||||
if (!validation.valid) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.importFailed.replace(
|
||||
"{error}",
|
||||
validation.error || "Invalid data",
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Import templates with dedup-append strategy
|
||||
const result = await importTemplates(data.templates, templates)
|
||||
|
||||
// Reload template list
|
||||
await loadTemplates()
|
||||
|
||||
setImportMessage({
|
||||
type: "success",
|
||||
text: dict.templates.importSuccess
|
||||
.replace("{imported}", String(result.imported))
|
||||
.replace("{skipped}", String(result.skipped)),
|
||||
})
|
||||
setTimeout(() => setImportMessage(null), 5000)
|
||||
} catch (error) {
|
||||
console.error("Failed to import templates:", error)
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.importFailed.replace(
|
||||
"{error}",
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state: no templates at all
|
||||
if (!loading && templates.length === 0) {
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.templates.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.templates.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FileText className="w-8 h-8 text-primary/60" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground mb-1">
|
||||
{dict.templates.emptyTitle}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
|
||||
{dict.templates.emptyDescription}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{dict.templates.createFirst}
|
||||
</button>
|
||||
|
||||
<TemplateCreateDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Template list
|
||||
return (
|
||||
<div className="py-2 px-2 animate-fade-in">
|
||||
<div className="space-y-3">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={dict.templates.searchPlaceholder}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{dict.templates.createButton}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={templates.length === 0}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={dict.templates.exportTemplates}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{dict.templates.exportTemplates}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={dict.templates.importTemplates}
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Import message */}
|
||||
{importMessage && (
|
||||
<div
|
||||
className={`text-xs px-3 py-2 rounded-lg ${
|
||||
importMessage.type === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{importMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading
|
||||
? // Loading skeleton
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={`skeleton-${String(i)}`}
|
||||
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-2/3" />
|
||||
<div className="h-3 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: filteredTemplates.length === 0
|
||||
? // Search empty state
|
||||
!loading && (
|
||||
<div className="flex flex-col items-center justify-center py-6 px-4">
|
||||
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{dict.templates.searchNoResults}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: filteredTemplates.map((template) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
|
||||
<div
|
||||
key={template.id}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() =>
|
||||
handleTemplateClick(template)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
handleTemplateClick(template)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{template.title}
|
||||
</div>
|
||||
{template.pinned && (
|
||||
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{template.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{template.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions and stats */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||
{template.runCount > 0
|
||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||
: dict.templates.neverUsed}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTogglePin(template)
|
||||
}}
|
||||
className={`p-1.5 rounded-lg transition-all ${
|
||||
template.pinned
|
||||
? "text-primary hover:text-primary/80 hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
title={
|
||||
template.pinned
|
||||
? dict.templates
|
||||
.unpin || "Unpin"
|
||||
: dict.templates.pin ||
|
||||
"Pin"
|
||||
}
|
||||
>
|
||||
<Bookmark
|
||||
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||
title={dict.common.edit}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDuplicate(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||
title={
|
||||
dict.templates.duplicate ||
|
||||
"Duplicate"
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteClick(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplateCreateDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
<TemplateEditDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
template={templateToEdit}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.deleteTitle ||
|
||||
"Delete this template?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.deleteDescription ||
|
||||
"This will permanently delete this template. This action cannot be undone."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||
>
|
||||
{dict.common.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Confirm Send Dialog - when there's unsent input */}
|
||||
<AlertDialog
|
||||
open={confirmSendDialogOpen}
|
||||
onOpenChange={setConfirmSendDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.confirmSendTitle ||
|
||||
"Replace current input?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.confirmSendDescription ||
|
||||
"You have unsent content in the input. Sending this template will replace it."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelSend}>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSend}>
|
||||
{dict.templates.confirmSendButton ||
|
||||
"Send Template"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -513,7 +513,7 @@ export function ModelConfigDialog({
|
||||
</div>
|
||||
|
||||
{/* Provider Details (Right Panel) */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-auto scrollbar-thin">
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
||||
{selectedProvider ? (
|
||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
||||
<div className="p-6 space-y-8">
|
||||
|
||||
@@ -199,7 +199,7 @@ export function ModelSelector({
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 &&
|
||||
models.length > 0
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||
export type ExportFormat = "drawio" | "png" | "svg"
|
||||
|
||||
interface SaveDialogProps {
|
||||
open: boolean
|
||||
@@ -74,11 +74,6 @@ export function SaveDialog({
|
||||
label: dict.save.formats.svg,
|
||||
extension: ".svg",
|
||||
},
|
||||
{
|
||||
value: "xmlsvg" as const,
|
||||
label: dict.save.formats.xmlsvg,
|
||||
extension: ".drawio.svg",
|
||||
},
|
||||
]
|
||||
|
||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -114,20 +114,6 @@ function SettingsContent({
|
||||
const [currentLang, setCurrentLang] = useState("en")
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
|
||||
// Panel visibility state
|
||||
const [showRecentChats, setShowRecentChats] = useState(true)
|
||||
const [showMyTemplates, setShowMyTemplates] = useState(true)
|
||||
const [showQuickExamples, setShowQuickExamples] = useState(true)
|
||||
|
||||
const handlePanelToggle = useCallback(
|
||||
(key: string, value: boolean, setter: (v: boolean) => void) => {
|
||||
setter(value)
|
||||
localStorage.setItem(key, String(value))
|
||||
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Proxy settings state (Electron only)
|
||||
const [httpProxy, setHttpProxy] = useState("")
|
||||
const [httpsProxy, setHttpsProxy] = useState("")
|
||||
@@ -178,17 +164,6 @@ function SettingsContent({
|
||||
)
|
||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
||||
|
||||
setShowRecentChats(
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
)
|
||||
setShowMyTemplates(
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
)
|
||||
setShowQuickExamples(
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
|
||||
"false",
|
||||
)
|
||||
|
||||
setError("")
|
||||
|
||||
// Load proxy settings (Electron only)
|
||||
@@ -304,7 +279,7 @@ function SettingsContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
@@ -314,7 +289,7 @@ function SettingsContent({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||
<div className="px-6 pb-6">
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{/* API Keys & Models */}
|
||||
{onOpenModelConfig && (
|
||||
@@ -468,63 +443,6 @@ function SettingsContent({
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Panel Visibility */}
|
||||
<SettingItem
|
||||
label={dict.settings.panelVisibility}
|
||||
description={dict.settings.panelVisibilityDescription}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-recent-chats"
|
||||
checked={showRecentChats}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showRecentChats,
|
||||
v,
|
||||
setShowRecentChats,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showRecentChats}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-my-templates"
|
||||
checked={showMyTemplates}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showMyTemplates,
|
||||
v,
|
||||
setShowMyTemplates,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showMyTemplates}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-quick-examples"
|
||||
checked={showQuickExamples}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showQuickExamples,
|
||||
v,
|
||||
setShowQuickExamples,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showQuickExamples}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* VLM Diagram Validation */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramValidation}
|
||||
|
||||
@@ -20,10 +20,9 @@ interface DiagramContextType {
|
||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
resolverRef: React.Ref<((value: string) => void) | null>
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
filename: string,
|
||||
@@ -57,6 +56,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
|
||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
||||
// Track latest chartXML for restoration after remount
|
||||
const chartXMLRef = useRef<string>("")
|
||||
|
||||
@@ -65,10 +66,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
if (hasCalledOnLoadRef.current) return
|
||||
hasCalledOnLoadRef.current = true
|
||||
setIsDrawioReady(true)
|
||||
// Restore diagram after remount (e.g., theme/UI change)
|
||||
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
drawioRef.current.load({ xml: chartXMLRef.current })
|
||||
}
|
||||
}
|
||||
|
||||
const resetDrawioReady = () => {
|
||||
@@ -81,6 +78,24 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
|
||||
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
||||
useEffect(() => {
|
||||
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
|
||||
if (!isDrawioReady) {
|
||||
hasDiagramRestoredRef.current = false
|
||||
return
|
||||
}
|
||||
// Only restore once per ready cycle
|
||||
if (hasDiagramRestoredRef.current) return
|
||||
hasDiagramRestoredRef.current = true
|
||||
|
||||
// Restore diagram from ref if we have one
|
||||
const xmlToRestore = chartXMLRef.current
|
||||
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
||||
drawioRef.current.load({ xml: xmlToRestore })
|
||||
}
|
||||
}, [isDrawioReady])
|
||||
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string) => void) | null
|
||||
@@ -219,8 +234,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||
if (format === "png" || format === "svg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -253,16 +267,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||
if (!data?.xml) return
|
||||
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||
return
|
||||
}
|
||||
setChartXML(data.xml)
|
||||
}
|
||||
|
||||
const clearDiagram = () => {
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
@@ -283,8 +287,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Map format to draw.io export format
|
||||
const drawioFormat =
|
||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
@@ -308,13 +311,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
// SVG format
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
@@ -393,7 +391,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
resolverRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
getThumbnailSvg,
|
||||
|
||||
@@ -94,8 +94,7 @@ if (!gotTheLock) {
|
||||
if (
|
||||
url.includes("diagrams.net") ||
|
||||
url.includes("draw.io") ||
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("http://127.0.0.1")
|
||||
url.startsWith("http://localhost")
|
||||
) {
|
||||
return { action: "allow" }
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
|
||||
const env: Record<string, string> = {
|
||||
NODE_ENV: "production",
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
HOSTNAME: "localhost",
|
||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
}
|
||||
|
||||
@@ -9,11 +9,9 @@ import { app } from "electron"
|
||||
const PORT_CONFIG = {
|
||||
// Development mode uses fixed port for hot reload compatibility
|
||||
development: 6002,
|
||||
// Legacy production port — tried first to preserve localStorage for existing users
|
||||
legacyProduction: 61337,
|
||||
// New production port below the ephemeral range (49152-65535)
|
||||
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
|
||||
production: 13370,
|
||||
// Production mode uses fixed port (61337) to preserve localStorage
|
||||
// Falls back to sequential ports if unavailable
|
||||
production: 61337,
|
||||
// Maximum attempts to find an available port (fallback)
|
||||
maxAttempts: 100,
|
||||
}
|
||||
@@ -29,10 +27,7 @@ let allocatedPort: number | null = null
|
||||
export function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(`Port ${port} unavailable: ${err.code}`)
|
||||
resolve(false)
|
||||
})
|
||||
server.once("error", () => resolve(false))
|
||||
server.once("listening", () => {
|
||||
server.close()
|
||||
resolve(true)
|
||||
@@ -44,12 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses fixed port (13370) to preserve localStorage
|
||||
* - In production: uses fixed port (61337) to preserve localStorage
|
||||
* - Falls back to sequential ports if preferred port is unavailable
|
||||
* - Last resort: lets the OS assign a port (port 0)
|
||||
*
|
||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||
* @returns Promise<number> The available port
|
||||
* @throws Error if no available port found after max attempts
|
||||
*/
|
||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
const isDev = !app.isPackaged
|
||||
@@ -69,16 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// In production, try legacy port first to preserve existing users' localStorage
|
||||
if (!isDev) {
|
||||
const legacyPort = PORT_CONFIG.legacyProduction
|
||||
if (await isPortAvailable(legacyPort)) {
|
||||
allocatedPort = legacyPort
|
||||
return legacyPort
|
||||
}
|
||||
}
|
||||
|
||||
// Try preferred port
|
||||
// Try preferred port first
|
||||
if (await isPortAvailable(preferredPort)) {
|
||||
allocatedPort = preferredPort
|
||||
return preferredPort
|
||||
@@ -98,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: let the OS pick an available port
|
||||
console.warn(
|
||||
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
|
||||
throw new Error(
|
||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||
)
|
||||
const osPort = await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", reject)
|
||||
server.once("listening", () => {
|
||||
const addr = server.address()
|
||||
const port = (addr as net.AddressInfo).port
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
server.listen(0, "127.0.0.1")
|
||||
})
|
||||
allocatedPort = osPort
|
||||
console.log(`OS assigned port: ${osPort}`)
|
||||
return osPort
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,5 +113,5 @@ export function getServerUrl(): string {
|
||||
"No port allocated yet. Call findAvailablePort() first.",
|
||||
)
|
||||
}
|
||||
return `http://127.0.0.1:${allocatedPort}`
|
||||
return `http://localhost:${allocatedPort}`
|
||||
}
|
||||
|
||||
@@ -66,11 +66,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
|
||||
// Handle page title updates
|
||||
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
||||
if (
|
||||
title &&
|
||||
!title.includes("localhost") &&
|
||||
!title.includes("127.0.0.1")
|
||||
) {
|
||||
if (title && !title.includes("localhost")) {
|
||||
mainWindow?.setTitle(title)
|
||||
} else {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
@@ -167,8 +167,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Get your API key from: https://www.qiniu.com/ai/models
|
||||
# QINIU_API_KEY=your_qiniu_api_key
|
||||
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
|
||||
|
||||
# Novita AI Configuration (Optional)
|
||||
# Get your API key from: https://novita.ai/dashboard/key
|
||||
# NOVITA_API_KEY=your_novita_api_key
|
||||
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
||||
|
||||
@@ -28,7 +28,6 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
||||
"qwen",
|
||||
"kimi",
|
||||
"qiniu",
|
||||
"novita",
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -99,7 +98,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"qiniu",
|
||||
"kimi",
|
||||
"minimax",
|
||||
"novita",
|
||||
]
|
||||
|
||||
// Bedrock provider options for Anthropic beta features
|
||||
@@ -522,8 +520,7 @@ function buildProviderOptions(
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita": {
|
||||
case "qiniu": {
|
||||
// These providers don't have reasoning configs in AI SDK yet
|
||||
// Gateway passes through to underlying providers which handle their own configs
|
||||
break
|
||||
@@ -558,7 +555,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
qiniu: "QINIU_API_KEY",
|
||||
kimi: "KIMI_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
novita: "NOVITA_API_KEY",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1262,8 +1258,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "qiniu":
|
||||
case "kimi":
|
||||
case "novita": {
|
||||
case "kimi": {
|
||||
const envVar = PROVIDER_ENV_VARS[provider]
|
||||
if (!envVar) {
|
||||
throw new Error(
|
||||
@@ -1290,7 +1285,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -129,12 +129,7 @@
|
||||
"disabled": "Disabled",
|
||||
"customSystemMessage": "Custom System Message",
|
||||
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
||||
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
||||
"panelVisibility": "Lobby Panels",
|
||||
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
|
||||
"showRecentChats": "Recent Chats",
|
||||
"showMyTemplates": "My Templates",
|
||||
"showQuickExamples": "Quick Examples"
|
||||
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams..."
|
||||
},
|
||||
"save": {
|
||||
"title": "Save Diagram",
|
||||
@@ -145,8 +140,7 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG Image",
|
||||
"svg": "SVG Image",
|
||||
"xmlsvg": "Editable SVG"
|
||||
"svg": "SVG Image"
|
||||
},
|
||||
"savedSuccessfully": "Saved successfully!"
|
||||
},
|
||||
@@ -271,52 +265,6 @@
|
||||
"searchPlaceholder": "Search chats...",
|
||||
"noResults": "No chats found"
|
||||
},
|
||||
"templates": {
|
||||
"title": "My Templates",
|
||||
"subtitle": "Your personal prompt library for quick diagram creation",
|
||||
"emptyTitle": "No templates yet",
|
||||
"emptyDescription": "Create your first template to start building your personal prompt library",
|
||||
"createFirst": "Create First Template",
|
||||
"neverUsed": "Not used yet",
|
||||
"usedCount": "{count} uses",
|
||||
"myTemplates": "My Templates",
|
||||
"createTitle": "Create Template",
|
||||
"createDescription": "Save a prompt for repeated use. Templates help you quickly start common workflows.",
|
||||
"promptLabel": "Prompt",
|
||||
"promptPlaceholder": "Describe the diagram you want to create...",
|
||||
"promptRequired": "Prompt is required",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter a title",
|
||||
"titleHint": "Leave empty to use the first 20 characters of prompt",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Add a description for this template",
|
||||
"pinnedLabel": "Pin Template",
|
||||
"pinnedHint": "Pinned templates appear at the top of the list",
|
||||
"createButton": "Create Template",
|
||||
"createFailed": "Failed to create template. Please try again.",
|
||||
"editTitle": "Edit Template",
|
||||
"editDescription": "Update your template content and settings.",
|
||||
"updateFailed": "Failed to update template. Please try again.",
|
||||
"duplicate": "Duplicate",
|
||||
"copySuffix": "(copy)",
|
||||
"deleteTitle": "Delete this template?",
|
||||
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
|
||||
"confirmSendTitle": "Replace current input?",
|
||||
"confirmSendDescription": "You have unsent content in the input. Sending this template will replace it.",
|
||||
"confirmSendButton": "Send Template",
|
||||
"searchPlaceholder": "Search templates...",
|
||||
"searchNoResults": "No templates match your search",
|
||||
"pin": "Pin to top",
|
||||
"unpin": "Unpin from top",
|
||||
"saveAsTemplate": "Save as Template",
|
||||
"exportTemplates": "Export Templates",
|
||||
"importTemplates": "Import Templates",
|
||||
"exportEmpty": "No templates to export",
|
||||
"exportSuccess": "Exported {count} template(s) successfully",
|
||||
"importNoFile": "Please select a JSON file",
|
||||
"importFailed": "Import failed: {error}",
|
||||
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
|
||||
},
|
||||
"validation": {
|
||||
"title": "Validate Diagram",
|
||||
"capturing": "Capturing",
|
||||
|
||||
@@ -129,12 +129,7 @@
|
||||
"disabled": "無効",
|
||||
"customSystemMessage": "カスタムシステムメッセージ",
|
||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||
"panelVisibility": "ロビーパネル",
|
||||
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||
"showRecentChats": "最近のチャット",
|
||||
"showMyTemplates": "マイテンプレート",
|
||||
"showQuickExamples": "クイック例"
|
||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用..."
|
||||
},
|
||||
"save": {
|
||||
"title": "ダイアグラムを保存",
|
||||
@@ -145,8 +140,7 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG 画像",
|
||||
"svg": "SVG 画像",
|
||||
"xmlsvg": "編集可能 SVG"
|
||||
"svg": "SVG 画像"
|
||||
},
|
||||
"savedSuccessfully": "保存完了!"
|
||||
},
|
||||
@@ -356,51 +350,5 @@
|
||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||
"serverDefaultModel": "サーバーデフォルトモデル"
|
||||
},
|
||||
"templates": {
|
||||
"title": "マイテンプレート",
|
||||
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
|
||||
"emptyTitle": "テンプレートがありません",
|
||||
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
|
||||
"createFirst": "最初のテンプレートを作成",
|
||||
"neverUsed": "未使用",
|
||||
"usedCount": "{count} 回使用",
|
||||
"myTemplates": "マイテンプレート",
|
||||
"createTitle": "テンプレート作成",
|
||||
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
|
||||
"promptLabel": "プロンプト",
|
||||
"promptPlaceholder": "作成するダイアグラムを説明してください...",
|
||||
"promptRequired": "プロンプトは必須です",
|
||||
"titleLabel": "タイトル",
|
||||
"titlePlaceholder": "タイトルを入力",
|
||||
"titleHint": "空欄の場合はプロンプトの最初の20文字が使用されます",
|
||||
"descriptionLabel": "説明",
|
||||
"descriptionPlaceholder": "このテンプレートの説明を追加",
|
||||
"pinnedLabel": "テンプレートをピン留め",
|
||||
"pinnedHint": "ピン留めされたテンプレートはリストの上部に表示されます",
|
||||
"createButton": "テンプレート作成",
|
||||
"createFailed": "テンプレートの作成に失敗しました。もう一度お試しください。",
|
||||
"editTitle": "テンプレート編集",
|
||||
"editDescription": "テンプレートの内容と設定を更新します。",
|
||||
"updateFailed": "テンプレートの更新に失敗しました。もう一度お試しください。",
|
||||
"duplicate": "複製",
|
||||
"copySuffix": "(コピー)",
|
||||
"deleteTitle": "このテンプレートを削除しますか?",
|
||||
"deleteDescription": "このテンプレートは完全に削除されます。この操作は取り消せません。",
|
||||
"confirmSendTitle": "現在の入力を置き換えますか?",
|
||||
"confirmSendDescription": "未送信のコンテンツがあります。このテンプレートを送信すると置き換えられます。",
|
||||
"confirmSendButton": "テンプレートを送信",
|
||||
"searchPlaceholder": "テンプレートを検索...",
|
||||
"searchNoResults": "検索に一致するテンプレートがありません",
|
||||
"pin": "上部にピン留め",
|
||||
"unpin": "ピン留め解除",
|
||||
"saveAsTemplate": "テンプレートとして保存",
|
||||
"exportTemplates": "テンプレートをエクスポート",
|
||||
"importTemplates": "テンプレートをインポート",
|
||||
"exportEmpty": "エクスポートするテンプレートがありません",
|
||||
"exportSuccess": "{count} 件のテンプレートをエクスポートしました",
|
||||
"importNoFile": "JSON ファイルを選択してください",
|
||||
"importFailed": "インポートに失敗しました:{error}",
|
||||
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +129,7 @@
|
||||
"disabled": "已停用",
|
||||
"customSystemMessage": "自訂系統訊息",
|
||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||
"panelVisibility": "大廳面板",
|
||||
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
"showMyTemplates": "我的範本",
|
||||
"showQuickExamples": "快速範例"
|
||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案..."
|
||||
},
|
||||
"save": {
|
||||
"title": "儲存圖表",
|
||||
@@ -145,8 +140,7 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG 圖片",
|
||||
"svg": "SVG 圖片",
|
||||
"xmlsvg": "可編輯 SVG"
|
||||
"svg": "SVG 圖片"
|
||||
},
|
||||
"savedSuccessfully": "儲存成功!"
|
||||
},
|
||||
@@ -271,52 +265,6 @@
|
||||
"searchPlaceholder": "搜尋對話...",
|
||||
"noResults": "未找到對話"
|
||||
},
|
||||
"templates": {
|
||||
"title": "我的範本",
|
||||
"subtitle": "快速建立圖表的個人提示庫",
|
||||
"emptyTitle": "尚無範本",
|
||||
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
|
||||
"createFirst": "建立第一個範本",
|
||||
"neverUsed": "未使用過",
|
||||
"usedCount": "使用 {count} 次",
|
||||
"myTemplates": "我的範本",
|
||||
"createTitle": "建立範本",
|
||||
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
|
||||
"promptLabel": "提示",
|
||||
"promptPlaceholder": "描述您想建立的圖表...",
|
||||
"promptRequired": "提示為必填項",
|
||||
"titleLabel": "標題",
|
||||
"titlePlaceholder": "輸入標題",
|
||||
"titleHint": "留空則使用提示的前 20 個字元",
|
||||
"descriptionLabel": "描述",
|
||||
"descriptionPlaceholder": "為此範本新增描述",
|
||||
"pinnedLabel": "釘選範本",
|
||||
"pinnedHint": "釘選的範本會顯示在清單頂部",
|
||||
"createButton": "建立範本",
|
||||
"createFailed": "建立範本失敗,請重試。",
|
||||
"editTitle": "編輯範本",
|
||||
"editDescription": "更新範本內容和設定。",
|
||||
"updateFailed": "更新範本失敗,請重試。",
|
||||
"duplicate": "複製",
|
||||
"copySuffix": "(副本)",
|
||||
"deleteTitle": "刪除此範本?",
|
||||
"deleteDescription": "此操作將永久刪除該範本,無法復原。",
|
||||
"confirmSendTitle": "取代目前輸入?",
|
||||
"confirmSendDescription": "您有未傳送的內容。傳送此範本將取代它。",
|
||||
"confirmSendButton": "傳送範本",
|
||||
"searchPlaceholder": "搜尋範本...",
|
||||
"searchNoResults": "沒有符合的範本",
|
||||
"pin": "置頂",
|
||||
"unpin": "取消置頂",
|
||||
"saveAsTemplate": "儲存為範本",
|
||||
"exportTemplates": "匯出範本",
|
||||
"importTemplates": "匯入範本",
|
||||
"exportEmpty": "沒有可匯出的範本",
|
||||
"exportSuccess": "成功匯出 {count} 個範本",
|
||||
"importNoFile": "請選擇一個 JSON 檔案",
|
||||
"importFailed": "匯入失敗:{error}",
|
||||
"importSuccess": "成功匯入 {imported} 個範本,跳過 {skipped} 個重複"
|
||||
},
|
||||
"validation": {
|
||||
"title": "驗證圖表",
|
||||
"capturing": "截圖中",
|
||||
|
||||
@@ -129,12 +129,7 @@
|
||||
"disabled": "已禁用",
|
||||
"customSystemMessage": "自定义系统消息",
|
||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||
"panelVisibility": "大厅面板",
|
||||
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
"showMyTemplates": "我的模板",
|
||||
"showQuickExamples": "快速示例"
|
||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案..."
|
||||
},
|
||||
"save": {
|
||||
"title": "保存图表",
|
||||
@@ -145,8 +140,7 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG 图片",
|
||||
"svg": "SVG 图片",
|
||||
"xmlsvg": "可编辑 SVG"
|
||||
"svg": "SVG 图片"
|
||||
},
|
||||
"savedSuccessfully": "保存成功!"
|
||||
},
|
||||
@@ -271,52 +265,6 @@
|
||||
"searchPlaceholder": "搜索对话...",
|
||||
"noResults": "未找到对话"
|
||||
},
|
||||
"templates": {
|
||||
"title": "我的模板库",
|
||||
"subtitle": "您的个人 prompt 库,快速创建图表",
|
||||
"emptyTitle": "暂无模板",
|
||||
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
|
||||
"createFirst": "创建第一个模板",
|
||||
"neverUsed": "未使用过",
|
||||
"usedCount": "使用 {count} 次",
|
||||
"myTemplates": "我的模板",
|
||||
"createTitle": "创建模板",
|
||||
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
|
||||
"promptLabel": "提示词",
|
||||
"promptPlaceholder": "描述您想要创建的图表...",
|
||||
"promptRequired": "提示词不能为空",
|
||||
"titleLabel": "标题",
|
||||
"titlePlaceholder": "输入标题",
|
||||
"titleHint": "留空则使用提示词前 20 个字符作为标题",
|
||||
"descriptionLabel": "描述",
|
||||
"descriptionPlaceholder": "为此模板添加描述",
|
||||
"pinnedLabel": "置顶模板",
|
||||
"pinnedHint": "置顶的模板会显示在列表顶部",
|
||||
"createButton": "创建模板",
|
||||
"createFailed": "创建模板失败,请重试。",
|
||||
"editTitle": "编辑模板",
|
||||
"editDescription": "更新模板内容和设置。",
|
||||
"updateFailed": "更新模板失败,请重试。",
|
||||
"duplicate": "复制",
|
||||
"copySuffix": "(副本)",
|
||||
"deleteTitle": "删除此模板?",
|
||||
"deleteDescription": "此操作将永久删除该模板,无法撤销。",
|
||||
"confirmSendTitle": "替换当前输入?",
|
||||
"confirmSendDescription": "您有未发送的内容。发送此模板将替换它。",
|
||||
"confirmSendButton": "发送模板",
|
||||
"searchPlaceholder": "搜索模板...",
|
||||
"searchNoResults": "没有匹配的模板",
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
"saveAsTemplate": "保存为模板",
|
||||
"exportTemplates": "导出模板",
|
||||
"importTemplates": "导入模板",
|
||||
"exportEmpty": "没有可导出的模板",
|
||||
"exportSuccess": "成功导出 {count} 个模板",
|
||||
"importNoFile": "请选择一个 JSON 文件",
|
||||
"importFailed": "导入失败:{error}",
|
||||
"importSuccess": "成功导入 {imported} 个模板,跳过 {skipped} 个重复"
|
||||
},
|
||||
"validation": {
|
||||
"title": "验证图表",
|
||||
"capturing": "截图中",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||
import { nanoid } from "nanoid"
|
||||
import type { Template } from "./template-storage"
|
||||
|
||||
// Constants
|
||||
const DB_NAME = "next-ai-drawio"
|
||||
const DB_VERSION = 2
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = "sessions"
|
||||
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
||||
const MAX_SESSIONS = 50
|
||||
@@ -44,47 +43,10 @@ interface ChatSessionDB extends DBSchema {
|
||||
value: ChatSession
|
||||
indexes: { "by-updated": number }
|
||||
}
|
||||
templates: {
|
||||
key: string
|
||||
value: Template
|
||||
indexes: {
|
||||
"by-updated": number
|
||||
"by-pinned": number
|
||||
"by-run-count": number
|
||||
"by-last-used": number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Database singleton
|
||||
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||
const resetDBPromise = () => {
|
||||
dbPromise = null
|
||||
}
|
||||
|
||||
const isClosingError = (error: unknown): boolean => {
|
||||
return (
|
||||
error instanceof DOMException &&
|
||||
error.name === "InvalidStateError" &&
|
||||
/closing/i.test(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
const withDB = async <T>(
|
||||
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
} catch (error) {
|
||||
if (isClosingError(error)) {
|
||||
resetDBPromise()
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||
if (!dbPromise) {
|
||||
@@ -96,42 +58,9 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||
})
|
||||
store.createIndex("by-updated", "updatedAt")
|
||||
}
|
||||
// Version 2: templates store (added by template-storage.ts)
|
||||
// Note: We also need to include this here to ensure the upgrade
|
||||
// callback properly handles all migrations when opening from this file
|
||||
if (oldVersion < 2) {
|
||||
// Check if templates store already exists (created by template-storage.ts)
|
||||
if (!db.objectStoreNames.contains("templates")) {
|
||||
const templateStore = db.createObjectStore(
|
||||
"templates",
|
||||
{
|
||||
keyPath: "id",
|
||||
},
|
||||
)
|
||||
templateStore.createIndex("by-updated", "updatedAt")
|
||||
templateStore.createIndex("by-pinned", "pinned")
|
||||
templateStore.createIndex("by-run-count", "runCount")
|
||||
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||
}
|
||||
}
|
||||
},
|
||||
terminated() {
|
||||
resetDBPromise()
|
||||
// Future migrations: if (oldVersion < 2) { ... }
|
||||
},
|
||||
})
|
||||
dbPromise
|
||||
.then((db) => {
|
||||
db.onversionchange = () => {
|
||||
db.close()
|
||||
resetDBPromise()
|
||||
}
|
||||
db.onclose = () => {
|
||||
resetDBPromise()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
resetDBPromise()
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
@@ -146,46 +75,31 @@ export function isIndexedDBAvailable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if IndexedDB is actually usable (not just present).
|
||||
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
|
||||
// that other code depends on.
|
||||
export async function isIndexedDBUsable(): Promise<boolean> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
await getDB()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||
if (!isIndexedDBAvailable()) return []
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly")
|
||||
const index = tx.store.index("by-updated")
|
||||
const metadata: SessionMetadata[] = []
|
||||
const db = await getDB()
|
||||
const tx = db.transaction(STORE_NAME, "readonly")
|
||||
const index = tx.store.index("by-updated")
|
||||
const metadata: SessionMetadata[] = []
|
||||
|
||||
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||
let cursor = await index.openCursor(null, "prev") // newest first
|
||||
while (cursor) {
|
||||
const s = cursor.value
|
||||
metadata.push({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
messageCount: s.messages.length,
|
||||
hasDiagram:
|
||||
!!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||
})
|
||||
cursor = await cursor.continue()
|
||||
}
|
||||
return metadata
|
||||
})
|
||||
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||
let cursor = await index.openCursor(null, "prev") // newest first
|
||||
while (cursor) {
|
||||
const s = cursor.value
|
||||
metadata.push({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
messageCount: s.messages.length,
|
||||
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||
})
|
||||
cursor = await cursor.continue()
|
||||
}
|
||||
return metadata
|
||||
} catch (error) {
|
||||
console.error("Failed to get session metadata:", error)
|
||||
return []
|
||||
@@ -195,9 +109,8 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||
export async function getSession(id: string): Promise<ChatSession | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
})
|
||||
const db = await getDB()
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
} catch (error) {
|
||||
console.error("Failed to get session:", error)
|
||||
return null
|
||||
@@ -207,9 +120,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
|
||||
export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, session)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, session)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Handle quota exceeded
|
||||
@@ -221,9 +133,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
await deleteOldestSession()
|
||||
// Retry once
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, session)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, session)
|
||||
return true
|
||||
} catch (retryError) {
|
||||
console.error(
|
||||
@@ -242,9 +153,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.delete(STORE_NAME, id)
|
||||
})
|
||||
const db = await getDB()
|
||||
await db.delete(STORE_NAME, id)
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error)
|
||||
}
|
||||
@@ -253,9 +163,8 @@ export async function deleteSession(id: string): Promise<void> {
|
||||
export async function getSessionCount(): Promise<number> {
|
||||
if (!isIndexedDBAvailable()) return 0
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
return await db.count(STORE_NAME)
|
||||
})
|
||||
const db = await getDB()
|
||||
return await db.count(STORE_NAME)
|
||||
} catch (error) {
|
||||
console.error("Failed to get session count:", error)
|
||||
return 0
|
||||
@@ -265,15 +174,14 @@ export async function getSessionCount(): Promise<number> {
|
||||
export async function deleteOldestSession(): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||
const index = tx.store.index("by-updated")
|
||||
const cursor = await index.openCursor()
|
||||
if (cursor) {
|
||||
await cursor.delete()
|
||||
}
|
||||
await tx.done
|
||||
})
|
||||
const db = await getDB()
|
||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||
const index = tx.store.index("by-updated")
|
||||
const cursor = await index.openCursor()
|
||||
if (cursor) {
|
||||
await cursor.delete()
|
||||
}
|
||||
await tx.done
|
||||
} catch (error) {
|
||||
console.error("Failed to delete oldest session:", error)
|
||||
}
|
||||
|
||||
@@ -30,9 +30,4 @@ export const STORAGE_KEYS = {
|
||||
|
||||
// Custom system message
|
||||
customSystemMessage: "next-ai-draw-io-custom-system-message",
|
||||
|
||||
// Panel visibility
|
||||
showRecentChats: "next-ai-draw-io-show-recent-chats",
|
||||
showMyTemplates: "next-ai-draw-io-show-my-templates",
|
||||
showQuickExamples: "next-ai-draw-io-show-quick-examples",
|
||||
} as const
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
// Constants
|
||||
const DB_NAME = "next-ai-drawio-templates"
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = "templates"
|
||||
|
||||
// Types
|
||||
export interface Template {
|
||||
id: string
|
||||
title: string
|
||||
prompt: string
|
||||
description?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
clickCount: number
|
||||
runCount: number
|
||||
lastUsedAt: number
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export type TemplateCreateInput = Pick<Template, "prompt"> &
|
||||
Partial<
|
||||
Omit<
|
||||
Template,
|
||||
| "id"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "clickCount"
|
||||
| "runCount"
|
||||
| "lastUsedAt"
|
||||
>
|
||||
>
|
||||
|
||||
interface TemplateDB extends DBSchema {
|
||||
templates: {
|
||||
key: string
|
||||
value: Template
|
||||
indexes: {
|
||||
"by-updated": number
|
||||
"by-pinned": number
|
||||
"by-run-count": number
|
||||
"by-last-used": number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default title: first 20 chars of trimmed prompt, with ellipsis if truncated
|
||||
const DEFAULT_TITLE_MAX_LENGTH = 20
|
||||
|
||||
export function generateDefaultTitle(prompt: string): string {
|
||||
const trimmed = prompt.trim()
|
||||
if (trimmed.length <= DEFAULT_TITLE_MAX_LENGTH) return trimmed
|
||||
return trimmed.slice(0, DEFAULT_TITLE_MAX_LENGTH).trim() + "..."
|
||||
}
|
||||
|
||||
// Database singleton
|
||||
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
|
||||
const resetDBPromise = () => {
|
||||
dbPromise = null
|
||||
}
|
||||
|
||||
const isClosingError = (error: unknown): boolean => {
|
||||
return (
|
||||
error instanceof DOMException &&
|
||||
error.name === "InvalidStateError" &&
|
||||
/closing/i.test(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
const withDB = async <T>(
|
||||
action: (db: IDBPDatabase<TemplateDB>) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
} catch (error) {
|
||||
if (isClosingError(error)) {
|
||||
resetDBPromise()
|
||||
const db = await getDB()
|
||||
return await action(db)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<TemplateDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const templateStore = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: "id",
|
||||
})
|
||||
templateStore.createIndex("by-updated", "updatedAt")
|
||||
templateStore.createIndex("by-pinned", "pinned")
|
||||
templateStore.createIndex("by-run-count", "runCount")
|
||||
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||
}
|
||||
}
|
||||
},
|
||||
terminated() {
|
||||
resetDBPromise()
|
||||
},
|
||||
})
|
||||
dbPromise
|
||||
.then((db) => {
|
||||
db.onversionchange = () => {
|
||||
db.close()
|
||||
resetDBPromise()
|
||||
}
|
||||
db.onclose = () => {
|
||||
resetDBPromise()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
resetDBPromise()
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
// Check if IndexedDB is available
|
||||
export function isIndexedDBAvailable(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
return "indexedDB" in window && window.indexedDB !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
export async function getAllTemplates(): Promise<Template[]> {
|
||||
if (!isIndexedDBAvailable()) return []
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const templates = await db.getAll(STORE_NAME)
|
||||
return sortTemplates(templates)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to get templates:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplate(id: string): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to get template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTemplate(
|
||||
input: TemplateCreateInput,
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
|
||||
const prompt = input.prompt.trim()
|
||||
if (!prompt) return null
|
||||
|
||||
const now = Date.now()
|
||||
const template: Template = {
|
||||
id: nanoid(),
|
||||
title: input.title?.trim() || generateDefaultTitle(prompt),
|
||||
prompt,
|
||||
description: input.description?.trim() || undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: input.pinned ?? false,
|
||||
}
|
||||
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, template)
|
||||
})
|
||||
return template
|
||||
} catch (error) {
|
||||
console.error("Failed to create template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTemplate(
|
||||
id: string,
|
||||
updates: Partial<Omit<Template, "id" | "createdAt">>,
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
|
||||
const updated: Template = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await db.put(STORE_NAME, updated)
|
||||
return updated
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to update template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<boolean> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.delete(STORE_NAME, id)
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to delete template:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateTemplate(
|
||||
id: string,
|
||||
copySuffix = "(copy)",
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
return await withDB(async (db) => {
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const duplicate: Template = {
|
||||
...existing,
|
||||
id: nanoid(),
|
||||
title: `${existing.title} ${copySuffix}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
}
|
||||
await db.put(STORE_NAME, duplicate)
|
||||
return duplicate
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Usage tracking
|
||||
|
||||
export async function incrementClickCount(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
template.clickCount += 1
|
||||
template.updatedAt = Date.now()
|
||||
await db.put(STORE_NAME, template)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to increment click count:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function incrementRunCount(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
const now = Date.now()
|
||||
template.runCount += 1
|
||||
template.lastUsedAt = now
|
||||
template.updatedAt = now
|
||||
await db.put(STORE_NAME, template)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to increment run count:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Search
|
||||
|
||||
export function searchTemplates(
|
||||
templates: Template[],
|
||||
query: string,
|
||||
): Template[] {
|
||||
if (!query.trim()) return templates
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return templates.filter((t) => {
|
||||
const titleMatch = t.title.toLowerCase().includes(lowerQuery)
|
||||
const descMatch =
|
||||
t.description?.toLowerCase().includes(lowerQuery) ?? false
|
||||
return titleMatch || descMatch
|
||||
})
|
||||
}
|
||||
|
||||
// Sorting
|
||||
|
||||
export function sortTemplates(templates: Template[]): Template[] {
|
||||
return [...templates].sort((a, b) => {
|
||||
// pinned desc
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
// runCount desc
|
||||
if (a.runCount !== b.runCount) return b.runCount - a.runCount
|
||||
// lastUsedAt desc
|
||||
if (a.lastUsedAt !== b.lastUsedAt) return b.lastUsedAt - a.lastUsedAt
|
||||
// updatedAt desc
|
||||
return b.updatedAt - a.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
// Import / Export
|
||||
|
||||
export const TEMPLATE_EXPORT_SCHEMA_VERSION = 1
|
||||
|
||||
export interface TemplateExportData {
|
||||
schemaVersion: number
|
||||
exportedAt: number
|
||||
templates: Template[]
|
||||
}
|
||||
|
||||
export function exportTemplates(templates: Template[]): TemplateExportData {
|
||||
return {
|
||||
schemaVersion: TEMPLATE_EXPORT_SCHEMA_VERSION,
|
||||
exportedAt: Date.now(),
|
||||
templates,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateImportData(data: unknown): {
|
||||
valid: boolean
|
||||
error?: string
|
||||
} {
|
||||
if (!data || typeof data !== "object") {
|
||||
return { valid: false, error: "Invalid data: expected an object" }
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>
|
||||
|
||||
if (typeof obj.schemaVersion !== "number") {
|
||||
return { valid: false, error: "Missing or invalid schemaVersion" }
|
||||
}
|
||||
|
||||
if (!Array.isArray(obj.templates)) {
|
||||
return { valid: false, error: "Missing or invalid templates array" }
|
||||
}
|
||||
|
||||
for (let i = 0; i < obj.templates.length; i++) {
|
||||
const t = obj.templates[i]
|
||||
if (!t || typeof t !== "object") {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Template at index ${i} is not an object`,
|
||||
}
|
||||
}
|
||||
const template = t as Record<string, unknown>
|
||||
if (typeof template.prompt !== "string" || !template.prompt.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Template at index ${i} has missing or empty prompt`,
|
||||
}
|
||||
}
|
||||
if (typeof template.title !== "string" || !template.title.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Template at index ${i} has missing or empty title`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export async function importTemplates(
|
||||
templates: Template[],
|
||||
existingTemplates: Template[],
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
|
||||
const existingKeys = new Set(
|
||||
existingTemplates.map((t) => `${t.title}|||${t.prompt}`),
|
||||
)
|
||||
|
||||
for (const t of templates) {
|
||||
const key = `${t.title}|||${t.prompt}`
|
||||
if (existingKeys.has(key)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const newTemplate: Template = {
|
||||
id: nanoid(),
|
||||
title:
|
||||
String(t.title || "").trim() ||
|
||||
generateDefaultTitle(String(t.prompt || "")),
|
||||
prompt: String(t.prompt || "").trim(),
|
||||
description: t.description ? String(t.description) : undefined,
|
||||
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
|
||||
updatedAt: now,
|
||||
clickCount: typeof t.clickCount === "number" ? t.clickCount : 0,
|
||||
runCount: typeof t.runCount === "number" ? t.runCount : 0,
|
||||
lastUsedAt: typeof t.lastUsedAt === "number" ? t.lastUsedAt : 0,
|
||||
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
|
||||
}
|
||||
try {
|
||||
await withDB(async (db) => {
|
||||
await db.put(STORE_NAME, newTemplate)
|
||||
})
|
||||
existingKeys.add(key)
|
||||
imported++
|
||||
} catch (error) {
|
||||
console.error("Failed to import template:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped }
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export type ProviderName =
|
||||
| "qiniu"
|
||||
| "kimi"
|
||||
| "minimax"
|
||||
| "novita"
|
||||
|
||||
// Individual model configuration
|
||||
export interface ModelConfig {
|
||||
@@ -102,7 +101,6 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
doubao: "bytedance",
|
||||
modelscope: "modelscope",
|
||||
minimax: "minimax",
|
||||
novita: "novita",
|
||||
}
|
||||
|
||||
// Provider metadata
|
||||
@@ -181,10 +179,6 @@ export const PROVIDER_INFO: Record<
|
||||
label: "MiniMax",
|
||||
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
|
||||
},
|
||||
novita: {
|
||||
label: "Novita AI",
|
||||
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||
},
|
||||
}
|
||||
|
||||
// Suggested models per provider for quick add
|
||||
@@ -356,12 +350,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
],
|
||||
novita: [
|
||||
// Novita AI models (OpenAI-compatible API)
|
||||
"moonshotai/kimi-k2.5",
|
||||
"zai-org/glm-5",
|
||||
"minimax/minimax-m2.5",
|
||||
],
|
||||
}
|
||||
|
||||
// Helper to generate UUID
|
||||
|
||||
2888
package-lock.json
generated
2888
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-ai-draw-io",
|
||||
"version": "0.4.14",
|
||||
"version": "0.4.13",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
@@ -51,7 +51,7 @@
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -72,7 +72,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"jsonrepair": "^3.13.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.23.25",
|
||||
"nanoid": "^5.0.0",
|
||||
"negotiator": "^1.0.0",
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@biomejs/biome": "2.4.4",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -128,7 +128,7 @@
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "9.39.4",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
|
||||
12
packages/mcp-server/package-lock.json
generated
12
packages/mcp-server/package-lock.json
generated
@@ -481,9 +481,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"version": "1.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
||||
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
@@ -521,9 +521,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"version": "24.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
|
||||
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
generateDefaultTitle,
|
||||
searchTemplates,
|
||||
sortTemplates,
|
||||
type Template,
|
||||
validateImportData,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
describe("generateDefaultTitle", () => {
|
||||
it("returns the prompt as-is when 20 chars or fewer", () => {
|
||||
expect(generateDefaultTitle("Hello")).toBe("Hello")
|
||||
expect(generateDefaultTitle("A".repeat(20))).toBe("A".repeat(20))
|
||||
})
|
||||
|
||||
it("truncates to 20 chars with ellipsis for longer prompts", () => {
|
||||
const result = generateDefaultTitle(
|
||||
"This is a very long prompt that should be truncated",
|
||||
)
|
||||
expect(result).toBe("This is a very long...")
|
||||
expect(result.length).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it("trims whitespace before truncating", () => {
|
||||
expect(generateDefaultTitle(" hello ")).toBe("hello")
|
||||
expect(generateDefaultTitle(" " + "x".repeat(25) + " ")).toBe(
|
||||
"xxxxxxxxxxxxxxxxxxxx...",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sortTemplates", () => {
|
||||
const baseTemplate = (
|
||||
overrides: Partial<Omit<Template, "id">> & Pick<Template, "id">,
|
||||
): Template => ({
|
||||
title: "Test",
|
||||
prompt: "test prompt",
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it("sorts pinned templates first", () => {
|
||||
const templates = [
|
||||
baseTemplate({ id: "1", runCount: 10 }),
|
||||
baseTemplate({ id: "2", pinned: true, runCount: 1 }),
|
||||
]
|
||||
const result = sortTemplates(templates)
|
||||
expect(result[0].id).toBe("2")
|
||||
})
|
||||
|
||||
it("sorts by runCount descending among non-pinned", () => {
|
||||
const templates = [
|
||||
baseTemplate({ id: "1", runCount: 5 }),
|
||||
baseTemplate({ id: "2", runCount: 10 }),
|
||||
baseTemplate({ id: "3", runCount: 1 }),
|
||||
]
|
||||
const result = sortTemplates(templates)
|
||||
expect(result.map((t) => t.id)).toEqual(["2", "1", "3"])
|
||||
})
|
||||
|
||||
it("sorts by lastUsedAt descending when runCount is equal", () => {
|
||||
const templates = [
|
||||
baseTemplate({ id: "1", runCount: 5, lastUsedAt: 100 }),
|
||||
baseTemplate({ id: "2", runCount: 5, lastUsedAt: 200 }),
|
||||
]
|
||||
const result = sortTemplates(templates)
|
||||
expect(result[0].id).toBe("2")
|
||||
})
|
||||
|
||||
it("sorts by updatedAt descending when runCount and lastUsedAt are equal", () => {
|
||||
const templates = [
|
||||
baseTemplate({
|
||||
id: "1",
|
||||
runCount: 5,
|
||||
lastUsedAt: 100,
|
||||
updatedAt: 50,
|
||||
}),
|
||||
baseTemplate({
|
||||
id: "2",
|
||||
runCount: 5,
|
||||
lastUsedAt: 100,
|
||||
updatedAt: 150,
|
||||
}),
|
||||
]
|
||||
const result = sortTemplates(templates)
|
||||
expect(result[0].id).toBe("2")
|
||||
})
|
||||
|
||||
it("does not mutate the original array", () => {
|
||||
const templates = [
|
||||
baseTemplate({ id: "1", runCount: 1 }),
|
||||
baseTemplate({ id: "2", runCount: 10 }),
|
||||
]
|
||||
const copy = [...templates]
|
||||
sortTemplates(templates)
|
||||
expect(templates.map((t) => t.id)).toEqual(copy.map((t) => t.id))
|
||||
})
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(sortTemplates([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("searchTemplates", () => {
|
||||
const templates: Template[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Flowchart",
|
||||
prompt: "Create a flowchart",
|
||||
description: "Basic flowchart template",
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "ER Diagram",
|
||||
prompt: "Design a database",
|
||||
description: "Entity relationship diagram",
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Network",
|
||||
prompt: "Draw network topology",
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
},
|
||||
]
|
||||
|
||||
it("returns all templates when query is empty", () => {
|
||||
expect(searchTemplates(templates, "")).toHaveLength(3)
|
||||
expect(searchTemplates(templates, " ")).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("searches by title", () => {
|
||||
const result = searchTemplates(templates, "flowchart")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("1")
|
||||
})
|
||||
|
||||
it("searches by description", () => {
|
||||
const result = searchTemplates(templates, "entity")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("2")
|
||||
})
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const result = searchTemplates(templates, "FLOWCHART")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("1")
|
||||
})
|
||||
|
||||
it("returns empty array when no match", () => {
|
||||
const result = searchTemplates(templates, "nonexistent")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("handles templates without description", () => {
|
||||
const result = searchTemplates(templates, "network")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("3")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateImportData", () => {
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateImportData(null).valid).toBe(false)
|
||||
expect(validateImportData("string").valid).toBe(false)
|
||||
expect(validateImportData(123).valid).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects missing schemaVersion", () => {
|
||||
const result = validateImportData({ templates: [] })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain("schemaVersion")
|
||||
})
|
||||
|
||||
it("rejects missing templates array", () => {
|
||||
const result = validateImportData({ schemaVersion: 1 })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain("templates")
|
||||
})
|
||||
|
||||
it("rejects template with missing prompt", () => {
|
||||
const result = validateImportData({
|
||||
schemaVersion: 1,
|
||||
templates: [{ title: "Test" }],
|
||||
})
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain("prompt")
|
||||
})
|
||||
|
||||
it("rejects template with empty prompt", () => {
|
||||
const result = validateImportData({
|
||||
schemaVersion: 1,
|
||||
templates: [{ title: "Test", prompt: " " }],
|
||||
})
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain("prompt")
|
||||
})
|
||||
|
||||
it("rejects template with missing title", () => {
|
||||
const result = validateImportData({
|
||||
schemaVersion: 1,
|
||||
templates: [{ prompt: "test" }],
|
||||
})
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain("title")
|
||||
})
|
||||
|
||||
it("accepts valid data", () => {
|
||||
const result = validateImportData({
|
||||
schemaVersion: 1,
|
||||
templates: [
|
||||
{
|
||||
id: "1",
|
||||
title: "Test",
|
||||
prompt: "test prompt",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts empty templates array", () => {
|
||||
const result = validateImportData({
|
||||
schemaVersion: 1,
|
||||
templates: [],
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it("reports index of invalid template", () => {
|
||||
const result = validateImportData({
|
||||
schemaVersion: 1,
|
||||
templates: [
|
||||
{ title: "Valid", prompt: "valid" },
|
||||
{ title: "Also Valid", prompt: "also valid" },
|
||||
{ title: "No Prompt" },
|
||||
],
|
||||
})
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain("index 2")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user