feat: move History and Download buttons to Settings dialog for cleaner chat interface (#442)

* fix: move History and Download buttons to Settings dialog for cleaner chat interface

* fix: cleanup unused imports/props, add i18n for diagram style

* fix: use npx directly to avoid package-lock.json changes in CI

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
This commit is contained in:
Biki Kalita
2025-12-28 18:46:10 +05:30
committed by GitHub
parent 1527883360
commit 226c336671
7 changed files with 307 additions and 329 deletions

View File

@@ -22,11 +22,8 @@ jobs:
with:
node-version: '24'
- name: Install Biome
run: npm install --save-dev @biomejs/biome
- name: Run Biome format
run: npx @biomejs/biome check --write --no-errors-on-unmatched .
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes

View File

@@ -17,14 +17,9 @@ import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
@@ -152,12 +147,9 @@ interface ChatInputProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
showHistory?: boolean
onToggleHistory?: (show: boolean) => void
sessionId?: string
error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
@@ -175,12 +167,8 @@ export function ChatInput({
files = [],
onFileChange = () => {},
pdfData = new Map(),
showHistory = false,
onToggleHistory = () => {},
sessionId,
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
models = [],
selectedModelId,
onModelSelect = () => {},
@@ -188,16 +176,14 @@ export function ChatInput({
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -385,99 +371,58 @@ export function ChatInput({
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={onToggleHistory}
/>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
className="scale-75"
/>
<label
htmlFor="minimal-style"
className={`text-xs cursor-pointer select-none ${
minimalStyle
? "text-primary font-medium"
: "text-muted-foreground"
}`}
>
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{dict.chat.minimalTooltip}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1 overflow-hidden justify-end">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
@@ -486,9 +431,7 @@ export function ChatInput({
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
@@ -510,6 +453,20 @@ export function ChatInput({
</div>
</div>
</div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
</form>
)
}

View File

@@ -3,29 +3,21 @@
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import {
AlertTriangle,
MessageSquarePlus,
PanelRightClose,
PanelRightOpen,
Settings,
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa"
import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary"
@@ -154,7 +146,6 @@ export default function ChatPanel({
// File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -247,182 +238,178 @@ export default function ChatPanel({
onExport,
})
const {
messages,
sendMessage,
addToolOutput,
stop,
status,
error,
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
try {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
return
}
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
return
}
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
const { messages, sendMessage, addToolOutput, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
try {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
})
}
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (friendlyMessage.includes("image content block")) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
return
}
if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
toast.error(
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
)
quotaManager.showTPMLimitToast()
return
}
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
}
})
}
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage =
"Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (friendlyMessage.includes("image content block")) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [
{ type: "text" as const, text: friendlyMessage },
],
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
}
return true
},
})
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
toast.error(
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
)
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
}
return true
},
})
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
@@ -959,18 +946,6 @@ export default function ChatPanel({
Next AI Drawio
</h1>
</div>
{!isMobile &&
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
)}
</div>
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
@@ -984,23 +959,6 @@ export default function ChatPanel({
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
</TooltipTrigger>
<TooltipContent>{dict.nav.github}</TooltipContent>
</Tooltip>
<ButtonWithTooltip
tooltipContent={dict.nav.settings}
@@ -1066,12 +1024,8 @@ export default function ChatPanel({
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId}
@@ -1088,6 +1042,8 @@ export default function ChatPanel({
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
/>
<ModelConfigDialog

View File

@@ -1,6 +1,6 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
@@ -24,7 +24,6 @@ import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
import { cn } from "@/lib/utils"
// Reusable setting item component for consistent layout
function SettingItem({
@@ -65,6 +64,8 @@ interface SettingsDialogProps {
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -86,6 +87,8 @@ function SettingsContent({
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
@@ -348,14 +351,61 @@ function SettingsContent({
}}
/>
</SettingItem>
{/* Diagram Style */}
<SettingItem
label={dict.settings.diagramStyle}
description={dict.settings.diagramStyleDescription}
>
<div className="flex items-center gap-2">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
/>
<span className="text-sm text-muted-foreground">
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</span>
</div>
</SettingItem>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
<p className="text-xs text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
<div className="flex items-center justify-center gap-3">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Tag className="h-3 w-3" />
{process.env.APP_VERSION}
</span>
<span className="text-muted-foreground">·</span>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Github className="h-3 w-3" />
GitHub
</a>
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<>
<span className="text-muted-foreground">·</span>
<a
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Info className="h-3 w-3" />
About
</a>
</>
)}
</div>
</div>
</DialogContent>
)

View File

@@ -98,7 +98,13 @@
"minimal": "Minimal",
"sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page."
"closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download"
},
"save": {
"title": "Save Diagram",

View File

@@ -98,7 +98,13 @@
"minimal": "ミニマル",
"sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。"
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード"
},
"save": {
"title": "ダイアグラムを保存",

View File

@@ -98,7 +98,13 @@
"minimal": "简约",
"sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。"
"closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录",
"download": "下载"
},
"save": {
"title": "保存图表",