mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
8 Commits
v0.4.14
...
feat/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2158d26bbb | ||
|
|
e27afe6f0d | ||
|
|
f593901fee | ||
|
|
6c6cf98019 | ||
|
|
f5ea5a0edd | ||
|
|
cb8127920c | ||
|
|
29538782fc | ||
|
|
8d6d33bfc9 |
9
.github/CONTRIBUTING.md
vendored
9
.github/CONTRIBUTING.md
vendored
@@ -61,6 +61,15 @@ For **significant changes** (new features, architecture changes, large refactors
|
|||||||
|
|
||||||
CI will run the full test suite on your PR.
|
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
|
## Code Review
|
||||||
|
|
||||||
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
||||||
|
|||||||
9
.github/workflows/electron-release.yml
vendored
9
.github/workflows/electron-release.yml
vendored
@@ -105,9 +105,14 @@ jobs:
|
|||||||
wait-for-completion: true
|
wait-for-completion: true
|
||||||
output-artifact-directory: release-signed
|
output-artifact-directory: release-signed
|
||||||
|
|
||||||
- name: Upload signed artifacts to release
|
- name: Fix latest.yml with signed exe hashes
|
||||||
|
run: node scripts/fix-latest-yml.mjs
|
||||||
|
|
||||||
|
- name: Upload signed artifacts and update metadata to release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: release-signed/*.exe
|
files: |
|
||||||
|
release-signed/*.exe
|
||||||
|
release/latest*.yml
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -75,3 +75,4 @@ ai-models.json
|
|||||||
|
|
||||||
# local backups
|
# local backups
|
||||||
*.bak
|
*.bak
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -436,7 +436,18 @@ ${userInputText}
|
|||||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
// - 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
|
// Some providers (e.g. MiniMax) don't support multiple system messages
|
||||||
// Merge them into a single system message for compatibility
|
// Merge them into a single system message for compatibility
|
||||||
const isSingleSystemProvider = SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider)
|
// 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 xmlContext = `${
|
const xmlContext = `${
|
||||||
previousXml
|
previousXml
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
@@ -236,6 +238,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
@@ -439,6 +442,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
id="chat-form"
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className={`w-full transition-all duration-200 ${
|
className={`w-full transition-all duration-200 ${
|
||||||
isDragging
|
isDragging
|
||||||
@@ -538,6 +542,18 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
</ButtonWithTooltip>
|
</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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -610,6 +626,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
isExtracting={isExtractingUrl}
|
isExtracting={isExtractingUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={showSaveAsTemplate}
|
||||||
|
onOpenChange={setShowSaveAsTemplate}
|
||||||
|
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||||
|
initialPrompt={input.trim()}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||||
@@ -159,6 +161,10 @@ interface ChatMessageDisplayProps {
|
|||||||
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
||||||
validationStates?: Record<string, ValidationState>
|
validationStates?: Record<string, ValidationState>
|
||||||
onImproveWithSuggestions?: (feedback: string) => void
|
onImproveWithSuggestions?: (feedback: string) => void
|
||||||
|
onSendTemplate?: (
|
||||||
|
template: import("@/lib/template-storage").Template,
|
||||||
|
) => void
|
||||||
|
currentInput?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
@@ -178,6 +184,8 @@ export function ChatMessageDisplay({
|
|||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
validationStates = {},
|
validationStates = {},
|
||||||
onImproveWithSuggestions,
|
onImproveWithSuggestions,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
@@ -237,6 +245,10 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({})
|
>({})
|
||||||
|
// Track "Save as Template" dialog
|
||||||
|
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null)
|
||||||
|
|
||||||
const setCopyState = (
|
const setCopyState = (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -650,6 +662,8 @@ export function ChatMessageDisplay({
|
|||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
dict={dict}
|
dict={dict}
|
||||||
/>
|
/>
|
||||||
) : messages.length === 0 ? null : (
|
) : messages.length === 0 ? null : (
|
||||||
@@ -747,8 +761,42 @@ export function ChatMessageDisplay({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</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">
|
<div className="max-w-[85%] min-w-0">
|
||||||
{/* Reasoning blocks - displayed first for assistant messages */}
|
{/* Reasoning blocks - displayed first for assistant messages */}
|
||||||
{message.role === "assistant" &&
|
{message.role === "assistant" &&
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import Image from "@/components/image-with-basepath"
|
|||||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
import { useAutoUpdate } from "@/hooks/use-auto-update"
|
||||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||||
@@ -973,6 +974,25 @@ export default function ChatPanel({
|
|||||||
pathname,
|
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 = (
|
const handleInputChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) => {
|
) => {
|
||||||
@@ -1234,10 +1254,22 @@ export default function ChatPanel({
|
|||||||
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-update listener (must be before early return so it's always active)
|
||||||
|
useAutoUpdate()
|
||||||
|
|
||||||
// Collapsed view (desktop only)
|
// Collapsed view (desktop only)
|
||||||
if (!isVisible && !isMobile) {
|
if (!isVisible && !isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
||||||
|
<Toaster
|
||||||
|
position="bottom-left"
|
||||||
|
richColors
|
||||||
|
expand
|
||||||
|
toastOptions={{
|
||||||
|
style: { maxWidth: "480px" },
|
||||||
|
duration: 2000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent={dict.nav.showPanel}
|
tooltipContent={dict.nav.showPanel}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1376,6 +1408,8 @@ export default function ChatPanel({
|
|||||||
loadedMessageIdsRef={loadedMessageIdsRef}
|
loadedMessageIdsRef={loadedMessageIdsRef}
|
||||||
validationStates={validationStates}
|
validationStates={validationStates}
|
||||||
onImproveWithSuggestions={handleImproveWithSuggestions}
|
onImproveWithSuggestions={handleImproveWithSuggestions}
|
||||||
|
onSendTemplate={handleSendTemplate}
|
||||||
|
currentInput={input}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||||
import ExamplePanel from "@/components/chat-example-panel"
|
import ExamplePanel from "@/components/chat-example-panel"
|
||||||
import Image from "@/components/image-with-basepath"
|
import Image from "@/components/image-with-basepath"
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
import type { Template } from "@/lib/template-storage"
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,6 +38,8 @@ interface ChatLobbyProps {
|
|||||||
onDeleteSession?: (id: string) => void
|
onDeleteSession?: (id: string) => void
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
|
onSendTemplate?: (template: Template) => void
|
||||||
|
currentInput?: string
|
||||||
dict: {
|
dict: {
|
||||||
sessionHistory?: {
|
sessionHistory?: {
|
||||||
recentChats?: string
|
recentChats?: string
|
||||||
@@ -44,6 +49,10 @@ interface ChatLobbyProps {
|
|||||||
deleteTitle?: string
|
deleteTitle?: string
|
||||||
deleteDescription?: string
|
deleteDescription?: string
|
||||||
}
|
}
|
||||||
|
templates?: {
|
||||||
|
title?: string
|
||||||
|
myTemplates?: string
|
||||||
|
}
|
||||||
examples?: {
|
examples?: {
|
||||||
quickExamples?: string
|
quickExamples?: string
|
||||||
}
|
}
|
||||||
@@ -75,164 +84,233 @@ 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({
|
export function ChatLobby({
|
||||||
sessions,
|
sessions,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
dict,
|
dict,
|
||||||
}: ChatLobbyProps) {
|
}: ChatLobbyProps) {
|
||||||
// Track whether examples section is expanded (collapsed by default when there's history)
|
const [templatesExpanded, setTemplatesExpanded] = useState(false)
|
||||||
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||||
// Delete confirmation dialog state
|
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||||
// Search filter for history
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
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
|
const hasHistory = sessions.length > 0
|
||||||
|
|
||||||
if (!hasHistory) {
|
if (!hasHistory) {
|
||||||
// Show full examples when no history
|
if (panelVisibility.myTemplates) {
|
||||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
return (
|
||||||
|
<TemplatePanel
|
||||||
|
setInput={setInput}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (panelVisibility.quickExamples) {
|
||||||
|
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show history + collapsible examples when there are sessions
|
// Show history + collapsible examples when there are sessions
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
{/* Recent Chats Section */}
|
{/* Recent Chats Section */}
|
||||||
<div className="mb-6">
|
{panelVisibility.recentChats && (
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
<div className="mb-6">
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
</p>
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
{/* Search Bar */}
|
</p>
|
||||||
<div className="relative mb-3">
|
{/* Search Bar */}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<div className="relative mb-3">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
type="text"
|
<input
|
||||||
placeholder={
|
type="text"
|
||||||
dict.sessionHistory?.searchPlaceholder ||
|
placeholder={
|
||||||
"Search chats..."
|
dict.sessionHistory?.searchPlaceholder ||
|
||||||
}
|
"Search chats..."
|
||||||
value={searchQuery}
|
}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
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"
|
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
|
{searchQuery && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSearchQuery("")}
|
type="button"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
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>
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
{sessions
|
<div className="space-y-2">
|
||||||
.filter((session) =>
|
{sessions
|
||||||
session.title
|
.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
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchQuery.toLowerCase()),
|
.includes(searchQuery.toLowerCase()),
|
||||||
)
|
).length === 0 &&
|
||||||
.map((session) => (
|
searchQuery && (
|
||||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
<div
|
{dict.sessionHistory?.noResults ||
|
||||||
key={session.id}
|
"No chats found"}
|
||||||
role="button"
|
</p>
|
||||||
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>
|
||||||
)}
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
|||||||
203
components/chat/TemplateCreateDialog.tsx
Normal file
203
components/chat/TemplateCreateDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
components/chat/TemplateEditDialog.tsx
Normal file
215
components/chat/TemplateEditDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
622
components/chat/TemplatePanel.tsx
Normal file
622
components/chat/TemplatePanel.tsx
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
export type ExportFormat = "drawio" | "png" | "svg"
|
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -74,6 +74,11 @@ export function SaveDialog({
|
|||||||
label: dict.save.formats.svg,
|
label: dict.save.formats.svg,
|
||||||
extension: ".svg",
|
extension: ".svg",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "xmlsvg" as const,
|
||||||
|
label: dict.save.formats.xmlsvg,
|
||||||
|
extension: ".drawio.svg",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -114,6 +114,20 @@ function SettingsContent({
|
|||||||
const [currentLang, setCurrentLang] = useState("en")
|
const [currentLang, setCurrentLang] = useState("en")
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
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)
|
// Proxy settings state (Electron only)
|
||||||
const [httpProxy, setHttpProxy] = useState("")
|
const [httpProxy, setHttpProxy] = useState("")
|
||||||
const [httpsProxy, setHttpsProxy] = useState("")
|
const [httpsProxy, setHttpsProxy] = useState("")
|
||||||
@@ -164,6 +178,17 @@ function SettingsContent({
|
|||||||
)
|
)
|
||||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
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("")
|
setError("")
|
||||||
|
|
||||||
// Load proxy settings (Electron only)
|
// Load proxy settings (Electron only)
|
||||||
@@ -443,6 +468,63 @@ function SettingsContent({
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</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 */}
|
{/* VLM Diagram Validation */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramValidation}
|
label={dict.settings.diagramValidation}
|
||||||
|
|||||||
124
components/update-toast.tsx
Normal file
124
components/update-toast.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
interface UpdateToastAvailableProps {
|
||||||
|
variant: "download"
|
||||||
|
version: string
|
||||||
|
onDownload: () => void
|
||||||
|
onDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateToastManualProps {
|
||||||
|
variant: "manual"
|
||||||
|
version: string
|
||||||
|
url: string
|
||||||
|
onDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateToastDownloadingProps {
|
||||||
|
variant: "downloading"
|
||||||
|
percent: number
|
||||||
|
onDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateToastProps =
|
||||||
|
| UpdateToastAvailableProps
|
||||||
|
| UpdateToastManualProps
|
||||||
|
| UpdateToastDownloadingProps
|
||||||
|
|
||||||
|
export function UpdateToast(props: UpdateToastProps) {
|
||||||
|
const { variant, onDismiss } = props
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{variant === "downloading" ? (
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
Downloading update... {Math.round(props.percent)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
Version {props.version} is available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variant === "download" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.onDownload()
|
||||||
|
}}
|
||||||
|
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === "manual" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(props.url, "_blank")
|
||||||
|
}}
|
||||||
|
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -65,6 +65,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
setIsDrawioReady(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 = () => {
|
const resetDrawioReady = () => {
|
||||||
@@ -77,24 +81,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
chartXMLRef.current = chartXML
|
chartXMLRef.current = chartXML
|
||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
|
||||||
// Also restore when chartXML changes while DrawIO is ready (e.g., session loaded after iframe ready)
|
|
||||||
const lastRestoredXmlRef = useRef<string>("")
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDrawioReady || !drawioRef.current) return
|
|
||||||
// Only load if we have a real diagram and it's different from what we already loaded
|
|
||||||
if (
|
|
||||||
isRealDiagram(chartXML) &&
|
|
||||||
chartXML !== lastRestoredXmlRef.current
|
|
||||||
) {
|
|
||||||
lastRestoredXmlRef.current = chartXML
|
|
||||||
drawioRef.current.load({ xml: chartXML })
|
|
||||||
} else if (!isRealDiagram(chartXML)) {
|
|
||||||
// Reset when diagram is cleared so a future restore can re-load the same XML.
|
|
||||||
lastRestoredXmlRef.current = ""
|
|
||||||
}
|
|
||||||
}, [isDrawioReady, chartXML])
|
|
||||||
|
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
const saveResolverRef = useRef<{
|
const saveResolverRef = useRef<{
|
||||||
resolver: ((data: string) => void) | null
|
resolver: ((data: string) => void) | null
|
||||||
@@ -233,7 +219,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
saveResolverRef.current = { resolver: null, format: null }
|
saveResolverRef.current = { resolver: null, format: null }
|
||||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||||
if (format === "png" || format === "svg") {
|
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||||
|
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +283,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// Map format to draw.io export format
|
||||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
const drawioFormat =
|
||||||
|
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||||
|
|
||||||
// Set up the resolver before triggering export
|
// Set up the resolver before triggering export
|
||||||
saveResolverRef.current = {
|
saveResolverRef.current = {
|
||||||
@@ -320,8 +308,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
extension = ".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 {
|
} else {
|
||||||
// SVG format
|
// SVG format (view-only)
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".svg"
|
extension = ".svg"
|
||||||
|
|||||||
14
electron/electron.d.ts
vendored
14
electron/electron.d.ts
vendored
@@ -2,6 +2,14 @@
|
|||||||
* Type declarations for Electron API exposed via preload script
|
* Type declarations for Electron API exposed via preload script
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Update status data sent from main process */
|
||||||
|
type UpdateStatusData =
|
||||||
|
| { status: "available"; version: string }
|
||||||
|
| { status: "available-manual"; version: string; url: string }
|
||||||
|
| { status: "downloading"; percent: number }
|
||||||
|
| { status: "downloaded" }
|
||||||
|
| { status: "error"; message: string }
|
||||||
|
|
||||||
/** Configuration preset interface */
|
/** Configuration preset interface */
|
||||||
interface ConfigPreset {
|
interface ConfigPreset {
|
||||||
id: string
|
id: string
|
||||||
@@ -74,6 +82,12 @@ declare global {
|
|||||||
>
|
>
|
||||||
/** Set user's preferred locale */
|
/** Set user's preferred locale */
|
||||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||||
|
/** Listen for update status events from main process */
|
||||||
|
onUpdateStatus: (
|
||||||
|
callback: (data: UpdateStatusData) => void,
|
||||||
|
) => () => void
|
||||||
|
/** Start downloading the available update */
|
||||||
|
startDownload: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
/** Settings window Electron API */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type MenuItemConstructorOptions,
|
type MenuItemConstructorOptions,
|
||||||
shell,
|
shell,
|
||||||
} from "electron"
|
} from "electron"
|
||||||
|
import { checkForUpdatesManual } from "./auto-updater"
|
||||||
import {
|
import {
|
||||||
applyPresetToEnv,
|
applyPresetToEnv,
|
||||||
getAllPresets,
|
getAllPresets,
|
||||||
@@ -156,6 +157,11 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
template.push({
|
template.push({
|
||||||
label: t.help,
|
label: t.help,
|
||||||
submenu: [
|
submenu: [
|
||||||
|
{
|
||||||
|
label: t.checkForUpdates,
|
||||||
|
click: () => checkForUpdatesManual(),
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: t.documentation,
|
label: t.documentation,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
|
|||||||
282
electron/main/auto-updater.ts
Normal file
282
electron/main/auto-updater.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import https from "node:https"
|
||||||
|
import { app, dialog, ipcMain } from "electron"
|
||||||
|
import electronUpdater from "electron-updater"
|
||||||
|
import { getMainWindow } from "./window-manager"
|
||||||
|
|
||||||
|
const { autoUpdater } = electronUpdater
|
||||||
|
|
||||||
|
const CHECK_INTERVAL = 4 * 60 * 60 * 1000 // 4 hours
|
||||||
|
const STARTUP_DELAY = 10_000 // 10 seconds
|
||||||
|
const GITHUB_API_URL =
|
||||||
|
"https://api.github.com/repos/DayuanJiang/next-ai-draw-io/releases/latest"
|
||||||
|
|
||||||
|
let isChecking = false
|
||||||
|
let updateDownloaded = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this platform supports electron-updater auto-update.
|
||||||
|
* macOS: disabled because builds are ad-hoc signed (no Apple Developer cert).
|
||||||
|
*/
|
||||||
|
function supportsAutoUpdate(): boolean {
|
||||||
|
if (process.platform === "darwin") return false
|
||||||
|
if (process.platform === "win32" && process.env.PORTABLE_EXECUTABLE_DIR)
|
||||||
|
return false
|
||||||
|
if (process.platform === "linux" && !process.env.APPIMAGE) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two semver-like version strings numerically.
|
||||||
|
* Returns true if remote > local.
|
||||||
|
* Strips prerelease/build metadata (e.g., "1.2.3-beta.1" → "1.2.3").
|
||||||
|
*/
|
||||||
|
function isNewerVersion(remote: string, local: string): boolean {
|
||||||
|
const strip = (v: string) => v.replace(/^v/, "").split("-")[0].split("+")[0]
|
||||||
|
const r = strip(remote).split(".").map(Number)
|
||||||
|
const l = strip(local).split(".").map(Number)
|
||||||
|
const len = Math.max(r.length, l.length)
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const rv = r[i] || 0
|
||||||
|
const lv = l[i] || 0
|
||||||
|
if (rv > lv) return true
|
||||||
|
if (rv < lv) return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send update status to the renderer via IPC
|
||||||
|
*/
|
||||||
|
function sendStatus(data: Record<string, unknown>) {
|
||||||
|
const win = getMainWindow()
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.send("update-status", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check GitHub API for latest release (used on macOS and Linux DEB)
|
||||||
|
*/
|
||||||
|
function checkGitHubRelease(manual: boolean) {
|
||||||
|
const req = https.get(
|
||||||
|
GITHUB_API_URL,
|
||||||
|
{
|
||||||
|
headers: { "User-Agent": "next-ai-draw-io" },
|
||||||
|
timeout: 15000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
console.error(`GitHub API returned status ${res.statusCode}`)
|
||||||
|
if (manual) {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: "error",
|
||||||
|
title: "Update Check Failed",
|
||||||
|
message:
|
||||||
|
"Could not check for updates. Please try again later.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isChecking = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setEncoding("utf8")
|
||||||
|
let body = ""
|
||||||
|
res.on("data", (chunk: string) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body)
|
||||||
|
const remoteVersion = data.tag_name || ""
|
||||||
|
const localVersion = app.getVersion()
|
||||||
|
|
||||||
|
if (isNewerVersion(remoteVersion, localVersion)) {
|
||||||
|
sendStatus({
|
||||||
|
status: "available-manual",
|
||||||
|
version: remoteVersion.replace(/^v/, ""),
|
||||||
|
url: data.html_url,
|
||||||
|
})
|
||||||
|
} else if (manual) {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: "info",
|
||||||
|
title: "No Updates",
|
||||||
|
message: "You're up to date!",
|
||||||
|
detail: `Version ${localVersion} is the latest version.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse GitHub release:", err)
|
||||||
|
if (manual) {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: "error",
|
||||||
|
title: "Update Check Failed",
|
||||||
|
message:
|
||||||
|
"Could not check for updates. Please try again later.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isChecking = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy()
|
||||||
|
isChecking = false
|
||||||
|
})
|
||||||
|
req.on("error", (err) => {
|
||||||
|
console.error("GitHub API request failed:", err)
|
||||||
|
if (manual) {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: "error",
|
||||||
|
title: "Update Check Failed",
|
||||||
|
message: "Could not check for updates. Please try again later.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isChecking = false
|
||||||
|
})
|
||||||
|
req.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up electron-updater event handlers (Windows NSIS / Linux AppImage)
|
||||||
|
*/
|
||||||
|
function setupAutoUpdater() {
|
||||||
|
autoUpdater.autoDownload = false
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
|
autoUpdater.on("update-available", (info) => {
|
||||||
|
console.log("Update available:", info.version)
|
||||||
|
sendStatus({ status: "available", version: info.version })
|
||||||
|
isChecking = false
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on("update-not-available", () => {
|
||||||
|
console.log("No update available")
|
||||||
|
isChecking = false
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on("download-progress", (progress) => {
|
||||||
|
sendStatus({
|
||||||
|
status: "downloading",
|
||||||
|
percent: progress.percent,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on("update-downloaded", () => {
|
||||||
|
updateDownloaded = true
|
||||||
|
console.log("Update downloaded")
|
||||||
|
sendStatus({ status: "downloaded" })
|
||||||
|
showRestartDialog()
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.on("error", (err) => {
|
||||||
|
console.error("Auto-update error:", err)
|
||||||
|
sendStatus({ status: "error", message: String(err) })
|
||||||
|
isChecking = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// IPC: renderer requests download
|
||||||
|
ipcMain.handle("updater:start-download", () => {
|
||||||
|
return autoUpdater.downloadUpdate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show restart dialog after update downloaded
|
||||||
|
*/
|
||||||
|
async function showRestartDialog() {
|
||||||
|
const result = await dialog.showMessageBox({
|
||||||
|
type: "info",
|
||||||
|
buttons: ["Restart Now", "Later"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
title: "Update Ready",
|
||||||
|
message: "A new version has been downloaded.",
|
||||||
|
detail: "Restart the app to install the update.",
|
||||||
|
})
|
||||||
|
if (result.response === 0) {
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single update check
|
||||||
|
*/
|
||||||
|
function doCheck(manual: boolean) {
|
||||||
|
if (isChecking) return
|
||||||
|
isChecking = true
|
||||||
|
|
||||||
|
// If update already downloaded, just re-show restart dialog
|
||||||
|
if (updateDownloaded && supportsAutoUpdate()) {
|
||||||
|
isChecking = false
|
||||||
|
showRestartDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsAutoUpdate()) {
|
||||||
|
// For manual check, register listeners BEFORE triggering check
|
||||||
|
// to avoid race where event fires before listeners are attached
|
||||||
|
if (manual) {
|
||||||
|
const onNotAvailable = () => {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: "info",
|
||||||
|
title: "No Updates",
|
||||||
|
message: "You're up to date!",
|
||||||
|
detail: `Version ${app.getVersion()} is the latest version.`,
|
||||||
|
})
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
const onAvailable = () => cleanup()
|
||||||
|
const onError = () => cleanup()
|
||||||
|
const cleanup = () => {
|
||||||
|
autoUpdater.off("update-not-available", onNotAvailable)
|
||||||
|
autoUpdater.off("update-available", onAvailable)
|
||||||
|
autoUpdater.off("error", onError)
|
||||||
|
}
|
||||||
|
autoUpdater.once("update-not-available", onNotAvailable)
|
||||||
|
autoUpdater.once("update-available", onAvailable)
|
||||||
|
autoUpdater.once("error", onError)
|
||||||
|
}
|
||||||
|
autoUpdater.checkForUpdates().catch((err) => {
|
||||||
|
console.error("checkForUpdates failed:", err)
|
||||||
|
isChecking = false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
checkGitHubRelease(manual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize auto-updater. Call once after createWindow().
|
||||||
|
*/
|
||||||
|
export function initAutoUpdater() {
|
||||||
|
if (!app.isPackaged) return
|
||||||
|
|
||||||
|
if (supportsAutoUpdate()) {
|
||||||
|
setupAutoUpdater()
|
||||||
|
} else {
|
||||||
|
// Register no-op handler so renderer doesn't get an unhandled error
|
||||||
|
ipcMain.handle("updater:start-download", () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check after startup delay
|
||||||
|
setTimeout(() => doCheck(false), STARTUP_DELAY)
|
||||||
|
|
||||||
|
// Periodic checks
|
||||||
|
setInterval(() => doCheck(false), CHECK_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual update check from menu item
|
||||||
|
*/
|
||||||
|
export function checkForUpdatesManual() {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
dialog.showMessageBox({
|
||||||
|
type: "info",
|
||||||
|
title: "Development Mode",
|
||||||
|
message: "Auto-update is not available in development mode.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doCheck(true)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
|
import { initAutoUpdater } from "./auto-updater"
|
||||||
import { getCurrentPresetEnv } from "./config-manager"
|
import { getCurrentPresetEnv } from "./config-manager"
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
@@ -58,6 +59,11 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
// Create main window
|
// Create main window
|
||||||
createWindow(serverUrl)
|
createWindow(serverUrl)
|
||||||
|
|
||||||
|
// Initialize auto-updater (production only)
|
||||||
|
if (!isDev) {
|
||||||
|
initAutoUpdater()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start application:", error)
|
console.error("Failed to start application:", error)
|
||||||
dialog.showErrorBox(
|
dialog.showErrorBox(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface MenuTranslations {
|
|||||||
help: string
|
help: string
|
||||||
documentation: string
|
documentation: string
|
||||||
reportIssue: string
|
reportIssue: string
|
||||||
|
checkForUpdates: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||||
@@ -62,6 +63,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
help: "Help",
|
help: "Help",
|
||||||
documentation: "Documentation",
|
documentation: "Documentation",
|
||||||
reportIssue: "Report Issue",
|
reportIssue: "Report Issue",
|
||||||
|
checkForUpdates: "Check for Updates...",
|
||||||
},
|
},
|
||||||
|
|
||||||
zh: {
|
zh: {
|
||||||
@@ -90,6 +92,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
help: "帮助",
|
help: "帮助",
|
||||||
documentation: "文档",
|
documentation: "文档",
|
||||||
reportIssue: "报告问题",
|
reportIssue: "报告问题",
|
||||||
|
checkForUpdates: "检查更新...",
|
||||||
},
|
},
|
||||||
|
|
||||||
ja: {
|
ja: {
|
||||||
@@ -118,6 +121,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
help: "ヘルプ",
|
help: "ヘルプ",
|
||||||
documentation: "ドキュメント",
|
documentation: "ドキュメント",
|
||||||
reportIssue: "問題を報告",
|
reportIssue: "問題を報告",
|
||||||
|
checkForUpdates: "アップデートを確認...",
|
||||||
},
|
},
|
||||||
|
|
||||||
"zh-Hant": {
|
"zh-Hant": {
|
||||||
@@ -146,6 +150,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
help: "說明",
|
help: "說明",
|
||||||
documentation: "文件",
|
documentation: "文件",
|
||||||
reportIssue: "回報問題",
|
reportIssue: "回報問題",
|
||||||
|
checkForUpdates: "檢查更新...",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron"
|
import { contextBridge, type IpcRendererEvent, ipcRenderer } from "electron"
|
||||||
|
|
||||||
|
// Locally defined to avoid dependency on electron.d.ts compilation scope
|
||||||
|
type UpdateStatus =
|
||||||
|
| { status: "available"; version: string }
|
||||||
|
| { status: "available-manual"; version: string; url: string }
|
||||||
|
| { status: "downloading"; percent: number }
|
||||||
|
| { status: "downloaded" }
|
||||||
|
| { status: "error"; message: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expose safe APIs to the renderer process
|
* Expose safe APIs to the renderer process
|
||||||
@@ -31,4 +39,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||||
setUserLocale: (locale: string) =>
|
setUserLocale: (locale: string) =>
|
||||||
ipcRenderer.invoke("set-user-locale", locale),
|
ipcRenderer.invoke("set-user-locale", locale),
|
||||||
|
|
||||||
|
// Auto-update
|
||||||
|
onUpdateStatus: (callback: (data: UpdateStatus) => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, data: UpdateStatus) =>
|
||||||
|
callback(data)
|
||||||
|
ipcRenderer.on("update-status", handler)
|
||||||
|
return () => ipcRenderer.removeListener("update-status", handler)
|
||||||
|
},
|
||||||
|
startDownload: () => ipcRenderer.invoke("updater:start-download"),
|
||||||
})
|
})
|
||||||
|
|||||||
86
hooks/use-auto-update.ts
Normal file
86
hooks/use-auto-update.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createElement, useEffect, useRef } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { UpdateToast } from "@/components/update-toast"
|
||||||
|
|
||||||
|
export function useAutoUpdate() {
|
||||||
|
const downloadToastId = useRef<string | number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = (window as Window).electronAPI
|
||||||
|
if (!api?.onUpdateStatus) return
|
||||||
|
|
||||||
|
const cleanup = api.onUpdateStatus((data) => {
|
||||||
|
switch (data.status) {
|
||||||
|
case "available":
|
||||||
|
toast.custom(
|
||||||
|
(t) =>
|
||||||
|
createElement(UpdateToast, {
|
||||||
|
variant: "download",
|
||||||
|
version: data.version,
|
||||||
|
onDownload: () => {
|
||||||
|
toast.dismiss(t)
|
||||||
|
api.startDownload().catch(() => {
|
||||||
|
// Error will come through update-status channel
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDismiss: () => toast.dismiss(t),
|
||||||
|
}),
|
||||||
|
{ duration: 15000 },
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
case "available-manual":
|
||||||
|
toast.custom(
|
||||||
|
(t) =>
|
||||||
|
createElement(UpdateToast, {
|
||||||
|
variant: "manual",
|
||||||
|
version: data.version,
|
||||||
|
url: data.url,
|
||||||
|
onDismiss: () => toast.dismiss(t),
|
||||||
|
}),
|
||||||
|
{ duration: Number.POSITIVE_INFINITY },
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
case "downloading": {
|
||||||
|
const id =
|
||||||
|
downloadToastId.current ??
|
||||||
|
`download-progress-${Date.now()}`
|
||||||
|
downloadToastId.current = id
|
||||||
|
toast.custom(
|
||||||
|
(t) =>
|
||||||
|
createElement(UpdateToast, {
|
||||||
|
variant: "downloading",
|
||||||
|
percent: data.percent,
|
||||||
|
onDismiss: () => toast.dismiss(t),
|
||||||
|
}),
|
||||||
|
{ id, duration: Number.POSITIVE_INFINITY },
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "downloaded":
|
||||||
|
// Dismiss progress toast — native restart dialog handles the rest
|
||||||
|
if (downloadToastId.current) {
|
||||||
|
toast.dismiss(downloadToastId.current)
|
||||||
|
downloadToastId.current = null
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
if (downloadToastId.current) {
|
||||||
|
// Error during active download — inform the user
|
||||||
|
toast.dismiss(downloadToastId.current)
|
||||||
|
downloadToastId.current = null
|
||||||
|
toast.error(
|
||||||
|
"Update download failed. Please try again later.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Otherwise: silent — background check failure, logged in main process
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanup
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -129,7 +129,12 @@
|
|||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"customSystemMessage": "Custom System Message",
|
"customSystemMessage": "Custom System Message",
|
||||||
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
||||||
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams..."
|
"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"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "Save Diagram",
|
"title": "Save Diagram",
|
||||||
@@ -140,7 +145,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG Image",
|
"png": "PNG Image",
|
||||||
"svg": "SVG Image"
|
"svg": "SVG Image",
|
||||||
|
"xmlsvg": "Editable SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "Saved successfully!"
|
"savedSuccessfully": "Saved successfully!"
|
||||||
},
|
},
|
||||||
@@ -265,6 +271,52 @@
|
|||||||
"searchPlaceholder": "Search chats...",
|
"searchPlaceholder": "Search chats...",
|
||||||
"noResults": "No chats found"
|
"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": {
|
"validation": {
|
||||||
"title": "Validate Diagram",
|
"title": "Validate Diagram",
|
||||||
"capturing": "Capturing",
|
"capturing": "Capturing",
|
||||||
|
|||||||
@@ -129,7 +129,12 @@
|
|||||||
"disabled": "無効",
|
"disabled": "無効",
|
||||||
"customSystemMessage": "カスタムシステムメッセージ",
|
"customSystemMessage": "カスタムシステムメッセージ",
|
||||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用..."
|
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||||
|
"panelVisibility": "ロビーパネル",
|
||||||
|
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||||
|
"showRecentChats": "最近のチャット",
|
||||||
|
"showMyTemplates": "マイテンプレート",
|
||||||
|
"showQuickExamples": "クイック例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "ダイアグラムを保存",
|
"title": "ダイアグラムを保存",
|
||||||
@@ -140,7 +145,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 画像",
|
"png": "PNG 画像",
|
||||||
"svg": "SVG 画像"
|
"svg": "SVG 画像",
|
||||||
|
"xmlsvg": "編集可能 SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "保存完了!"
|
"savedSuccessfully": "保存完了!"
|
||||||
},
|
},
|
||||||
@@ -350,5 +356,51 @@
|
|||||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||||
"serverDefaultModel": "サーバーデフォルトモデル"
|
"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,7 +129,12 @@
|
|||||||
"disabled": "已停用",
|
"disabled": "已停用",
|
||||||
"customSystemMessage": "自訂系統訊息",
|
"customSystemMessage": "自訂系統訊息",
|
||||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案..."
|
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||||
|
"panelVisibility": "大廳面板",
|
||||||
|
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||||
|
"showRecentChats": "最近聊天",
|
||||||
|
"showMyTemplates": "我的範本",
|
||||||
|
"showQuickExamples": "快速範例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "儲存圖表",
|
"title": "儲存圖表",
|
||||||
@@ -140,7 +145,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 圖片",
|
"png": "PNG 圖片",
|
||||||
"svg": "SVG 圖片"
|
"svg": "SVG 圖片",
|
||||||
|
"xmlsvg": "可編輯 SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "儲存成功!"
|
"savedSuccessfully": "儲存成功!"
|
||||||
},
|
},
|
||||||
@@ -265,6 +271,52 @@
|
|||||||
"searchPlaceholder": "搜尋對話...",
|
"searchPlaceholder": "搜尋對話...",
|
||||||
"noResults": "未找到對話"
|
"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": {
|
"validation": {
|
||||||
"title": "驗證圖表",
|
"title": "驗證圖表",
|
||||||
"capturing": "截圖中",
|
"capturing": "截圖中",
|
||||||
|
|||||||
@@ -129,7 +129,12 @@
|
|||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"customSystemMessage": "自定义系统消息",
|
"customSystemMessage": "自定义系统消息",
|
||||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案..."
|
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||||
|
"panelVisibility": "大厅面板",
|
||||||
|
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||||
|
"showRecentChats": "最近聊天",
|
||||||
|
"showMyTemplates": "我的模板",
|
||||||
|
"showQuickExamples": "快速示例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "保存图表",
|
"title": "保存图表",
|
||||||
@@ -140,7 +145,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 图片",
|
"png": "PNG 图片",
|
||||||
"svg": "SVG 图片"
|
"svg": "SVG 图片",
|
||||||
|
"xmlsvg": "可编辑 SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "保存成功!"
|
"savedSuccessfully": "保存成功!"
|
||||||
},
|
},
|
||||||
@@ -265,6 +271,52 @@
|
|||||||
"searchPlaceholder": "搜索对话...",
|
"searchPlaceholder": "搜索对话...",
|
||||||
"noResults": "未找到对话"
|
"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": {
|
"validation": {
|
||||||
"title": "验证图表",
|
"title": "验证图表",
|
||||||
"capturing": "截图中",
|
"capturing": "截图中",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||||
import { nanoid } from "nanoid"
|
import { nanoid } from "nanoid"
|
||||||
|
import type { Template } from "./template-storage"
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const DB_NAME = "next-ai-drawio"
|
const DB_NAME = "next-ai-drawio"
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 2
|
||||||
const STORE_NAME = "sessions"
|
const STORE_NAME = "sessions"
|
||||||
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
||||||
const MAX_SESSIONS = 50
|
const MAX_SESSIONS = 50
|
||||||
@@ -43,6 +44,16 @@ interface ChatSessionDB extends DBSchema {
|
|||||||
value: ChatSession
|
value: ChatSession
|
||||||
indexes: { "by-updated": number }
|
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
|
// Database singleton
|
||||||
@@ -85,7 +96,24 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
|||||||
})
|
})
|
||||||
store.createIndex("by-updated", "updatedAt")
|
store.createIndex("by-updated", "updatedAt")
|
||||||
}
|
}
|
||||||
// Future migrations: if (oldVersion < 2) { ... }
|
// 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() {
|
terminated() {
|
||||||
resetDBPromise()
|
resetDBPromise()
|
||||||
|
|||||||
@@ -30,4 +30,9 @@ export const STORAGE_KEYS = {
|
|||||||
|
|
||||||
// Custom system message
|
// Custom system message
|
||||||
customSystemMessage: "next-ai-draw-io-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
|
} as const
|
||||||
|
|||||||
437
lib/template-storage.ts
Normal file
437
lib/template-storage.ts
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
2946
package-lock.json
generated
2946
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@
|
|||||||
"@opennextjs/cloudflare": "^1.17.1",
|
"@opennextjs/cloudflare": "^1.17.1",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -70,9 +70,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"electron-updater": "^6.8.3",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.577.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.4.4",
|
"@biomejs/biome": "2.4.10",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"eslint": "9.39.3",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"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": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.27.1",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.9",
|
"@hono/node-server": "^1.19.9",
|
||||||
@@ -521,9 +521,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.11.0",
|
"version": "24.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||||
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
134
scripts/fix-latest-yml.mjs
Normal file
134
scripts/fix-latest-yml.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Fix latest.yml metadata after Windows code signing.
|
||||||
|
*
|
||||||
|
* electron-builder generates latest.yml with hashes of unsigned executables.
|
||||||
|
* After SignPath signs them, the hashes change. This script reads each
|
||||||
|
* latest*.yml in release/, finds the matching signed exe in release-signed/,
|
||||||
|
* and rewrites the sha512 and size fields.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/fix-latest-yml.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import {
|
||||||
|
createReadStream,
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
const RELEASE_DIR = "release"
|
||||||
|
const SIGNED_DIR = "release-signed"
|
||||||
|
|
||||||
|
// Verify directories exist
|
||||||
|
if (!existsSync(RELEASE_DIR)) {
|
||||||
|
console.error(`Error: ${RELEASE_DIR}/ directory does not exist`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!existsSync(SIGNED_DIR)) {
|
||||||
|
console.error(`Error: ${SIGNED_DIR}/ directory does not exist`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all latest*.yml files
|
||||||
|
const ymlFiles = readdirSync(RELEASE_DIR).filter(
|
||||||
|
(f) => f.startsWith("latest") && f.endsWith(".yml"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (ymlFiles.length === 0) {
|
||||||
|
console.log("No latest*.yml files found in release/")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute SHA-512 hash of a file using streaming (avoids loading large exe into memory)
|
||||||
|
*/
|
||||||
|
function hashFile(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = createHash("sha512")
|
||||||
|
const stream = createReadStream(filePath)
|
||||||
|
stream.on("data", (chunk) => hash.update(chunk))
|
||||||
|
stream.on("end", () => resolve(hash.digest("base64")))
|
||||||
|
stream.on("error", reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of signed exe filenames to their hash and size
|
||||||
|
const signedFiles = new Map()
|
||||||
|
for (const f of readdirSync(SIGNED_DIR)) {
|
||||||
|
if (!f.endsWith(".exe")) continue
|
||||||
|
const filePath = join(SIGNED_DIR, f)
|
||||||
|
const sha512 = await hashFile(filePath)
|
||||||
|
const size = statSync(filePath).size
|
||||||
|
signedFiles.set(f, { sha512, size })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedFiles.size === 0) {
|
||||||
|
console.error("No signed .exe files found in release-signed/")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Found ${signedFiles.size} signed exe(s):`,
|
||||||
|
[...signedFiles.keys()].join(", "),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const ymlFile of ymlFiles) {
|
||||||
|
const ymlPath = join(RELEASE_DIR, ymlFile)
|
||||||
|
const content = readFileSync(ymlPath, "utf-8")
|
||||||
|
const lines = content.split("\n")
|
||||||
|
const outputLines = []
|
||||||
|
|
||||||
|
// Track the current file entry being processed (by url field)
|
||||||
|
let currentExeName = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Match " url: SomeFile.exe" inside the files array
|
||||||
|
const urlMatch = line.match(/^\s+url:\s+(.+\.exe)\s*$/)
|
||||||
|
if (urlMatch) {
|
||||||
|
currentExeName = urlMatch[1]
|
||||||
|
outputLines.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match top-level "path: SomeFile.exe"
|
||||||
|
const pathMatch = line.match(/^path:\s+(.+\.exe)\s*$/)
|
||||||
|
if (pathMatch) {
|
||||||
|
currentExeName = pathMatch[1]
|
||||||
|
outputLines.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace sha512 lines
|
||||||
|
const sha512Match = line.match(/^(\s*)sha512:\s+/)
|
||||||
|
if (sha512Match && currentExeName && signedFiles.has(currentExeName)) {
|
||||||
|
const indent = sha512Match[1]
|
||||||
|
outputLines.push(
|
||||||
|
`${indent}sha512: ${signedFiles.get(currentExeName).sha512}`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace size lines
|
||||||
|
const sizeMatch = line.match(/^(\s*)size:\s+\d+/)
|
||||||
|
if (sizeMatch && currentExeName && signedFiles.has(currentExeName)) {
|
||||||
|
const indent = sizeMatch[1]
|
||||||
|
outputLines.push(
|
||||||
|
`${indent}size: ${signedFiles.get(currentExeName).size}`,
|
||||||
|
)
|
||||||
|
// Reset current exe after processing both sha512 and size
|
||||||
|
currentExeName = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
outputLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(ymlPath, outputLines.join("\n"))
|
||||||
|
console.log(`Updated ${ymlFile}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done!")
|
||||||
269
tests/unit/template-storage.test.ts
Normal file
269
tests/unit/template-storage.test.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
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