feat(ui): canvas-first redesign with a self-hosted draw.io (#957)

The canvas now fills the window and the chat floats on its right. draw.io is
served from our own origin (public/drawio, downloaded at build time and
checked against the release's SHA-256), so the app can drive the editor
directly:

- every AI change becomes a version card with a thumbnail; compare, restore
  or undo it, and Ctrl+Z on the canvas takes an AI change back in one step
- shapes the AI just changed are highlighted on the canvas
- selected shapes appear as a chip and are sent to the model
- draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV
  import) and page tabs; the shape library starts closed
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- Bedrock in the model settings also takes an API key instead of access keys
- phones switch between canvas and chat
- dark mode uses draw.io's own dark mode, with neutral greys around it

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the features above
that need same origin are off.

The chat logic moved from the 1500-line chat panel into a chat engine plus
small UI components; zustand holds canvas, versions, settings and UI state.
This commit is contained in:
Dayuan Jiang
2026-10-08 16:20:15 +09:00
committed by GitHub
parent 18a20aa1ca
commit c513d1a0c2
110 changed files with 17891 additions and 6944 deletions
+37 -19
View File
@@ -911,7 +911,9 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
switch (provider) {
case "bedrock": {
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
// Use client-provided credentials if available (a Bedrock API
// key, or an access key pair), otherwise fall back to IAM/env vars
const clientApiKey = overrides?.apiKey
const hasClientCredentials =
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
// Keys from the admin panel. The ADMIN_ names keep them out of the
@@ -937,33 +939,46 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
process.env.AWS_REGION ||
"us-west-2"
const bedrockProvider = hasClientCredentials
const bedrockProvider = clientApiKey
? createAmazonBedrock({
region: bedrockRegion,
accessKeyId: overrides.awsAccessKeyId as string,
secretAccessKey: overrides.awsSecretAccessKey as string,
...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken,
}),
// Without an apiKey the SDK reads the server's
// AWS_BEARER_TOKEN_BEDROCK, which wins over the keys
apiKey: "",
apiKey: clientApiKey,
// The SDK falls back to signing with AWS keys when it
// drops a key (one of spaces): never the server's
credentialProvider: async () => {
throw new Error("The Bedrock API key is empty")
},
// Without a baseURL it reads the server's
// AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL
baseURL: bedrockRuntimeUrl(bedrockRegion),
})
: adminAccessKeyId && adminSecretAccessKey
: hasClientCredentials
? createAmazonBedrock({
region: bedrockRegion,
accessKeyId: adminAccessKeyId,
secretAccessKey: adminSecretAccessKey,
// The keys the admin panel's Test button checked
accessKeyId: overrides.awsAccessKeyId as string,
secretAccessKey: overrides.awsSecretAccessKey as string,
...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken,
}),
// Without an apiKey the SDK reads the server's
// AWS_BEARER_TOKEN_BEDROCK, which wins over the keys
apiKey: "",
// Without a baseURL it reads the server's
// AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL
baseURL: bedrockRuntimeUrl(bedrockRegion),
})
: createAmazonBedrock({
region: bedrockRegion,
credentialProvider: fromNodeProviderChain(),
})
: adminAccessKeyId && adminSecretAccessKey
? createAmazonBedrock({
region: bedrockRegion,
accessKeyId: adminAccessKeyId,
secretAccessKey: adminSecretAccessKey,
// The keys the admin panel's Test button checked
apiKey: "",
})
: createAmazonBedrock({
region: bedrockRegion,
credentialProvider: fromNodeProviderChain(),
})
model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) {
@@ -1194,7 +1209,10 @@ export function usesServerCredentials(
const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "")
switch (provider) {
case "bedrock":
return !(overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey)
return !(
overrides?.apiKey ||
(overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey)
)
case "vertexai":
return !overrides?.vertexApiKey
case "edgeone":
+34
View File
@@ -133,3 +133,37 @@ export function fixToolInputJson(input: string): string {
.replace(/"(\w+)"\s*=\s*"/g, '"$1": "')
)
}
// Shapes the user selected on the canvas; sent with the request as
// selectedCells: [{ id, label }]
const MAX_SELECTED_CELLS = 50
const MAX_SELECTED_TEXT = 100
function cleanSelectionText(value: unknown): string {
if (typeof value !== "string") return ""
return value
.replace(/[\r\n"]+/g, " ")
.trim()
.slice(0, MAX_SELECTED_TEXT)
}
/** Prompt text describing the selection, or "" when there is none */
export function formatSelectionContext(selectedCells: unknown): string {
if (!Array.isArray(selectedCells)) return ""
const lines = selectedCells
.slice(0, MAX_SELECTED_CELLS)
.map((cell) => {
const id = cleanSelectionText(cell?.id)
if (!id) return null
const label = cleanSelectionText(cell?.label)
return label ? `- id="${id}" (${label})` : `- id="${id}"`
})
.filter((line): line is string => line !== null)
if (lines.length === 0) return ""
// Past the limit the model must know the list is cut short
const more = selectedCells.length - MAX_SELECTED_CELLS
if (more > 0) lines.push(`- and ${more} more selected shapes not listed`)
return `The user selected these shapes on the canvas before sending this message:
${lines.join("\n")}
Apply the request to the selected shapes unless it clearly asks for something else. Prefer edit_diagram with these cell ids over redrawing the whole diagram.`
}
+37
View File
@@ -0,0 +1,37 @@
import type { getSelectedAIConfig } from "@/hooks/use-model-config"
type AIConfig = ReturnType<typeof getSelectedAIConfig>
/**
* Request headers for /api/chat: the access code, the user's own provider
* credentials (when a client-side model is selected) and per-request options.
*/
export function buildChatHeaders(
config: AIConfig,
options: { minimalStyle: boolean; maxOutputTokens: string },
): Record<string, string> {
const headers: Record<string, string> = {
"x-access-code": config.accessCode,
}
const set = (name: string, value: string | undefined) => {
if (value) headers[name] = value
}
if (config.aiProvider) {
set("x-ai-provider", config.aiProvider)
set("x-ai-base-url", config.aiBaseUrl)
set("x-ai-api-key", config.aiApiKey)
set("x-ai-model", config.aiModel)
// AWS Bedrock credentials
set("x-aws-access-key-id", config.awsAccessKeyId)
set("x-aws-secret-access-key", config.awsSecretAccessKey)
set("x-aws-region", config.awsRegion)
set("x-aws-session-token", config.awsSessionToken)
// Vertex AI credentials (Express Mode)
set("x-vertex-api-key", config.vertexApiKey)
}
// Selected model ID for server model lookup (apiKeyEnv/baseUrlEnv)
set("x-selected-model-id", config.selectedModelId)
if (options.minimalStyle) headers["x-minimal-style"] = "true"
set("x-max-output-tokens", options.maxOutputTokens)
return headers
}
+36
View File
@@ -0,0 +1,36 @@
import { useState } from "react"
/** Copy text, falling back to execCommand for non-secure contexts (HTTP) */
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
try {
textarea.select()
return document.execCommand("copy")
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
/** Copy text and show "copied" for a moment; onFail runs when it failed */
export function useCopy(onFail?: () => void) {
const [copied, setCopied] = useState(false)
const copy = async (text: string) => {
const ok = await copyText(text)
if (!ok) onFail?.()
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}
return { copied, copy }
}
+321
View File
@@ -0,0 +1,321 @@
import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts"
/** What changed on the first page between two versions of a diagram */
export interface ChangeSummary {
shapesAdded: number
shapesRemoved: number
shapesChanged: number
edgesAdded: number
edgesRemoved: number
edgesChanged: number
}
export interface DiagramDiff {
summary: ChangeSummary
/** Ids of cells that were added or changed (for highlighting) */
touchedIds: string[]
/** The diagram before had no shapes or connectors */
fromScratch: boolean
}
export const EMPTY_SUMMARY: ChangeSummary = {
shapesAdded: 0,
shapesRemoved: 0,
shapesChanged: 0,
edgesAdded: 0,
edgesRemoved: 0,
edgesChanged: 0,
}
interface CellInfo {
isEdge: boolean
signature: string
}
/** A page's mxGraphModel element, inflated when the page is compressed */
function modelOfPage(diagram: Element): Element | null {
const model = diagram.querySelector("mxGraphModel")
if (model) return model
const inflated = decompressPageContent(diagram.textContent || "")
if (!inflated) return null
const inner = new DOMParser().parseFromString(inflated, "text/xml")
return inner.querySelector("mxGraphModel")
}
function parse(xml: string): Document | null {
if (!xml?.trim()) return null
const doc = new DOMParser().parseFromString(xml, "text/xml")
return doc.querySelector("parsererror") ? null : doc
}
/** The first page's mxGraphModel element, whatever wrapper the XML has */
function firstPageModel(xml: string): Element | null {
const doc = parse(xml)
if (!doc) return null
const diagram = doc.querySelector("diagram")
if (diagram) return modelOfPage(diagram)
return doc.querySelector("mxGraphModel") ?? doc.documentElement
}
// Values draw.io leaves out when it saves: the model may write them
const DEFAULT_VALUES: Record<string, Record<string, string>> = {
mxCell: {
vertex: "0",
edge: "0",
connectable: "1",
visible: "1",
collapsed: "0",
style: "",
},
mxGeometry: { x: "0", y: "0", width: "0", height: "0", relative: "0" },
mxPoint: { x: "0", y: "0" },
}
// Numbers draw.io writes back in their shortest form ("40.0" is "40"), on
// the elements that hold a position or size (a wrapper's "x" is the
// user's own data, shown as written)
const NUMBERS = new Set(["x", "y", "width", "height"])
const GEOMETRY = new Set(["mxGeometry", "mxPoint", "mxRectangle"])
/**
* A style as draw.io reads it (mxStylesheet.getCellStyle): entries apply in
* order and empty ones do nothing; a leading ";" leaves the default out
*/
function normalStyle(style: string): string {
const entries = style.split(";").filter((entry) => entry.trim() !== "")
return (style.startsWith(";") ? ";" : "") + entries.join(";")
}
/**
* What a cell is, independent of how the XML was written: the same cell
* saved by draw.io and written by the model can order attributes
* differently, and draw.io drops values that are the default. Style
* entries keep their order, which counts.
*/
function signatureOf(node: Element): string {
const defaults = DEFAULT_VALUES[node.tagName] ?? {}
const attrs = Array.from(node.attributes)
.flatMap((a) => {
let value = a.value
// A cell's style (a wrapper's "style" is the user's own data)
if (a.name === "style" && node.tagName === "mxCell") {
value = normalStyle(value)
} else if (
GEOMETRY.has(node.tagName) &&
NUMBERS.has(a.name) &&
value.trim() !== ""
) {
const n = Number(value)
if (Number.isFinite(n)) value = String(n)
}
if (defaults[a.name] === value) return []
return [[a.name, value]]
})
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
// As JSON: a value can hold any text, also "name=value" pairs
return JSON.stringify([
node.tagName,
attrs,
Array.from(node.children).map(signatureOf),
])
}
/** The cells of a page, layers included, with the element that holds each */
function cellsIn(model: Element | null) {
if (!model) return []
return Array.from(model.getElementsByTagName("mxCell")).flatMap((cell) => {
// Cells with links or custom data are wrapped; the wrapper holds the id
const wrapper = cell.parentElement
const isWrapped =
wrapper?.tagName === "object" || wrapper?.tagName === "UserObject"
const id = cell.getAttribute("id") || wrapper?.getAttribute("id")
if (!id) return []
return [{ id, cell, node: isWrapped && wrapper ? wrapper : cell }]
})
}
/** Shapes and connectors of the first page (not the root and its layers) */
function collectCells(xml: string): Map<string, CellInfo> {
const cells = new Map<string, CellInfo>()
for (const { id, cell, node } of cellsIn(firstPageModel(xml))) {
if (id === "0" || cell.getAttribute("parent") === "0") continue
cells.set(id, {
isEdge: cell.getAttribute("edge") === "1",
signature: signatureOf(node),
})
}
return cells
}
export function diffDiagrams(beforeXml: string, afterXml: string): DiagramDiff {
const before = collectCells(beforeXml)
const after = collectCells(afterXml)
const summary: ChangeSummary = { ...EMPTY_SUMMARY }
const touchedIds: string[] = []
for (const [id, cell] of after) {
const old = before.get(id)
if (!old) {
touchedIds.push(id)
if (cell.isEdge) summary.edgesAdded++
else summary.shapesAdded++
} else if (old.signature !== cell.signature) {
touchedIds.push(id)
if (cell.isEdge) summary.edgesChanged++
else summary.shapesChanged++
}
}
for (const [id, cell] of before) {
if (after.has(id)) continue
if (cell.isEdge) summary.edgesRemoved++
else summary.shapesRemoved++
}
return { summary, touchedIds, fromScratch: before.size === 0 }
}
// Where the view was scrolled to, saved with the page; not a change
const VIEW_ATTRIBUTES = new Set(["dx", "dy"])
// A page setting written on one side only is draw.io's default being
// filled in when the other side has that default; an empty list: any value
// is a change. Settings not listed are not compared then, like the paper
// size: a page without it keeps the canvas's (draw.io's readGraphState).
const PAGE_DEFAULTS: Record<string, string[]> = {
background: ["none"],
backgroundImage: [],
extFonts: [],
math: ["0"],
shadow: ["0"],
page: ["1"],
pageScale: ["1"],
grid: ["1"],
gridSize: ["10"],
guides: ["1"],
tooltips: ["1"],
connect: ["1"],
arrows: ["1"],
fold: ["1"],
}
// Settings draw.io reads as numbers (Editor.readGraphState): "10.0" is 10
const PAGE_NUMBERS = new Set([
"gridSize",
"pageScale",
"pageWidth",
"pageHeight",
])
function samePageSettings(a: Element, b: Element): boolean {
const names = new Set(
[...Array.from(a.attributes), ...Array.from(b.attributes)].map(
(attr) => attr.name,
),
)
const read = (page: Element, name: string) => {
const value = page.getAttribute(name)
if (value === null || !PAGE_NUMBERS.has(name) || !value.trim()) {
return value
}
const n = Number(value)
return Number.isFinite(n) ? String(n) : value
}
for (const name of names) {
if (VIEW_ATTRIBUTES.has(name)) continue
const va = read(a, name)
const vb = read(b, name)
if (va !== null && vb !== null) {
if (va !== vb) return false
continue
}
const defaults = PAGE_DEFAULTS[name]
if (defaults && !defaults.includes((va ?? vb) as string)) return false
}
return true
}
/**
* Every cell of a page, layers included, and the order of each parent's
* children (which shape is in front)
*/
function contentOf(model: Element | null) {
const cells = new Map<string, string>()
const children = new Map<string, string[]>()
for (const { id, cell, node } of cellsIn(model)) {
cells.set(id, signatureOf(node))
const parent = cell.getAttribute("parent") ?? ""
children.set(parent, [...(children.get(parent) ?? []), id])
}
return { cells, children }
}
function sameContent(a: Element | null, b: Element | null): boolean {
const ca = contentOf(a)
const cb = contentOf(b)
if (ca.cells.size !== cb.cells.size) return false
for (const [id, signature] of ca.cells) {
if (cb.cells.get(id) !== signature) return false
}
for (const [parent, ids] of ca.children) {
if (cb.children.get(parent)?.join("\n") !== ids.join("\n")) {
return false
}
}
return true
}
/**
* File variables (%name% placeholders) as draw.io reads them, from JSON:
* the same names and values in any order; none is the same as "{}"
*/
export function sameFileVars(a: string | null, b: string | null): boolean {
if (a === b) return true
try {
// By name: names are unique, and a pair's text can look like another's
const sorted = (vars: string | null) =>
JSON.stringify(
Object.entries(JSON.parse(vars ?? "{}")).sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
),
)
return sorted(a) === sorted(b)
} catch {
return false
}
}
/**
* Whether two documents hold the same pages (names, cells, their order),
* page settings and file variables
*/
export function isSameDocument(a: string, b: string): boolean {
const pagesOf = (doc: Document) => {
const diagrams = Array.from(doc.getElementsByTagName("diagram"))
if (diagrams.length === 0) {
return [{ name: null, model: doc.querySelector("mxGraphModel") }]
}
return diagrams.map((d) => ({
name: d.getAttribute("name"),
model: modelOfPage(d),
}))
}
const docA = parse(a)
const docB = parse(b)
if (!docA || !docB) return a === b
const varsOf = (doc: Document) =>
doc.documentElement.nodeName === "mxfile"
? doc.documentElement.getAttribute("vars")
: null
if (!sameFileVars(varsOf(docA), varsOf(docB))) return false
const pagesA = pagesOf(docA)
const pagesB = pagesOf(docB)
if (pagesA.length !== pagesB.length) return false
return pagesA.every((pageA, i) => {
const pageB = pagesB[i]
if (pageA.name !== null && pageB.name !== null) {
if (pageA.name !== pageB.name) return false
}
if (pageA.model && pageB.model) {
if (!samePageSettings(pageA.model, pageB.model)) return false
}
return sameContent(pageA.model, pageB.model)
})
}
-17
View File
@@ -1,17 +0,0 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}
+99
View File
@@ -0,0 +1,99 @@
import { getAssetUrl } from "@/lib/base-path"
import type { Locale } from "@/lib/i18n/config"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
/** External draw.io configured at build time (cross-origin, limited features) */
const EXTERNAL_DRAWIO_URL = process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || ""
/**
* URL of the draw.io editor iframe. By default this is the copy bundled in
* public/drawio, served from our own origin.
*/
export function getDrawioSrc({
lang,
isElectron,
dark,
}: {
lang: Locale
isElectron: boolean
dark: boolean
}): string {
// index.html: Next.js does not serve directory indexes
const base =
EXTERNAL_DRAWIO_URL ||
`${window.location.origin}${getAssetUrl("/drawio/index.html")}`
const url = new URL(base)
const params: Record<string, string> = {
embed: "1",
proto: "json",
configure: "1",
ui: "simple",
spin: "0",
libraries: "0",
noSaveBtn: "1",
noExitBtn: "1",
saveAndExit: "0",
// draw.io's own dark mode (switched in place later when the editor
// can be driven directly)
dark: dark ? "1" : "0",
// draw.io names Traditional Chinese "zh-tw"
lang: lang === "zh-Hant" ? "zh-tw" : lang,
}
// No calls to external services from the desktop app
if (isElectron && !EXTERNAL_DRAWIO_URL) params.offline = "1"
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return url.toString()
}
/**
* Show the page (a white sheet with a grid) unless the diagram says
* otherwise. AI output usually has a bare <mxGraphModel>, and draw.io's
* simple UI would then show neither page nor grid.
*/
export function withPageDefaults(xml: string): string {
return xml.replace(
/<mxGraphModel\b([^>]*?)(\/?)>/g,
(_match, attrs, slash) => {
let next = attrs as string
if (!/\spage\s*=/.test(next)) next += ' page="1"'
if (!/\sgrid\s*=/.test(next)) next += ' grid="1"'
if (!/\sgridSize\s*=/.test(next)) next += ' gridSize="10"'
return `<mxGraphModel${next}${slash}>`
},
)
}
/** Empty document loaded when draw.io starts */
export const EMPTY_DRAWIO_DOCUMENT = withPageDefaults(BLANK_MXFILE)
/**
* Editor defaults sent with draw.io's configure message. The simple UI turns
* page view off for new diagrams; keep the white page on the canvas, as in
* the classic UI.
*/
export const DRAWIO_CONFIG = {
defaultPageVisible: true,
defaultGridEnabled: true,
// A loaded diagram leaves the focus where it is (draw.io focuses its
// window otherwise, and keys typed for the chat would go to the canvas)
noAutoFocus: true,
}
/**
* CSS injected into draw.io: the table around the sheet and its toolbar in
* light mode. Dark mode keeps draw.io's own colors: the rules only match
* while draw.io is light (it puts geDarkMode on the body), so they need no
* change when the theme switches.
*/
export const DRAWIO_CSS = `
body:not(.geDarkMode) {
--workspace-color: #f4f5f7;
--focus-color: #14181d;
}
body:not(.geDarkMode) .geDiagramContainer { background-color: #f4f5f7; }
body:not(.geDarkMode) .geBackgroundPage { box-shadow: 0 1px 2px rgba(20,24,29,.08), 0 12px 32px -12px rgba(20,24,29,.18) !important; }
body:not(.geDarkMode) .geSimpleMainMenu { border-bottom: 1px solid #e4e7eb !important; box-shadow: none !important; }
.geSidebarContainer[style*="width: 0px"] { border: none !important; }
`
+686
View File
@@ -0,0 +1,686 @@
/**
* Direct access to the draw.io editor running in our same-origin iframe.
*
* draw.io has no public JavaScript API for embedders; everything here uses
* its internal objects (EditorUi, mxGraph). Every call checks that what it
* needs exists, so a draw.io update that renames something turns the feature
* off (with a console warning) instead of breaking the page.
*
* With an external draw.io (cross-origin) none of this is available and the
* app uses the postMessage protocol only.
*/
import { sameFileVars } from "@/lib/diagram-diff"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
type EditorUi = any
type FrameWindow = any
let ui: EditorUi | null = null
let win: FrameWindow | null = null
let detachListeners: (() => void) | null = null
const warned = new Set<string>()
// Keyboard shortcuts of the app that must also work while focus is inside
// the draw.io iframe (keydown events there never reach our window)
let appShortcutHandler: ((event: KeyboardEvent) => boolean) | null = null
/** The handler returns true when it handled the key */
export function setAppShortcutHandler(
handler: ((event: KeyboardEvent) => boolean) | null,
) {
appShortcutHandler = handler
}
function warnOnce(key: string, message: string) {
if (warned.has(key)) return
warned.add(key)
console.warn(`[drawio] ${message}`)
}
export function attachEditor(editorUi: EditorUi, frameWindow: FrameWindow) {
if (ui === editorUi) return
detachEditor()
ui = editorUi
win = frameWindow
detachListeners = installListeners()
keepPanelsOnResize()
closeSidePanels()
softenGrid()
useCanvasStore.getState().set({ hasEditor: true })
syncAll()
}
// draw.io's simple UI opens or closes its format panel and shape library
// when its window crosses a width; the user opens them from its toolbar, so
// a resize (the chat panel sliding, a narrow window) must leave them alone.
// windowResized only toggles them when it knows the previous width.
function keepPanelsOnResize() {
const original = ui?.windowResized
if (typeof original !== "function" || original.naiWrapped) return
const wrapped = function (this: any, ...args: unknown[]) {
this.lastWindowWidth = null
return original.apply(this, args)
}
wrapped.naiWrapped = true
ui.windowResized = wrapped
}
// draw.io's grid (#e6e6e6 every 10 px) is busy behind pale shapes
function softenGrid() {
const view = graph()?.view
if (!view) return
try {
view.gridColor = "#eceef1"
view.validateBackground()
} catch {
// ignore
}
}
// draw.io opens its format panel and shape library on start, which leaves
// little room for the diagram next to the chat panel; its toolbar opens them
function closeSidePanels() {
try {
if (isFormatPanelOpen()) ui.actions?.get?.("format")?.funct()
} catch {
// ignore
}
try {
if (ui.sidebarWindow?.window?.isVisible?.()) {
ui.sidebarWindow.window.setVisible(false)
} else if (ui.isShapesPanelVisible?.()) {
// At once, as the end of draw.io's toggleShapesPanel does: its
// slide takes a moment, and a diagram loaded meanwhile is
// centered in the narrower canvas
ui.hsplitPosition = 0
ui.refresh()
ui.fireEvent?.(new win.mxEventObject("shapesPanelChanged"))
}
} catch {
// ignore
}
}
export function detachEditor() {
detachListeners?.()
detachListeners = null
clearHighlights()
ui = null
win = null
previewBase = null
useCanvasStore.getState().set({
hasEditor: false,
selection: [],
selectionRect: null,
isDrawioPopupOpen: false,
})
}
function graph() {
return ui?.editor?.graph ?? null
}
// ---------------------------------------------------------------------------
// Replacing the diagram (AI changes)
// ---------------------------------------------------------------------------
// Diagram before the current AI turn started streaming. Previews are applied
// without undo history; the final result replaces this base as one undo step.
let previewBase: string | null = null
function currentFileXml(): string | null {
try {
const node = ui.getXmlFileData(null, null, true)
return win.mxUtils.getXml(node)
} catch {
return null
}
}
function withoutUndo(fn: () => void) {
const manager = ui?.editor?.undoManager
if (!manager?.undoableEditHappened) {
fn()
return
}
const original = manager.undoableEditHappened
manager.undoableEditHappened = () => {}
try {
fn()
} finally {
manager.undoableEditHappened = original
}
}
/**
* replaceDiagramData replaces the current page with one <mxGraphModel>. Only
* single-page documents qualify; multi-page ones fall back to a full load.
*/
function toSinglePageModel(xml: string): string | null {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (doc.querySelector("parsererror")) return null
const root = doc.documentElement
if (root.nodeName === "mxGraphModel") return xml
if (root.nodeName !== "mxfile") return null
const diagrams = root.getElementsByTagName("diagram")
if (diagrams.length !== 1) return null
const model = diagrams[0].getElementsByTagName("mxGraphModel")[0]
if (model) return new XMLSerializer().serializeToString(model)
// Compressed page
try {
const text = diagrams[0].textContent?.trim()
const inflated = text ? win?.Graph?.decompress?.(text) : null
return typeof inflated === "string" &&
inflated.includes("<mxGraphModel")
? inflated
: null
} catch {
return null
}
}
function replace(xml: string) {
const model = toSinglePageModel(xml)
if (!model || typeof ui?.replaceDiagramData !== "function") {
throw new Error("Diagram can't be replaced in place")
}
ui.replaceDiagramData(model)
}
/** No shapes on any layer */
function isEmptyModel(): boolean {
const g = graph()
const root = g?.model.getRoot()
if (!root) return true
for (let i = 0; i < g.model.getChildCount(root); i++) {
if (g.model.getChildCount(g.model.getChildAt(root, i)) > 0) {
return false
}
}
return true
}
/** Whether a page has shapes; layers (cells under the root) are none */
function hasShapes(xml: string): boolean {
const model = toSinglePageModel(xml)
if (model === null) return hasCells(xml)
const cells = new DOMParser()
.parseFromString(model, "text/xml")
.getElementsByTagName("mxCell")
return Array.from(cells).some((cell) => {
const parent = cell.getAttribute("parent")
return parent !== null && parent !== "0"
})
}
// Page settings draw.io applies on a full load only: replacing the page in
// place (Editor.readGraphState) keeps the old ones. (Its adaptive colors and
// theme stay too: a diagram the AI writes does not set them.)
function hasLoadOnlySettings(model: string): boolean {
const page = new DOMParser().parseFromString(
model,
"text/xml",
).documentElement
return (
page.hasAttribute("backgroundImage") ||
page.hasAttribute("extFonts") ||
page.getAttribute("math") === "1" ||
page.getAttribute("shadow") === "1"
)
}
function canvasHasLoadOnlySettings(): boolean {
const g = graph()
return (
!!g &&
(!!g.backgroundImage ||
!!g.mathEnabled ||
!!g.shadowVisible ||
(g.extFonts?.length ?? 0) > 0)
)
}
/** The file variables (%name% placeholders) a document sets, if any */
function fileVars(xml: string): string | null {
const root = new DOMParser().parseFromString(
xml,
"text/xml",
).documentElement
return root?.nodeName === "mxfile" ? root.getAttribute("vars") : null
}
/** Can this diagram go through the editor (with undo) instead of a full load? */
export function canReplaceDiagram(xml: string): boolean {
if (!ui) return false
if (typeof ui.replaceDiagramData !== "function") {
warnOnce("replace", "replaceDiagramData not found, using full loads")
return false
}
const pageCount = Array.isArray(ui.pages) ? ui.pages.length : 1
if (pageCount > 1) return false
// Replacing the page keeps the file's variables: other ones, or none
// over a file with some, load in full
if (
!sameFileVars(
fileVars(xml),
ui.fileNode?.getAttribute?.("vars") ?? null,
)
) {
return false
}
const model = toSinglePageModel(xml)
// A document with them, or replacing one with them, loads in full
return (
model !== null &&
!hasLoadOnlySettings(model) &&
!canvasHasLoadOnlySettings()
)
}
/** Show a streaming preview without touching the undo history */
export function previewDiagram(xml: string) {
if (previewBase === null) previewBase = currentFileXml() ?? ""
const wasEmpty = isEmptyModel()
withoutUndo(() => replace(xml))
if (wasEmpty) fitDiagram()
}
/** Name and id of the page in a single-page mxfile, if it has them */
function pageOf(xml: string): { name: string | null; id: string | null } {
const doc = new DOMParser().parseFromString(xml, "text/xml")
const diagram =
doc.documentElement?.nodeName === "mxfile"
? doc.getElementsByTagName("diagram")[0]
: undefined
return {
name: diagram?.getAttribute("name") || null,
id: diagram?.getAttribute("id") || null,
}
}
/**
* A change of the page's id for draw.io's undo history (it has none of its
* own): execute swaps the ids, as draw.io's RenamePage does with names
*/
function changePageId(page: any, id: string) {
let other = id
return {
execute() {
const current = page.getId()
page.node.setAttribute("id", other)
other = current
},
}
}
/**
* Apply the final AI result (or a restored version) as a single undo step,
* page name and id included: the page gets the document's id, as a full
* load would give it (links to the page use it). The view is fitted only
* when the canvas was empty, so a restore keeps the user's zoom.
*/
export function commitDiagram(xml: string) {
const wasEmpty =
isEmptyModel() || (previewBase !== null && !hasShapes(previewBase))
const base = previewBase ? toSinglePageModel(previewBase) : null
previewBase = null
// Undo goes back to the diagram before streaming started. draw.io's
// ReplaceDiagram change keeps the document it replaced for undo: hand it
// that diagram, so the canvas changes once. (Putting it back on the
// canvas first would also send it to the app, after the result.)
const ReplaceDiagram = win?.ReplaceDiagram
const parse = (model: string) => win.mxUtils.parseXml(model).documentElement
const direct = typeof ReplaceDiagram === "function" && !!win?.mxUtils
if (base && !direct) withoutUndo(() => replace(base))
const model = graph()?.model
const { name, id } = pageOf(xml)
const page = ui?.currentPage
model?.beginUpdate()
try {
const next = toSinglePageModel(xml)
if (direct && next) {
const change = new ReplaceDiagram(ui, parse(next))
model.execute(change)
if (base) change.data = parse(base)
} else {
replace(xml)
}
if (name && page && win?.RenamePage && page.getName?.() !== name) {
model.execute(new win.RenamePage(ui, page, name))
}
if (id && page?.node && page.getId?.() !== id) {
model.execute(changePageId(page, id))
}
} finally {
model?.endUpdate()
}
if (wasEmpty) fitDiagram()
}
/** Throw away the preview and go back to the given diagram */
export function revertPreview(targetXml: string) {
previewBase = null
withoutUndo(() => replace(targetXml))
}
/** Forget the preview base (new user turn) without changing the canvas */
export function resetPreview() {
previewBase = null
}
// When the app last fitted the diagram on its own; a canvas resize right
// after (the chat panel sliding in) fits again, unless the user zoomed since
let lastAutoFitAt = 0
// True while fitDiagram runs: its own zoom change is not the user's
let fitting = false
/** The zoom changed: if the user did it, a later resize keeps their view */
function zoomChanged() {
if (!fitting) lastAutoFitAt = 0
}
/** Fit again if the last automatic fit just happened (layout still moving) */
export function refitIfRecent(windowMs = 1500) {
if (Date.now() - lastAutoFitAt < windowMs) {
fitDiagram()
return true
}
return false
}
// Room around a fitted diagram (half of this on each side)
const FIT_BORDER = 128
/** Fit the shapes (not the whole page) into the canvas, at most at 100% */
function fitDiagram() {
const g = graph()
if (!g) return
fitting = true
try {
const b = g.getGraphBounds()
const { scale, translate } = g.view
if (b && b.width > 0 && b.height > 0 && win?.mxRectangle) {
const model = new win.mxRectangle(
b.x / scale - translate.x,
b.y / scale - translate.y,
b.width / scale,
b.height / scale,
)
g.fitWindow(model, FIT_BORDER, 1)
} else if (ui?.actions?.get?.("fitWindow")) {
ui.actions.get("fitWindow").funct()
if (g.view.scale > 1) g.zoomTo(1, true)
}
} catch {
// ignore
} finally {
fitting = false
}
// After the zoom events of the fit itself
lastAutoFitAt = Date.now()
}
/** The canvas changed width: keep what was in the middle in the middle */
export function keepCenter(oldWidth: number, newWidth: number) {
const container = graph()?.container
if (!container || !oldWidth || !newWidth) return
container.scrollLeft += (oldWidth - newWidth) / 2
}
// ---------------------------------------------------------------------------
// Highlighting what the AI changed
// ---------------------------------------------------------------------------
let highlights: any[] = []
export function clearHighlights() {
for (const h of highlights) {
try {
h.destroy()
} catch {
// ignore
}
}
highlights = []
}
// Outline of changed shapes: dark enough to show on white paper and on
// draw.io's pale fills (yellow ones included)
const HIGHLIGHT_OUTLINE = "#a86b00"
/**
* Marks changed cells: shapes get a soft halo plus a thin outline,
* connectors only the outline (a halo would cover their labels and arrows)
*/
export function highlightCells(ids: string[], color: string) {
clearHighlights()
const g = graph()
if (!g || !win?.mxCellHighlight || ids.length === 0) return
const add = (
state: any,
stroke: string,
width: number,
opacity: number,
) => {
const h = new win.mxCellHighlight(g, stroke, width)
h.opacity = opacity
h.highlight(state)
highlights.push(h)
}
for (const id of ids.slice(0, 200)) {
const cell = g.model.getCell(id)
const state = cell ? g.view.getState(cell) : null
if (!state) continue
if (!g.model.isEdge(cell)) add(state, color, 8, 18)
add(state, HIGHLIGHT_OUTLINE, 2, 100)
}
}
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
function cellLabel(cell: any): string {
const g = graph()
let text = ""
try {
text = g?.convertValueToString(cell) ?? ""
} catch {
text = ""
}
// Labels can contain HTML
text = text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim()
return text.length > 80 ? `${text.slice(0, 80)}…` : text
}
function readSelection(): SelectedCell[] {
const g = graph()
if (!g) return []
return (g.getSelectionCells() as any[])
.filter((cell) => cell?.id)
.map((cell) => ({
id: String(cell.id),
label: cellLabel(cell),
isEdge: !!g.model.isEdge(cell),
}))
}
function readSelectionRect() {
const g = graph()
if (!g || g.isSelectionEmpty()) return null
const cells = g.getSelectionCells()
const bounds = g.view.getBounds(cells)
if (!bounds) return null
const container = g.container as HTMLElement
const rect = container.getBoundingClientRect()
return {
x: rect.left + bounds.x - container.scrollLeft,
y: rect.top + bounds.y - container.scrollTop,
width: bounds.width,
height: bounds.height,
}
}
// ---------------------------------------------------------------------------
// Pages
// ---------------------------------------------------------------------------
function readPages() {
const pages = (ui?.pages ?? []) as any[]
return pages.map((page, index) => ({
id: String(page.getId?.() ?? index),
name: String(page.getName?.() ?? `Page-${index + 1}`),
}))
}
// ---------------------------------------------------------------------------
// Keeping the store in sync
// ---------------------------------------------------------------------------
function syncSelectionRect() {
useCanvasStore.getState().set({ selectionRect: readSelectionRect() })
}
function syncAll() {
if (!ui) return
const g = graph()
useCanvasStore.getState().set({
pages: readPages(),
currentPageId: ui.currentPage?.getId
? String(ui.currentPage.getId())
: null,
selection: readSelection(),
selectionRect: readSelectionRect(),
isFreehand: !!g?.freehand?.isDrawing?.(),
})
}
function isFormatPanelOpen(): boolean {
try {
if (typeof ui.isFormatPanelVisible === "function") {
return !!ui.isFormatPanelVisible()
}
return (ui.formatWidth ?? 0) > 0
} catch {
return false
}
}
function installListeners(): () => void {
const g = graph()
if (!g || !win?.mxEvent) return () => {}
const mxEvent = win.mxEvent
const cleanups: (() => void)[] = []
const listen = (source: any, name: string, handler: () => void) => {
if (!source?.addListener) return
source.addListener(name, handler)
cleanups.push(() => source.removeListener(handler))
}
// Any user action ends the "what the AI just changed" highlight
const onSelection = () => {
clearHighlights()
syncAll()
}
const onScale = () => {
zoomChanged()
syncSelectionRect()
}
listen(g.getSelectionModel(), mxEvent.CHANGE, onSelection)
listen(g.model, mxEvent.CHANGE, syncAll)
// Drawing started or stopped: the "ask AI" button hides meanwhile
listen(g, "freehandStateChanged", syncAll)
listen(g.view, mxEvent.SCALE, onScale)
listen(g.view, mxEvent.TRANSLATE, syncSelectionRect)
listen(g.view, mxEvent.SCALE_AND_TRANSLATE, onScale)
listen(ui.editor, "pageSelected", syncAll)
// Capture phase, so the app shortcut wins before draw.io's key handler
const onKeyDown = (event: KeyboardEvent) => {
if (appShortcutHandler?.(event)) {
event.preventDefault()
event.stopPropagation()
}
}
const frameDocument = win.document as Document | undefined
frameDocument?.addEventListener("keydown", onKeyDown, true)
cleanups.push(() =>
frameDocument?.removeEventListener("keydown", onKeyDown, true),
)
// draw.io adds its menus and dialogs to its body, and the shape picker
// (from the arrows next to a selection) to the canvas, and removes them
// when they close: the "ask AI" button over the frame must not cover them
const body = frameDocument?.body
const canvas = g.container as HTMLElement | undefined
if (body && canvas && win.MutationObserver) {
const syncPopups = () =>
useCanvasStore.getState().set({
isDrawioPopupOpen:
!!body.querySelector(
":scope > .mxPopupMenu, :scope > .geDialog",
) || !!canvas.querySelector(":scope > .geShapePicker"),
})
const observer = new win.MutationObserver(syncPopups)
observer.observe(body, { childList: true })
observer.observe(canvas, { childList: true })
cleanups.push(() => observer.disconnect())
}
const container = g.container as HTMLElement | undefined
container?.addEventListener("scroll", syncSelectionRect, { passive: true })
cleanups.push(() =>
container?.removeEventListener("scroll", syncSelectionRect),
)
return () => {
for (const cleanup of cleanups) cleanup()
}
}
/**
* Finds the EditorUi instance inside a same-origin draw.io iframe.
*
* draw.io keeps no global reference to it, so we wrap prototype methods it
* calls on startup and after every load; the first call hands us `this`.
* Returns false when the frame is cross-origin.
*/
export function watchForEditorUi(
frameWindow: Window,
onFound: (editorUi: EditorUi) => void,
): boolean {
let w: any
try {
w = frameWindow as any
// Throws for a cross-origin frame
void w.document
} catch {
return false
}
const hook = (proto: any, method: string) => {
if (!proto || typeof proto[method] !== "function") return
if (proto[method].__naiWrapped) return
const original = proto[method]
const wrapped = function (this: any, ...args: any[]) {
onFound(this)
return original.apply(this, args)
}
;(wrapped as any).__naiWrapped = true
proto[method] = wrapped
}
hook(w.EditorUi?.prototype, "updateActionStates")
hook(w.App?.prototype, "fileLoaded")
hook(w.EditorUi?.prototype, "fileLoaded")
return true
}
+183 -111
View File
@@ -3,22 +3,19 @@
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"confirm": "Confirm",
"clear": "Clear",
"edit": "Edit",
"delete": "Delete",
"loading": "Loading..",
"new": "NEW"
"loading": "Loading.."
},
"nav": {
"about": "About",
"editor": "Editor",
"newChat": "Start fresh chat",
"newChat": "New diagram",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
"aiChat": "AI Chat"
"hidePanel": "Hide chat panel",
"showPanel": "Show chat panel",
"aiChat": "AI chat"
},
"providers": {
"useServerDefault": "Use Server Default",
@@ -39,12 +36,11 @@
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
"inputLabel": "Chat input",
"send": "Send",
"stopGeneration": "Stop generation",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"diagramHistory": "Diagram history",
"saveDiagram": "Save diagram",
"uploadFile": "Upload file (image, PDF, text)",
"minimalStyle": "Minimal",
"styledMode": "Styled",
@@ -52,39 +48,38 @@
"regenerate": "Regenerate response",
"copyResponse": "Copy response",
"copied": "Copied!",
"failedToCopy": "Failed to copy",
"failedToCopyDetail": "Failed to copy message. Please copy manually or check clipboard permissions.",
"goodResponse": "Good response",
"badResponse": "Bad response",
"clickToEdit": "Click to edit",
"editMessage": "Edit message",
"saveAndSubmit": "Save & Submit",
"ExtractURL": "Extract from URL"
"saveAndSubmit": "Save & submit",
"ExtractURL": "Extract from URL",
"attach": "Add",
"placeholderFollowUp": "Ask for a change, or select shapes to edit them",
"emptyThread": "Draw on the canvas, or describe what you want below.",
"attachedImage": "Attached image",
"retry": "Retry"
},
"examples": {
"title": "Create diagrams with AI",
"subtitle": "Describe what you want to create or upload an image to replicate",
"quickExamples": "Quick Examples",
"paperToDiagram": "Paper to Diagram",
"paperToDiagram": "Paper to diagram",
"paperDescription": "Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more",
"animatedDiagram": "Animated Diagram",
"animatedDiagram": "Animated diagram",
"animatedDescription": "Draw a transformer architecture with animated connectors",
"awsArchitecture": "AWS Architecture",
"awsArchitecture": "AWS architecture",
"awsDescription": "Create a cloud architecture diagram with AWS icons",
"replicateFlowchart": "Replicate Flowchart",
"replicateFlowchart": "Replicate a flowchart",
"replicateDescription": "Upload and replicate an existing flowchart",
"creativeDrawing": "Creative Drawing",
"creativeDrawing": "Creative drawing",
"creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples are cached for instant response",
"cachedNote": "Examples open instantly and don't use your model.",
"mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
},
"settings": {
"title": "Settings",
"description": "Configure your application settings.",
"apiKeysModels": "API Keys & Models",
"apiKeysModelsDescription": "Configure AI providers and API keys.",
"accessCode": "Access Code",
"accessCode": "Access code",
"accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.",
"aiProvider": "AI Provider Settings",
@@ -101,17 +96,10 @@
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style",
"themeDefault": "Default",
"themeDark": "Dark",
"themeMinimal": "Minimal",
"themeSketch": "Sketch",
"themeSimple": "Simple",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut",
"themeDescription": "Light or dark interface. The diagram always stays on a white sheet, the way it exports.",
"diagramStyle": "Diagram style",
"diagramStyleDescription": "How the AI draws new diagrams: Styled uses colors and icons, Minimal keeps shapes plain.",
"sendShortcut": "Send shortcut",
"sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
@@ -119,52 +107,53 @@
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download",
"proxy": "Proxy Settings",
"proxy": "Proxy settings",
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
"httpProxy": "HTTP Proxy",
"httpsProxy": "HTTPS Proxy",
"httpProxy": "HTTP proxy",
"httpsProxy": "HTTPS proxy",
"applyProxy": "Apply",
"proxyApplied": "Proxy settings applied",
"diagramValidation": "Diagram Validation (Experimental)",
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
"enabled": "Enabled",
"disabled": "Disabled",
"customSystemMessage": "Custom System Message",
"diagramValidation": "Check diagrams with a screenshot (experimental)",
"diagramValidationDescription": "After each drawing, a model that can read images looks at a screenshot and asks for fixes. Needs a model that can read images.",
"customSystemMessage": "Custom instructions",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
"maxOutputTokens": "Max Output Tokens",
"maxOutputTokens": "Max output tokens",
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
"panelVisibility": "Lobby Panels",
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
"showRecentChats": "Recent Chats",
"showMyTemplates": "My Templates",
"showQuickExamples": "Quick Examples"
"tabModels": "Models & keys",
"tabAppearance": "Appearance",
"tabAdvanced": "Advanced",
"tabAbout": "About",
"themeLight": "Light",
"themeDarkMode": "Dark",
"themeSystem": "System",
"accessCodeSaved": "Access code saved",
"proxyInvalid": "Proxy addresses must start with http:// or https://",
"proxyFailed": "Couldn't apply the proxy settings",
"appVersion": "Version",
"projectWebsite": "Project website"
},
"save": {
"title": "Save Diagram",
"description": "Choose a format and filename to save your diagram.",
"title": "Export diagram",
"description": "Pick a format and a file name.",
"format": "Format",
"filename": "Filename",
"filenamePlaceholder": "Enter filename",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG Image",
"svg": "SVG Image",
"drawio": "draw.io file",
"png": "PNG image",
"svg": "SVG image",
"xmlsvg": "Editable SVG"
},
"savedSuccessfully": "Saved successfully!"
},
"history": {
"title": "Diagram History",
"description": "Here saved each diagram before AI modification.\nClick on a diagram to restore it",
"noHistory": "No history available yet. Send messages to create diagram history.",
"version": "Version",
"restoreTo": "Restore to Version {version}?"
"savedSuccessfully": "Diagram exported",
"hints": {
"drawio": "Keeps everything editable",
"png": "A picture for slides and documents",
"svg": "A picture that scales without blur",
"xmlsvg": "A picture that still opens in draw.io"
}
},
"dialogs": {
"clearTitle": "Clear Everything?",
"clearDescription": "This will clear the current conversation and reset the diagram. This action cannot be undone.",
"clearEverything": "Clear Everything",
"clearSuccess": "Started a fresh chat"
},
"errors": {
@@ -189,6 +178,14 @@
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
"continueWithoutSaving": "Continue without saving",
"notADiagramFile": "{name} isn't a draw.io diagram.",
"fileTooLong": "{name}: Content exceeds {limit}k character limit ({size}k chars)",
"fileReadFailed": "Failed to read file: {name}",
"answerEndedEarly": "The answer ended before the diagram was finished.",
"attachmentsStillReading": "Attachments are still being read. Send again when they are ready.",
"sendInProgress": "The last message is still being sent. Try again in a moment.",
"answerInProgress": "An answer is still coming. Send again when it is done.",
"cannotResend": "This message can't be sent again: the diagram from when it was sent wasn't saved.",
"llm": {
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
@@ -227,11 +224,6 @@
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "Generate Diagram",
"editDiagram": "Edit Diagram",
"appendDiagram": "Continue Diagram",
"complete": "Complete",
"error": "Error",
"truncated": "Truncated"
},
"file": {
@@ -246,7 +238,8 @@
"extract": "Extract",
"Cancel": "Cancel",
"enterUrl": "Please enter a URL",
"invalidFormat": "Invalid URL format"
"invalidFormat": "Invalid URL format",
"failed": "Couldn't read that page"
},
"reasoning": {
"thinking": "Thinking...",
@@ -279,30 +272,29 @@
"sessionHistory": {
"tooltip": "Chat History",
"newChat": "New Chat",
"empty": "No chat history yet",
"empty": "No diagrams yet",
"emptyHint": "Start a conversation to begin",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"earlier": "Earlier",
"deleteTitle": "Delete this chat?",
"deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.",
"recentChats": "Recent Chats",
"deleteTitle": "Delete this diagram?",
"deleteDescription": "The diagram and its conversation are deleted for good.",
"justNow": "Just now",
"searchPlaceholder": "Search chats...",
"noResults": "No chats found"
"searchPlaceholder": "Search diagrams…",
"noResults": "No chats found",
"rename": "Rename",
"busyHint": "Available when the answer is finished"
},
"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",
"emptyTitle": "No saved prompts yet",
"emptyDescription": "Save a prompt you reuse, for example \"Draw an AWS architecture for…\". It shows up here.",
"createFirst": "Save your first prompt",
"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.",
"myTemplates": "Saved prompts",
"createTitle": "Save a prompt",
"createDescription": "Save a prompt to use again later.",
"promptLabel": "Prompt",
"promptPlaceholder": "Describe the diagram you want to create...",
"promptRequired": "Prompt is required",
@@ -310,33 +302,34 @@
"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.",
"descriptionPlaceholder": "What this prompt is for",
"pinnedLabel": "Pin to top",
"pinnedHint": "Pinned prompts appear at the top of the list",
"createButton": "Save prompt",
"createFailed": "Couldn't save the prompt. Try again.",
"editTitle": "Edit saved prompt",
"editDescription": "Change the prompt and its settings.",
"updateFailed": "Couldn't update the prompt. Try again.",
"duplicate": "Duplicate",
"copySuffix": "(copy)",
"deleteTitle": "Delete this template?",
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
"deleteTitle": "Delete this saved prompt?",
"deleteDescription": "The prompt is deleted for good.",
"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",
"confirmSendDescription": "You have unsent text in the message box. Sending this prompt replaces it.",
"confirmSendButton": "Send prompt",
"searchPlaceholder": "Search saved prompts…",
"searchNoResults": "No saved prompts match",
"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",
"saveAsTemplate": "Save this prompt",
"exportTemplates": "Export saved prompts",
"importTemplates": "Import saved prompts",
"exportEmpty": "No saved prompts to export",
"exportSuccess": "Exported {count} saved prompt(s)",
"importNoFile": "Please select a JSON file",
"importFailed": "Import failed: {error}",
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
"importSuccess": "Imported {imported} saved prompt(s), skipped {skipped} duplicate(s)",
"saveDisabledHint": "Type a prompt first"
},
"validation": {
"title": "Validate Diagram",
@@ -357,8 +350,6 @@
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
},
"modelConfig": {
"title": "AI Model Configuration",
"description": "Configure multiple AI providers and models",
"configure": "Configure",
"addProvider": "Add Provider",
"addModel": "Add Model",
@@ -375,10 +366,12 @@
"validationError": "Validation failed",
"addModelFirst": "Add at least one model to validate",
"providers": "Providers",
"addProviderHint": "Add a provider to get started",
"addProviderHint": "No providers yet",
"verified": "Verified",
"configuration": "Configuration",
"displayName": "Display Name",
"bedrockApiKey": "Bedrock API key",
"bedrockApiKeyHint": "With an API key, the access keys below are not needed.",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region",
@@ -404,13 +397,14 @@
"noModelsConfigured": "No models configured",
"modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists",
"configureProviders": "Configure AI Providers",
"configureProviders": "Set up AI providers",
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type provider name...",
"modelsConfiguredCount": "{count} model(s) configured",
"validationFailedCount": "{count} model(s) failed validation",
"requestFailed": "Request failed ({status})",
"cancel": "Cancel",
"delete": "Delete",
"clickToChange": "(click to change)",
@@ -430,7 +424,9 @@
"unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model",
"showValue": "Show value",
"hideValue": "Hide value"
"hideValue": "Hide value",
"noProvidersTitle": "Using the model set up on this server",
"noProvidersHint": "To use other models, add a provider with your own API key below the list on the left."
},
"admin": {
"title": "Admin Settings",
@@ -575,5 +571,81 @@
"description": "Timezone for the daily reset boundary."
}
}
},
"workspace": {
"export": "Export",
"openFile": "Open .drawio file",
"canvasLoading": "Loading the canvas…",
"askAi": "Ask AI to change this",
"untitled": "Untitled diagram",
"mobileCanvas": "Canvas",
"mobileChat": "Chat"
},
"selection": {
"one": "Selected: {label}",
"many": "{count} shapes selected",
"shape": "shape",
"connector": "connector",
"remove": "Don't send the selection",
"placeholder": "Describe how to change the selected shapes"
},
"versions": {
"strip": "Versions",
"created": "Drew the diagram",
"edited": "Changed the diagram",
"undone": "Change undone",
"undo": "Undo change",
"redo": "Redo change",
"compare": "Compare",
"compareTitle": "Compare versions",
"currentCanvas": "Canvas now",
"versionLabel": "Version {n}",
"previous": "Previous version",
"next": "Next version",
"rendering": "Rendering preview…",
"showCode": "Show XML",
"hideCode": "Hide XML",
"drawing": "Drawing the diagram…",
"editing": "Changing the diagram…",
"loadingLibrary": "Loading a shape library…",
"libraryLoaded": "Loaded a shape library",
"failed": "This attempt failed",
"failedRetrying": "This attempt failed, trying again",
"stopped": "Stopped",
"truncatedHint": "The reply hit the output limit before the diagram was complete. Ask for a simpler diagram, or raise Max output tokens in Settings > Advanced.",
"noChanges": "No visible changes",
"listSeparator": ", ",
"shapesAddedOne": "added 1 shape",
"shapesAddedOther": "added {count} shapes",
"edgesAddedOne": "added 1 connector",
"edgesAddedOther": "added {count} connectors",
"shapesChangedOne": "changed 1 shape",
"shapesChangedOther": "changed {count} shapes",
"edgesChangedOne": "changed 1 connector",
"edgesChangedOther": "changed {count} connectors",
"shapesRemovedOne": "removed 1 shape",
"shapesRemovedOther": "removed {count} shapes",
"edgesRemovedOne": "removed 1 connector",
"edgesRemovedOther": "removed {count} connectors",
"totals": "{shapes}, {edges}",
"shapesTotalOne": "1 shape",
"shapesTotalOther": "{count} shapes",
"edgesTotalOne": "1 connector",
"edgesTotalOther": "{count} connectors",
"restoreVersion": "Restore v{n}",
"restoredVersion": "Restored v{n}",
"undoRestore": "Undo",
"noPreview": "No preview",
"compareSince": "Since version {n}, the canvas has changed:",
"isOnCanvas": "This version is what the canvas shows now.",
"differsElsewhere": "The shapes and connectors on the first page are the same as in version {n}; their order, other pages or the page settings differ."
},
"lobby": {
"title": "What would you like to draw?",
"lede": "Describe a diagram, or upload a screenshot or PDF. You get a regular draw.io diagram that you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas",
"openFileHint": "Keep working on a diagram you have"
}
}
+174 -102
View File
@@ -3,21 +3,18 @@
"save": "保存",
"cancel": "キャンセル",
"close": "閉じる",
"confirm": "確認",
"clear": "クリア",
"edit": "編集",
"delete": "削除",
"loading": "読み込み中..",
"new": "新規"
"loading": "読み込み中.."
},
"nav": {
"about": "概要",
"editor": "エディタ",
"newChat": "新しいチャットを開始",
"newChat": "新しい図",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
"hidePanel": "チャットパネルを非表示",
"showPanel": "チャットパネルを表示",
"aiChat": "AI チャット"
},
"providers": {
@@ -39,12 +36,11 @@
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"inputLabel": "チャット入力",
"send": "送信",
"stopGeneration": "生成を停止",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"diagramHistory": "ダイアグラム履歴",
"saveDiagram": "ダイアグラムを保存",
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
"minimalStyle": "ミニマル",
"styledMode": "スタイル付き",
@@ -52,38 +48,37 @@
"regenerate": "応答を再生成",
"copyResponse": "応答をコピー",
"copied": "コピーしました!",
"failedToCopy": "コピーに失敗しました",
"failedToCopyDetail": "メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。",
"goodResponse": "良い応答",
"badResponse": "悪い応答",
"clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信",
"ExtractURL": "URLから抽出"
"ExtractURL": "URLから抽出",
"attach": "追加",
"placeholderFollowUp": "変更したい内容を入力。図形を選んでから頼むこともできます",
"emptyThread": "キャンバスに直接描くか、下で描きたいものを伝えてください。",
"attachedImage": "添付画像",
"retry": "再試行"
},
"examples": {
"title": "AI でダイアグラムを作成",
"subtitle": "作成したいものを説明するか、画像をアップロードして複製",
"quickExamples": "クイック例",
"paperToDiagram": "論文からダイアグラムへ",
"paperToDiagram": "論文を図に",
"paperDescription": ".pdf, .txt, .md, .json, .csv, .py, .js, .ts などをアップロード",
"animatedDiagram": "アニメーション図",
"animatedDescription": "アニメーションコネクタ付きの Transformer アーキテクチャを描画",
"awsArchitecture": "AWS アーキテクチャ",
"awsArchitecture": "AWS 構成図",
"awsDescription": "AWS アイコンでクラウドアーキテクチャ図を作成",
"replicateFlowchart": "フローチャートを複製",
"replicateFlowchart": "フローチャートを再現",
"replicateDescription": "既存のフローチャートをアップロードして複製",
"creativeDrawing": "クリエイティブな描画",
"creativeDrawing": "自由なイラスト",
"creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はキャッシュされ、即座に応答します",
"cachedNote": "例はすぐに開き、モデルは使いません。",
"mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
},
"settings": {
"title": "設定",
"description": "アプリケーション設定を構成します。",
"apiKeysModels": "API キーとモデル",
"apiKeysModelsDescription": "AI プロバイダーと API キーを設定します。",
"accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
@@ -101,17 +96,10 @@
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル",
"themeDefault": "デフォルト",
"themeDark": "ダーク",
"themeMinimal": "ミニマル",
"themeSketch": "スケッチ",
"themeSimple": "シンプル",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット",
"themeDescription": "画面をライトにするかダークにするか。図はエクスポートしたときと同じく、常に白い用紙の上に表示されます。",
"diagramStyle": "図のスタイル",
"diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。",
"sendShortcut": "送信のショートカット",
"sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
@@ -125,46 +113,47 @@
"httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました",
"diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"enabled": "有効",
"disabled": "無効",
"customSystemMessage": "カスタムシステムメッセージ",
"diagramValidation": "スクリーンショットで図をチェック(実験的)",
"diagramValidationDescription": "描くたびに、画像を読めるモデルがスクリーンショットを確認し、修正を求めます。画像を読めるモデルが必要です。",
"customSystemMessage": "カスタム指示",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"maxOutputTokens": "最大出力トークン数",
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
"panelVisibility": "ロビーパネル",
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
"showRecentChats": "最近のチャット",
"showMyTemplates": "マイテンプレート",
"showQuickExamples": "クイック例"
"tabModels": "モデルとキー",
"tabAppearance": "外観",
"tabAdvanced": "詳細",
"tabAbout": "このアプリについて",
"themeLight": "ライト",
"themeDarkMode": "ダーク",
"themeSystem": "システムに合わせる",
"accessCodeSaved": "アクセスコードを保存しました",
"proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください",
"proxyFailed": "プロキシ設定を適用できませんでした",
"appVersion": "バージョン",
"projectWebsite": "プロジェクトのサイト"
},
"save": {
"title": "ダイアグラムを保存",
"description": "形式とファイル名を選択してダイアグラムを保存します。",
"title": "図をエクスポート",
"description": "形式とファイル名を選んでください。",
"format": "形式",
"filename": "ファイル名",
"filenamePlaceholder": "ファイル名を入力",
"formats": {
"drawio": "Draw.io XML",
"drawio": "draw.io ファイル",
"png": "PNG 画像",
"svg": "SVG 画像",
"xmlsvg": "編集可能 SVG"
"xmlsvg": "編集できる SVG"
},
"savedSuccessfully": "保存完了!"
},
"history": {
"title": "ダイアグラム履歴",
"description": "AI 修正前に保存された各ダイアグラム。\nダイアグラムをクリックして復元",
"noHistory": "まだ履歴がありません。メッセージを送信してダイアグラム履歴を作成してください。",
"version": "バージョン",
"restoreTo": "バージョン {version} に復元しますか?"
"savedSuccessfully": "エクスポートしました",
"hints": {
"drawio": "すべて編集できるまま保存",
"png": "スライドや文書向けの画像",
"svg": "拡大してもぼやけない画像",
"xmlsvg": "draw.io でも開ける画像"
}
},
"dialogs": {
"clearTitle": "すべてクリアしますか?",
"clearDescription": "現在の会話をクリアし、ダイアグラムをリセットします。この操作は元に戻せません。",
"clearEverything": "すべてクリア",
"clearSuccess": "新しいチャットを開始しました"
},
"errors": {
@@ -189,6 +178,14 @@
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
"notADiagramFile": "{name} は draw.io の図ではありません。",
"fileTooLong": "{name}:内容が上限の {limit}k 文字を超えています({size}k 文字)",
"fileReadFailed": "ファイルを読み込めませんでした:{name}",
"answerEndedEarly": "図を描き終える前に回答が終わりました。",
"attachmentsStillReading": "添付ファイルをまだ読み込んでいます。読み込みが終わってから送信してください。",
"sendInProgress": "前のメッセージを送信しています。少し待ってからもう一度お試しください。",
"answerInProgress": "回答をまだ受信しています。終わってからもう一度送信してください。",
"cannotResend": "このメッセージは送り直せません。送信時の図が保存されていません。",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
@@ -227,11 +224,6 @@
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "ダイアグラムを生成",
"editDiagram": "ダイアグラムを編集",
"appendDiagram": "ダイアグラムに追加",
"complete": "完了",
"error": "エラー",
"truncated": "切り捨て"
},
"file": {
@@ -246,7 +238,8 @@
"extract": "抽出",
"Cancel": "キャンセル",
"enterUrl": "URLを入力してください",
"invalidFormat": "無効なURL形式です"
"invalidFormat": "無効なURL形式です",
"failed": "このページを読み込めませんでした"
},
"reasoning": {
"thinking": "考え中...",
@@ -279,18 +272,19 @@
"sessionHistory": {
"tooltip": "チャット履歴",
"newChat": "新しいチャット",
"empty": "チャット履歴はまだありません",
"empty": "まだ図はありません",
"emptyHint": "会話を始めてください",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "このチャットを削除しますか?",
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
"recentChats": "最近のチャット",
"deleteTitle": "この図を削除しますか?",
"deleteDescription": "図とその会話は完全に削除されます。",
"justNow": "たった今",
"searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません"
"searchPlaceholder": "図を検索…",
"noResults": "チャットが見つかりません",
"rename": "名前を変更",
"busyHint": "回答が終わると選べます"
},
"validation": {
"title": "ダイアグラムを検証",
@@ -311,8 +305,6 @@
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": {
"title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定",
"configure": "設定",
"addProvider": "プロバイダーを追加",
"addModel": "モデルを追加",
@@ -329,10 +321,12 @@
"validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
"providers": "プロバイダー",
"addProviderHint": "プロバイダーを追加して開始",
"addProviderHint": "プロバイダーはまだありません",
"verified": "検証済み",
"configuration": "設定",
"displayName": "表示名",
"bedrockApiKey": "Bedrock API キー",
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
"awsAccessKeyId": "AWS アクセスキー ID",
"awsSecretAccessKey": "AWS シークレットアクセスキー",
"awsRegion": "AWS リージョン",
@@ -358,13 +352,14 @@
"noModelsConfigured": "モデルが設定されていません",
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"configureProviders": "AI プロバイダーを設定",
"configureProviders": "AI プロバイダーの設定",
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...",
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
"validationFailedCount": "{count} 個のモデルの検証に失敗",
"requestFailed": "リクエストに失敗しました({status})",
"cancel": "キャンセル",
"delete": "削除",
"clickToChange": "(クリックして変更)",
@@ -384,19 +379,19 @@
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示",
"hideValue": "値を非表示"
"hideValue": "値を非表示",
"noProvidersTitle": "このサーバーで設定されたモデルを使用中",
"noProvidersHint": "ほかのモデルを使うには、左のリストの下でプロバイダーを追加し、自分の API キーを入力してください。"
},
"templates": {
"title": "マイテンプレート",
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
"emptyTitle": "テンプレートがありません",
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
"createFirst": "最初のテンプレートを作成",
"emptyTitle": "保存したプロンプトはまだありません",
"emptyDescription": "「…の AWS 構成図を描いて」のようによく使うプロンプトを保存すると、ここに表示されます。",
"createFirst": "最初のプロンプトを保存",
"neverUsed": "未使用",
"usedCount": "{count} 回使用",
"myTemplates": "マイテンプレート",
"createTitle": "テンプレート作成",
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
"myTemplates": "保存したプロンプト",
"createTitle": "プロンプトを保存",
"createDescription": "あとで使えるようにプロンプトを保存します。",
"promptLabel": "プロンプト",
"promptPlaceholder": "作成するダイアグラムを説明してください...",
"promptRequired": "プロンプトは必須です",
@@ -404,33 +399,34 @@
"titlePlaceholder": "タイトルを入力",
"titleHint": "空欄の場合はプロンプトの最初の20文字が使用されます",
"descriptionLabel": "説明",
"descriptionPlaceholder": "このテンプレートの説明を追加",
"pinnedLabel": "テンプレートをピン留め",
"pinnedHint": "ピン留めされたテンプレートはリストの上部に表示されます",
"createButton": "テンプレート作成",
"createFailed": "テンプレートの作成に失敗しました。もう一度お試しください。",
"editTitle": "テンプレート編集",
"editDescription": "テンプレートの内容と設定を更新します。",
"updateFailed": "テンプレートの更新に失敗しました。もう一度お試しください。",
"descriptionPlaceholder": "このプロンプトの用途",
"pinnedLabel": "先頭に固定",
"pinnedHint": "固定したプロンプトはリストの先頭に表示されます",
"createButton": "保存",
"createFailed": "プロンプトを保存できませんでした。もう一度お試しください。",
"editTitle": "プロンプトを編集",
"editDescription": "プロンプトと設定を変更します。",
"updateFailed": "プロンプトを更新できませんでした。もう一度お試しください。",
"duplicate": "複製",
"copySuffix": "(コピー)",
"deleteTitle": "このテンプレートを削除しますか?",
"deleteDescription": "このテンプレートは完全に削除されます。この操作は取り消せません。",
"deleteTitle": "このプロンプトを削除しますか?",
"deleteDescription": "プロンプトは完全に削除されます。",
"confirmSendTitle": "現在の入力を置き換えますか?",
"confirmSendDescription": "未送信のコンテンツがあります。このテンプレートを送信すると置き換えられます。",
"confirmSendButton": "テンプレートを送信",
"searchPlaceholder": "テンプレートを検索...",
"searchNoResults": "検索に一致するテンプレートがありません",
"confirmSendDescription": "入力欄に未送信の内容があります。このプロンプトを送ると置き換わります。",
"confirmSendButton": "送信",
"searchPlaceholder": "プロンプトを検索…",
"searchNoResults": "一致するプロンプトはありません",
"pin": "上部にピン留め",
"unpin": "ピン留め解除",
"saveAsTemplate": "テンプレートとして保存",
"exportTemplates": "テンプレートをエクスポート",
"importTemplates": "テンプレートをインポート",
"exportEmpty": "エクスポートするテンプレートがありません",
"exportSuccess": "{count} 件のテンプレートをエクスポートしました",
"saveAsTemplate": "このプロンプトを保存",
"exportTemplates": "プロンプトをエクスポート",
"importTemplates": "プロンプトをインポート",
"exportEmpty": "エクスポートするプロンプトがありません",
"exportSuccess": "{count} 件のプロンプトをエクスポートしました",
"importNoFile": "JSON ファイルを選択してください",
"importFailed": "インポートに失敗しました:{error}",
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
"importSuccess": "{imported} 件のプロンプトをインポートし、重複 {skipped} 件をスキップしました",
"saveDisabledHint": "先にプロンプトを入力してください"
},
"admin": {
"title": "管理者設定",
@@ -575,5 +571,81 @@
"description": "1 日のリセット境界に使用するタイムゾーン。"
}
}
},
"workspace": {
"export": "エクスポート",
"openFile": ".drawio ファイルを開く",
"canvasLoading": "キャンバスを読み込み中…",
"askAi": "ここを AI に直してもらう",
"untitled": "無題の図",
"mobileCanvas": "キャンバス",
"mobileChat": "チャット"
},
"selection": {
"one": "選択中:{label}",
"many": "{count} 個の図形を選択中",
"shape": "図形",
"connector": "コネクタ",
"remove": "選択した図形を送らない",
"placeholder": "選択した図形をどう変えたいか書いてください"
},
"versions": {
"strip": "バージョン",
"created": "図を描きました",
"edited": "図を修正しました",
"undone": "この変更は取り消しました",
"undo": "変更を取り消す",
"redo": "変更をやり直す",
"compare": "比較",
"compareTitle": "バージョンを比較",
"currentCanvas": "現在のキャンバス",
"versionLabel": "バージョン {n}",
"previous": "前のバージョン",
"next": "次のバージョン",
"rendering": "プレビューを作成中…",
"showCode": "XML を表示",
"hideCode": "XML を隠す",
"drawing": "図を描いています…",
"editing": "図を修正しています…",
"loadingLibrary": "図形ライブラリを読み込み中…",
"libraryLoaded": "図形ライブラリを読み込みました",
"failed": "今回はうまくいきませんでした",
"failedRetrying": "うまくいかなかったので再試行しています",
"stopped": "停止しました",
"truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 詳細 で最大出力トークン数を増やしてください。",
"noChanges": "目に見える変更はありません",
"listSeparator": "、",
"shapesAddedOne": "図形を 1 個追加",
"shapesAddedOther": "図形を {count} 個追加",
"edgesAddedOne": "コネクタを 1 本追加",
"edgesAddedOther": "コネクタを {count} 本追加",
"shapesChangedOne": "図形を 1 個変更",
"shapesChangedOther": "図形を {count} 個変更",
"edgesChangedOne": "コネクタを 1 本変更",
"edgesChangedOther": "コネクタを {count} 本変更",
"shapesRemovedOne": "図形を 1 個削除",
"shapesRemovedOther": "図形を {count} 個削除",
"edgesRemovedOne": "コネクタを 1 本削除",
"edgesRemovedOther": "コネクタを {count} 本削除",
"totals": "{shapes}、{edges}",
"shapesTotalOne": "図形 1 個",
"shapesTotalOther": "図形 {count} 個",
"edgesTotalOne": "コネクタ 1 本",
"edgesTotalOther": "コネクタ {count} 本",
"restoreVersion": "v{n} に戻す",
"restoredVersion": "v{n} に戻しました",
"undoRestore": "元に戻す",
"noPreview": "プレビューなし",
"compareSince": "バージョン {n} から現在までの変更:",
"isOnCanvas": "現在のキャンバスはこのバージョンと同じです。",
"differsElsewhere": "1 ページ目の図形と線はバージョン {n} と同じですが、重なり順、ほかのページ、またはページ設定が変わっています。"
},
"lobby": {
"title": "どんな図を描きますか?",
"lede": "図の内容を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは通常の draw.io の図なので、続けて編集もエクスポートもできます。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める",
"openFileHint": "手元の図の続きを編集"
}
}
+175 -103
View File
@@ -3,22 +3,19 @@
"save": "儲存",
"cancel": "取消",
"close": "關閉",
"confirm": "確認",
"clear": "清除",
"edit": "編輯",
"delete": "刪除",
"loading": "載入中...",
"new": "新建"
"loading": "載入中..."
},
"nav": {
"about": "關於",
"editor": "編輯器",
"newChat": "開始新對話",
"newChat": "新增圖紙",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
"showPanel": "顯示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
"hidePanel": "隱藏聊天面板",
"showPanel": "顯示聊天面板",
"aiChat": "AI 對話"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
@@ -39,12 +36,11 @@
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"inputLabel": "聊天輸入框",
"send": "傳送",
"stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"diagramHistory": "圖表歷史",
"saveDiagram": "儲存圖表",
"uploadFile": "上傳檔案(圖片、PDF、文字)",
"minimalStyle": "簡約",
"styledMode": "精緻",
@@ -52,38 +48,37 @@
"regenerate": "重新產生回應",
"copyResponse": "複製回應",
"copied": "已複製!",
"failedToCopy": "複製失敗",
"failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。",
"goodResponse": "有幫助",
"badResponse": "無幫助",
"clickToEdit": "點擊編輯",
"editMessage": "編輯訊息",
"saveAndSubmit": "儲存並提交",
"ExtractURL": "從 URL 擷取"
"saveAndSubmit": "儲存並送出",
"ExtractURL": "從 URL 擷取",
"attach": "新增",
"placeholderFollowUp": "說說想怎麼改,也可以先選取要改的圖形",
"emptyThread": "可以直接在畫布上畫,也可以在下面說說要畫什麼。",
"attachedImage": "附加的圖片",
"retry": "重試"
},
"examples": {
"title": "用 AI 建立圖表",
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
"quickExamples": "快速範例",
"paperToDiagram": "文件轉圖表",
"paperToDiagram": "論文轉圖表",
"paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案",
"animatedDiagram": "動畫圖表",
"animatedDescription": "繪製帶有動畫連接器的 Transformer 架構",
"awsArchitecture": "AWS 架構",
"awsDescription": "使用 AWS 圖示建立雲端架構圖",
"replicateFlowchart": "複製流程圖",
"replicateFlowchart": "複刻流程圖",
"replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪圖",
"creativeDrawing": "創意繪畫",
"creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例已快取,可即時回應",
"cachedNote": "範例會立即開啟,不呼叫模型。",
"mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
},
"settings": {
"title": "設定",
"description": "配置您的應用程式設定。",
"apiKeysModels": "API 金鑰和模型",
"apiKeysModelsDescription": "配置 AI 提供商和 API 金鑰。",
"accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。",
@@ -101,17 +96,10 @@
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式",
"themeDefault": "預設",
"themeDark": "深色",
"themeMinimal": "簡約",
"themeSketch": "草圖",
"themeSimple": "簡單",
"diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵",
"themeDescription": "介面用淺色還是深色。圖紙始終是白底,和匯出後的樣子一致。",
"diagramStyle": "圖形風格",
"diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。",
"sendShortcut": "傳送快速鍵",
"sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
@@ -125,46 +113,47 @@
"httpsProxy": "HTTPS 代理",
"applyProxy": "套用",
"proxyApplied": "代理設定已套用",
"diagramValidation": "圖表驗證(實驗性)",
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已啟用",
"disabled": "已停用",
"customSystemMessage": "自訂系統訊息",
"diagramValidation": "用截圖檢查圖表(實驗性)",
"diagramValidationDescription": "每次畫完後,讓能看懂圖片的模型檢查截圖並要求修正。需要能讀圖片的模型。",
"customSystemMessage": "自訂指令",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"maxOutputTokens": "最大輸出 token 數",
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
"panelVisibility": "大廳面板",
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的範本",
"showQuickExamples": "快速範例"
"tabModels": "模型與金鑰",
"tabAppearance": "外觀",
"tabAdvanced": "進階",
"tabAbout": "關於",
"themeLight": "淺色",
"themeDarkMode": "深色",
"themeSystem": "跟隨系統",
"accessCodeSaved": "存取碼已儲存",
"proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭",
"proxyFailed": "代理設定沒有生效",
"appVersion": "版本",
"projectWebsite": "專案網站"
},
"save": {
"title": "儲存圖表",
"description": "選擇格式和檔案名稱以儲存您的圖表。",
"title": "匯出圖紙",
"description": "選擇格式和檔案名稱。",
"format": "格式",
"filename": "檔案名稱",
"filenamePlaceholder": "輸入檔案名稱",
"formats": {
"drawio": "Draw.io XML",
"drawio": "draw.io 檔案",
"png": "PNG 圖片",
"svg": "SVG 圖片",
"xmlsvg": "可編輯 SVG"
"xmlsvg": "可編輯的 SVG"
},
"savedSuccessfully": "儲存成功!"
},
"history": {
"title": "圖表歷史",
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
"version": "版本",
"restoreTo": "還原到版本 {version}?"
"savedSuccessfully": "已匯出",
"hints": {
"drawio": "所有內容都能繼續編輯",
"png": "用於簡報和文件的圖片",
"svg": "放大也不會模糊的圖片",
"xmlsvg": "圖片,也能在 draw.io 裡開啟編輯"
}
},
"dialogs": {
"clearTitle": "清除所有內容?",
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
"clearEverything": "清除所有內容",
"clearSuccess": "已開始新對話"
},
"errors": {
@@ -189,6 +178,14 @@
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
"notADiagramFile": "{name} 不是 draw.io 圖表檔案。",
"fileTooLong": "{name}:內容超過了 {limit}k 字元的上限({size}k 字元)",
"fileReadFailed": "無法讀取檔案:{name}",
"answerEndedEarly": "回答在圖畫完之前就結束了。",
"attachmentsStillReading": "附件還在讀取,讀完後再傳送。",
"sendInProgress": "上一則訊息還在傳送,請稍後再試。",
"answerInProgress": "回答還在進行,等它結束後再傳送。",
"cannotResend": "這則訊息無法重新傳送:傳送時的畫布沒有儲存下來。",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
@@ -227,11 +224,6 @@
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "產生圖表",
"editDiagram": "編輯圖表",
"appendDiagram": "繼續圖表",
"complete": "完成",
"error": "錯誤",
"truncated": "已截斷"
},
"file": {
@@ -246,7 +238,8 @@
"extract": "擷取",
"Cancel": "取消",
"enterUrl": "請輸入 URL",
"invalidFormat": "URL 格式無效"
"invalidFormat": "URL 格式無效",
"failed": "無法讀取這個網頁"
},
"reasoning": {
"thinking": "思考中...",
@@ -279,30 +272,29 @@
"sessionHistory": {
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "暫無聊天紀錄",
"empty": "還沒有圖紙",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除此對話?",
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
"recentChats": "最近對話",
"deleteTitle": "刪除這張圖紙?",
"deleteDescription": "圖紙和它的對話會被永久刪除。",
"justNow": "剛剛",
"searchPlaceholder": "搜尋對話...",
"noResults": "未找到對話"
"searchPlaceholder": "搜尋圖紙…",
"noResults": "未找到對話",
"rename": "重新命名",
"busyHint": "回答完成後才能切換"
},
"templates": {
"title": "我的範本",
"subtitle": "快速建立圖表的個人提示庫",
"emptyTitle": "尚無範本",
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
"createFirst": "建立第一個範本",
"emptyTitle": "還沒有常用提示詞",
"emptyDescription": "把經常用的提示詞存起來,比如「畫一張……的 AWS 架構圖」,之後會出現在這裡。",
"createFirst": "儲存第一個提示詞",
"neverUsed": "未使用過",
"usedCount": "使用 {count} 次",
"myTemplates": "我的範本",
"createTitle": "建立範本",
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
"myTemplates": "常用提示詞",
"createTitle": "儲存提示詞",
"createDescription": "把提示詞存起來,以後再用。",
"promptLabel": "提示",
"promptPlaceholder": "描述您想建立的圖表...",
"promptRequired": "提示為必填項",
@@ -310,33 +302,34 @@
"titlePlaceholder": "輸入標題",
"titleHint": "留空則使用提示的前 20 個字元",
"descriptionLabel": "描述",
"descriptionPlaceholder": "為此範本新增描述",
"pinnedLabel": "釘選範本",
"pinnedHint": "釘選的範本會顯示在清單頂部",
"createButton": "建立範本",
"createFailed": "建立範本失敗,請重試。",
"editTitle": "編輯範本",
"editDescription": "更新範本內容和設定。",
"updateFailed": "更新範本失敗,請重試。",
"descriptionPlaceholder": "這個提示詞的用途",
"pinnedLabel": "置頂",
"pinnedHint": "置頂的提示詞顯示在清單最上面",
"createButton": "儲存",
"createFailed": "儲存提示詞失敗,請重試。",
"editTitle": "編輯提示詞",
"editDescription": "修改提示詞和它的設定。",
"updateFailed": "更新提示詞失敗,請重試。",
"duplicate": "複製",
"copySuffix": "(副本)",
"deleteTitle": "刪除此範本?",
"deleteDescription": "此操作將永久刪除該範本,無法復原。",
"deleteTitle": "刪除這個提示詞?",
"deleteDescription": "提示詞會被永久刪除。",
"confirmSendTitle": "取代目前輸入?",
"confirmSendDescription": "您有未傳送的內容。傳送此範本將取代它。",
"confirmSendButton": "傳送範本",
"searchPlaceholder": "搜尋範本...",
"searchNoResults": "沒有符合的範本",
"confirmSendDescription": "輸入框裡有還沒傳送的內容,傳送這個提示詞會取代它。",
"confirmSendButton": "傳送",
"searchPlaceholder": "搜尋提示詞…",
"searchNoResults": "沒有符合的提示詞",
"pin": "置頂",
"unpin": "取消置頂",
"saveAsTemplate": "儲存為範本",
"exportTemplates": "匯出範本",
"importTemplates": "匯入範本",
"exportEmpty": "沒有可匯出的範本",
"exportSuccess": "成功匯出 {count} 個範本",
"saveAsTemplate": "儲存這個提示詞",
"exportTemplates": "匯出提示詞",
"importTemplates": "匯入提示詞",
"exportEmpty": "沒有可匯出的提示詞",
"exportSuccess": "已匯出 {count} 個提示詞",
"importNoFile": "請選擇一個 JSON 檔案",
"importFailed": "匯入失敗:{error}",
"importSuccess": "成功匯入 {imported} 個範本,跳過 {skipped} 個重複"
"importSuccess": "已匯入 {imported} 個提示詞,略過 {skipped} 個重複",
"saveDisabledHint": "先在輸入框裡寫好提示詞"
},
"validation": {
"title": "驗證圖表",
@@ -357,8 +350,6 @@
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多個 AI 提供商和模型",
"configure": "配置",
"addProvider": "新增提供商",
"addModel": "新增模型",
@@ -375,10 +366,12 @@
"validationError": "驗證失敗",
"addModelFirst": "請先新增至少一個模型以進行驗證",
"providers": "提供商",
"addProviderHint": "新增提供商即可開始使用",
"addProviderHint": "還沒有服務商",
"verified": "已驗證",
"configuration": "配置",
"displayName": "顯示名稱",
"bedrockApiKey": "Bedrock API 金鑰",
"bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。",
"awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
@@ -404,13 +397,14 @@
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"configureProviders": "設定 AI 服務商",
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入提供商名稱...",
"modelsConfiguredCount": "已配置 {count} 個模型",
"validationFailedCount": "{count} 個模型驗證失敗",
"requestFailed": "請求失敗({status})",
"cancel": "取消",
"delete": "刪除",
"clickToChange": "(點擊變更)",
@@ -430,7 +424,9 @@
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型",
"showValue": "顯示值",
"hideValue": "隱藏值"
"hideValue": "隱藏值",
"noProvidersTitle": "正在使用伺服器上設定的模型",
"noProvidersHint": "想用其他模型,可以在左側清單下方新增服務商並填寫自己的 API Key。"
},
"admin": {
"title": "管理員設定",
@@ -575,5 +571,81 @@
"description": "每日重置邊界所用的時區。"
}
}
},
"workspace": {
"export": "匯出",
"openFile": "開啟 .drawio 檔案",
"canvasLoading": "正在載入畫布…",
"askAi": "讓 AI 改這裡",
"untitled": "未命名圖紙",
"mobileCanvas": "畫布",
"mobileChat": "對話"
},
"selection": {
"one": "已選取:{label}",
"many": "已選取 {count} 個圖形",
"shape": "圖形",
"connector": "連線",
"remove": "不傳送選取的圖形",
"placeholder": "說說要怎麼改選取的圖形"
},
"versions": {
"strip": "版本",
"created": "畫好了圖表",
"edited": "改好了圖表",
"undone": "這次修改已復原",
"undo": "復原修改",
"redo": "重做修改",
"compare": "比較",
"compareTitle": "比較版本",
"currentCanvas": "現在的畫布",
"versionLabel": "第 {n} 版",
"previous": "上一版",
"next": "下一版",
"rendering": "正在產生預覽…",
"showCode": "檢視 XML",
"hideCode": "收起 XML",
"drawing": "正在畫圖…",
"editing": "正在修改圖表…",
"loadingLibrary": "正在讀取圖形庫…",
"libraryLoaded": "讀取了圖形庫",
"failed": "這次沒有成功",
"failedRetrying": "這次沒有成功,正在重試",
"stopped": "已停止",
"truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 進階 中調高最大輸出長度。",
"noChanges": "沒有可見的變化",
"listSeparator": ",",
"shapesAddedOne": "新增 1 個圖形",
"shapesAddedOther": "新增 {count} 個圖形",
"edgesAddedOne": "新增 1 條連線",
"edgesAddedOther": "新增 {count} 條連線",
"shapesChangedOne": "改了 1 個圖形",
"shapesChangedOther": "改了 {count} 個圖形",
"edgesChangedOne": "改了 1 條連線",
"edgesChangedOther": "改了 {count} 條連線",
"shapesRemovedOne": "刪了 1 個圖形",
"shapesRemovedOther": "刪了 {count} 個圖形",
"edgesRemovedOne": "刪了 1 條連線",
"edgesRemovedOther": "刪了 {count} 條連線",
"totals": "共 {shapes}、{edges}",
"shapesTotalOne": "1 個圖形",
"shapesTotalOther": "{count} 個圖形",
"edgesTotalOne": "1 條連線",
"edgesTotalOther": "{count} 條連線",
"restoreVersion": "還原到 v{n}",
"restoredVersion": "已還原到 v{n}",
"undoRestore": "復原",
"noPreview": "沒有預覽",
"compareSince": "從第 {n} 版到現在,畫布上:",
"isOnCanvas": "畫布現在顯示的就是這一版。",
"differsElsewhere": "第一頁的形狀和連線與第 {n} 版相同,疊放順序、其他頁面或頁面設定有改動。"
},
"lobby": {
"title": "說說你要畫什麼",
"lede": "描述一張圖,或上傳截圖、PDF。畫出來的是標準的 draw.io 圖表,可以接著改,也可以匯出。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始",
"openFileHint": "接著編輯已有的圖"
}
}
+173 -101
View File
@@ -3,22 +3,19 @@
"save": "保存",
"cancel": "取消",
"close": "关闭",
"confirm": "确认",
"clear": "清除",
"edit": "编辑",
"delete": "删除",
"loading": "加载中...",
"new": "新建"
"loading": "加载中..."
},
"nav": {
"about": "关于",
"editor": "编辑器",
"newChat": "开始新对话",
"newChat": "新建图纸",
"github": "GitHub",
"settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
"hidePanel": "隐藏聊天面板",
"showPanel": "显示聊天面板",
"aiChat": "AI 对话"
},
"providers": {
"useServerDefault": "使用服务器默认值",
@@ -39,12 +36,11 @@
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
"inputLabel": "聊天输入框",
"send": "发送",
"stopGeneration": "停止生成",
"sendMessage": "发送消息",
"clearConversation": "清除对话",
"diagramHistory": "图表历史",
"saveDiagram": "保存图表",
"uploadFile": "上传文件(图片、PDF、文本)",
"minimalStyle": "简约",
"styledMode": "精致",
@@ -52,38 +48,37 @@
"regenerate": "重新生成响应",
"copyResponse": "复制响应",
"copied": "已复制!",
"failedToCopy": "复制失败",
"failedToCopyDetail": "复制消息失败。请手动复制或检查剪贴板权限。",
"goodResponse": "有帮助",
"badResponse": "无帮助",
"clickToEdit": "点击编辑",
"editMessage": "编辑消息",
"saveAndSubmit": "保存并提交",
"ExtractURL": "从 URL 提取"
"ExtractURL": "从 URL 提取",
"attach": "添加",
"placeholderFollowUp": "说说想怎么改,也可以先选中要改的图形",
"emptyThread": "可以直接在画布上画,也可以在下面说说要画什么。",
"attachedImage": "附带的图片",
"retry": "重试"
},
"examples": {
"title": "用 AI 创建图表",
"subtitle": "描述您想要创建的内容或上传图片进行复制",
"quickExamples": "快速示例",
"paperToDiagram": "文档转图表",
"paperToDiagram": "论文转图表",
"paperDescription": "上传 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等文件",
"animatedDiagram": "动画图表",
"animatedDescription": "绘制带有动画连接器的 Transformer 架构",
"awsArchitecture": "AWS 架构",
"awsDescription": "使用 AWS 图标创建云架构图",
"replicateFlowchart": "复制流程图",
"replicateFlowchart": "复刻流程图",
"replicateDescription": "上传并复制现有流程图",
"creativeDrawing": "创意绘图",
"creativeDrawing": "创意绘画",
"creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例已缓存,可即时响应",
"cachedNote": "示例会立即打开,不调用模型。",
"mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
},
"settings": {
"title": "设置",
"description": "配置您的应用程序设置。",
"apiKeysModels": "API 密钥和模型",
"apiKeysModelsDescription": "配置 AI 提供商和 API 密钥。",
"accessCode": "访问码",
"accessCodePlaceholder": "输入访问码",
"accessCodeDescription": "使用此应用程序需要访问码。",
@@ -101,16 +96,9 @@
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式",
"themeDefault": "默认",
"themeDark": "深色",
"themeMinimal": "简约",
"themeSketch": "草图",
"themeSimple": "简单",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"themeDescription": "界面用浅色还是深色。图纸始终是白底,和导出后的样子一致。",
"diagramStyle": "图形风格",
"diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。",
"sendShortcut": "发送快捷键",
"sendShortcutDescription": "选择发送消息的方式。",
"enterToSend": "回车发送",
@@ -125,46 +113,47 @@
"httpsProxy": "HTTPS 代理",
"applyProxy": "应用",
"proxyApplied": "代理设置已应用",
"diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已启用",
"disabled": "已禁用",
"customSystemMessage": "自定义系统消息",
"diagramValidation": "用截图检查图表(实验性)",
"diagramValidationDescription": "每次画完后,让能看懂图片的模型检查截图并要求修正。需要能读图片的模型。",
"customSystemMessage": "自定义指令",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"maxOutputTokens": "最大输出 token 数",
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
"panelVisibility": "大厅面板",
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的模板",
"showQuickExamples": "快速示例"
"tabModels": "模型和密钥",
"tabAppearance": "外观",
"tabAdvanced": "高级",
"tabAbout": "关于",
"themeLight": "浅色",
"themeDarkMode": "深色",
"themeSystem": "跟随系统",
"accessCodeSaved": "访问码已保存",
"proxyInvalid": "代理地址必须以 http:// 或 https:// 开头",
"proxyFailed": "代理设置没有生效",
"appVersion": "版本",
"projectWebsite": "项目网站"
},
"save": {
"title": "保存图表",
"description": "选择格式和文件名以保存您的图表。",
"title": "导出图纸",
"description": "选择格式和文件名。",
"format": "格式",
"filename": "文件名",
"filenamePlaceholder": "输入文件名",
"formats": {
"drawio": "Draw.io XML",
"drawio": "draw.io 文件",
"png": "PNG 图片",
"svg": "SVG 图片",
"xmlsvg": "可编辑 SVG"
"xmlsvg": "可编辑的 SVG"
},
"savedSuccessfully": "保存成功!"
},
"history": {
"title": "图表历史",
"description": "在 AI 修改之前保存的每个图表。\n点击图表以恢复它",
"noHistory": "尚无历史记录。发送消息以创建图表历史。",
"version": "版本",
"restoreTo": "恢复到版本 {version}?"
"savedSuccessfully": "已导出",
"hints": {
"drawio": "所有内容都能继续编辑",
"png": "用于幻灯片和文档的图片",
"svg": "放大也不会模糊的图片",
"xmlsvg": "图片,也能在 draw.io 里打开编辑"
}
},
"dialogs": {
"clearTitle": "清除所有内容?",
"clearDescription": "这将清除当前对话并重置图表。此操作无法撤消。",
"clearEverything": "清除所有内容",
"clearSuccess": "已开始新对话"
},
"errors": {
@@ -189,6 +178,14 @@
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
"continueWithoutSaving": "不保存,继续",
"notADiagramFile": "{name} 不是 draw.io 图表文件。",
"fileTooLong": "{name}:内容超过了 {limit}k 字符的上限({size}k 字符)",
"fileReadFailed": "无法读取文件:{name}",
"answerEndedEarly": "回答在图画完之前就结束了。",
"attachmentsStillReading": "附件还在读取,读完后再发送。",
"sendInProgress": "上一条消息还在发送,请稍后再试。",
"answerInProgress": "回答还在进行,等它结束后再发送。",
"cannotResend": "这条消息无法重新发送:发送时的画布没有保存下来。",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
@@ -227,11 +224,6 @@
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "生成图表",
"editDiagram": "编辑图表",
"appendDiagram": "继续图表",
"complete": "完成",
"error": "错误",
"truncated": "已截断"
},
"file": {
@@ -246,7 +238,8 @@
"extract": "提取",
"Cancel": "取消",
"enterUrl": "请输入 URL",
"invalidFormat": "URL 格式无效"
"invalidFormat": "URL 格式无效",
"failed": "无法读取这个网页"
},
"reasoning": {
"thinking": "思考中...",
@@ -279,30 +272,29 @@
"sessionHistory": {
"tooltip": "聊天历史",
"newChat": "新对话",
"empty": "暂无聊天记录",
"empty": "还没有图纸",
"emptyHint": "开始对话吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"earlier": "更早",
"deleteTitle": "删除此对话?",
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
"recentChats": "最近对话",
"deleteTitle": "删除这张图纸?",
"deleteDescription": "图纸和它的对话会被永久删除。",
"justNow": "刚刚",
"searchPlaceholder": "搜索对话...",
"noResults": "未找到对话"
"searchPlaceholder": "搜索图纸…",
"noResults": "未找到对话",
"rename": "重命名",
"busyHint": "回答完成后才能切换"
},
"templates": {
"title": "我的模板库",
"subtitle": "您的个人 prompt 库,快速创建图表",
"emptyTitle": "暂无模板",
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
"createFirst": "创建第一个模板",
"emptyTitle": "还没有常用提示词",
"emptyDescription": "把经常用的提示词存起来,比如“画一张……的 AWS 架构图”,之后会出现在这里。",
"createFirst": "保存第一个提示词",
"neverUsed": "未使用过",
"usedCount": "使用 {count} 次",
"myTemplates": "我的模板",
"createTitle": "创建模板",
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
"myTemplates": "常用提示词",
"createTitle": "保存提示词",
"createDescription": "把提示词存起来,以后再用。",
"promptLabel": "提示词",
"promptPlaceholder": "描述您想要创建的图表...",
"promptRequired": "提示词不能为空",
@@ -310,33 +302,34 @@
"titlePlaceholder": "输入标题",
"titleHint": "留空则使用提示词前 20 个字符作为标题",
"descriptionLabel": "描述",
"descriptionPlaceholder": "为此模板添加描述",
"pinnedLabel": "置顶模板",
"pinnedHint": "置顶的模板会显示在列表顶部",
"createButton": "创建模板",
"createFailed": "创建模板失败,请重试。",
"editTitle": "编辑模板",
"editDescription": "更新模板内容和设置。",
"updateFailed": "更新模板失败,请重试。",
"descriptionPlaceholder": "这个提示词的用途",
"pinnedLabel": "置顶",
"pinnedHint": "置顶的提示词显示在列表最上面",
"createButton": "保存",
"createFailed": "保存提示词失败,请重试。",
"editTitle": "编辑提示词",
"editDescription": "修改提示词和它的设置。",
"updateFailed": "更新提示词失败,请重试。",
"duplicate": "复制",
"copySuffix": "(副本)",
"deleteTitle": "删除此模板?",
"deleteDescription": "此操作将永久删除该模板,无法撤销。",
"deleteTitle": "删除这个提示词?",
"deleteDescription": "提示词会被永久删除。",
"confirmSendTitle": "替换当前输入?",
"confirmSendDescription": "您有未发送的内容。发送此模板将替换它。",
"confirmSendButton": "发送模板",
"searchPlaceholder": "搜索模板...",
"searchNoResults": "没有匹配的模板",
"confirmSendDescription": "输入框里有还没发送的内容,发送这个提示词会替换它。",
"confirmSendButton": "发送",
"searchPlaceholder": "搜索提示词…",
"searchNoResults": "没有匹配的提示词",
"pin": "置顶",
"unpin": "取消置顶",
"saveAsTemplate": "保存为模板",
"exportTemplates": "导出模板",
"importTemplates": "导入模板",
"exportEmpty": "没有可导出的模板",
"exportSuccess": "成功导出 {count} 个模板",
"saveAsTemplate": "保存这个提示词",
"exportTemplates": "导出提示词",
"importTemplates": "导入提示词",
"exportEmpty": "没有可导出的提示词",
"exportSuccess": "已导出 {count} 个提示词",
"importNoFile": "请选择一个 JSON 文件",
"importFailed": "导入失败:{error}",
"importSuccess": "成功导入 {imported} 个模板,跳过 {skipped} 个重复"
"importSuccess": "已导入 {imported} 个提示词,跳过 {skipped} 个重复",
"saveDisabledHint": "先在输入框里写好提示词"
},
"validation": {
"title": "验证图表",
@@ -357,8 +350,6 @@
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型",
"configure": "配置",
"addProvider": "添加提供商",
"addModel": "添加模型",
@@ -375,10 +366,12 @@
"validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证",
"providers": "提供商",
"addProviderHint": "添加提供商即可开始使用",
"addProviderHint": "还没有服务商",
"verified": "已验证",
"configuration": "配置",
"displayName": "显示名称",
"bedrockApiKey": "Bedrock API 密钥",
"bedrockApiKeyHint": "填了 API 密钥,下面的访问密钥就不用填。",
"awsAccessKeyId": "AWS 访问密钥 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域",
@@ -404,13 +397,14 @@
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"configureProviders": "设置 AI 服务商",
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认",
"typeProviderName": "输入提供商名称...",
"modelsConfiguredCount": "已配置 {count} 个模型",
"validationFailedCount": "{count} 个模型验证失败",
"requestFailed": "请求失败({status})",
"cancel": "取消",
"delete": "删除",
"clickToChange": "(点击更改)",
@@ -430,7 +424,9 @@
"unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型",
"showValue": "显示值",
"hideValue": "隐藏值"
"hideValue": "隐藏值",
"noProvidersTitle": "正在使用服务器上配置的模型",
"noProvidersHint": "想用其他模型,可以在左侧列表下方添加服务商并填写自己的 API Key。"
},
"admin": {
"title": "管理员设置",
@@ -575,5 +571,81 @@
"description": "每日重置边界所用的时区。"
}
}
},
"workspace": {
"export": "导出",
"openFile": "打开 .drawio 文件",
"canvasLoading": "正在加载画布…",
"askAi": "让 AI 改这里",
"untitled": "未命名图纸",
"mobileCanvas": "画布",
"mobileChat": "对话"
},
"selection": {
"one": "已选:{label}",
"many": "已选 {count} 个图形",
"shape": "图形",
"connector": "连线",
"remove": "不发送选中的图形",
"placeholder": "说说要怎么改选中的图形"
},
"versions": {
"strip": "版本",
"created": "画好了图表",
"edited": "改好了图表",
"undone": "这次修改已撤销",
"undo": "撤销修改",
"redo": "重做修改",
"compare": "对比",
"compareTitle": "对比版本",
"currentCanvas": "现在的画布",
"versionLabel": "第 {n} 版",
"previous": "上一版",
"next": "下一版",
"rendering": "正在生成预览…",
"showCode": "查看 XML",
"hideCode": "收起 XML",
"drawing": "正在画图…",
"editing": "正在修改图表…",
"loadingLibrary": "正在读取图形库…",
"libraryLoaded": "读取了图形库",
"failed": "这次没有成功",
"failedRetrying": "这次没有成功,正在重试",
"stopped": "已停止",
"truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 高级 里调高最大输出长度。",
"noChanges": "没有可见的变化",
"listSeparator": ",",
"shapesAddedOne": "新增 1 个图形",
"shapesAddedOther": "新增 {count} 个图形",
"edgesAddedOne": "新增 1 条连线",
"edgesAddedOther": "新增 {count} 条连线",
"shapesChangedOne": "改了 1 个图形",
"shapesChangedOther": "改了 {count} 个图形",
"edgesChangedOne": "改了 1 条连线",
"edgesChangedOther": "改了 {count} 条连线",
"shapesRemovedOne": "删了 1 个图形",
"shapesRemovedOther": "删了 {count} 个图形",
"edgesRemovedOne": "删了 1 条连线",
"edgesRemovedOther": "删了 {count} 条连线",
"totals": "共 {shapes}、{edges}",
"shapesTotalOne": "1 个图形",
"shapesTotalOther": "{count} 个图形",
"edgesTotalOne": "1 条连线",
"edgesTotalOther": "{count} 条连线",
"restoreVersion": "恢复到 v{n}",
"restoredVersion": "已恢复到 v{n}",
"undoRestore": "撤销",
"noPreview": "没有预览",
"compareSince": "从第 {n} 版到现在,画布上:",
"isOnCanvas": "画布现在显示的就是这一版。",
"differsElsewhere": "第一页的形状和连线与第 {n} 版相同,叠放顺序、其他页面或页面设置有改动。"
},
"lobby": {
"title": "说说你要画什么",
"lede": "描述一张图,或者上传截图、PDF。画出来的是标准的 draw.io 图表,可以接着改,也可以导出。",
"placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试",
"recent": "最近的图纸",
"drawYourself": "从空白画布开始",
"openFileHint": "接着编辑已有的图"
}
}
+7
View File
@@ -0,0 +1,7 @@
/** True on macOS and iOS, where shortcuts use ⌘ instead of Ctrl */
export const isMac =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad/.test(navigator.userAgent)
/** Prefix for shortcut labels: "⌘" on Apple platforms, "Ctrl+" elsewhere */
export const modKey = isMac ? "⌘" : "Ctrl+"
+20
View File
@@ -0,0 +1,20 @@
/** "3 min ago", "yesterday", or a short date, in the UI language */
export function formatRelativeTime(
timestamp: number,
locale: string,
justNow: string,
): string {
const diffMs = Date.now() - timestamp
const minutes = Math.floor(diffMs / 60_000)
if (minutes < 1) return justNow
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
if (minutes < 60) return rtf.format(-minutes, "minute")
const hours = Math.floor(minutes / 60)
if (hours < 24) return rtf.format(-hours, "hour")
const days = Math.floor(hours / 24)
if (days < 7) return rtf.format(-days, "day")
return new Date(timestamp).toLocaleDateString(locale, {
month: "short",
day: "numeric",
})
}
+30 -6
View File
@@ -1,6 +1,7 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
import { toast } from "sonner"
import type { DiagramVersion } from "@/stores/versions-store"
import type { Template } from "./template-storage"
// Constants
@@ -20,13 +21,18 @@ export interface ChatSession {
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string // Small PNG preview of the diagram
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
// Old version list (before AI versions existed); read once, then replaced
diagramHistory?: { svg: string; xml: string }[]
// Diagram states produced by AI changes, shown as cards in the chat
versions?: DiagramVersion[]
}
export interface StoredMessage {
id: string
role: "user" | "assistant" | "system"
parts: Array<{ type: string; [key: string]: unknown }>
// e.g. the shapes selected when a user message was sent
metadata?: unknown
}
export interface SessionMetadata {
@@ -214,13 +220,17 @@ export async function readSessionCount(): Promise<number | null> {
}
}
export async function deleteOldestSession(): Promise<void> {
// keepId: a chat being opened, which stays
export async function deleteOldestSession(keepId?: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
let cursor = await index.openCursor()
while (cursor && cursor.value.id === keepId) {
cursor = await cursor.continue()
}
if (cursor) {
await cursor.delete()
}
@@ -230,17 +240,30 @@ export async function deleteOldestSession(): Promise<void> {
}
}
// Enforce max sessions limit
export async function enforceSessionLimit(): Promise<void> {
// Enforce max sessions limit. keep: the chat being opened, asked for right
// before each deletion (it can be clicked while the count is read)
export async function enforceSessionLimit(
keep?: () => string | undefined,
): Promise<void> {
const count = await getSessionCount()
if (count > MAX_SESSIONS) {
const toDelete = count - MAX_SESSIONS
for (let i = 0; i < toDelete; i++) {
await deleteOldestSession()
await deleteOldestSession(keep?.())
}
}
}
export async function renameSession(
id: string,
title: string,
): Promise<ChatSession | null> {
const session = await getSession(id)
if (!session) return null
const updated = { ...session, title, updatedAt: Date.now() }
return (await saveSession(updated)) ? updated : null
}
// Helper: Create a new empty session
export function createEmptySession(): ChatSession {
return {
@@ -300,6 +323,7 @@ export function sanitizeMessage(message: unknown): StoredMessage | null {
id: msg.id as string,
role: role as "user" | "assistant" | "system",
parts,
...(msg.metadata !== undefined && { metadata: msg.metadata }),
}
}
+11 -20
View File
@@ -1,26 +1,19 @@
// Centralized localStorage keys for quota tracking and settings
// Chat data is now stored in IndexedDB via session-storage.ts
// Centralized localStorage keys for settings.
// Chat data is stored in IndexedDB via session-storage.ts
export const STORAGE_KEYS = {
// Quota tracking
requestCount: "next-ai-draw-io-request-count",
requestDate: "next-ai-draw-io-request-date",
tokenCount: "next-ai-draw-io-token-count",
tokenDate: "next-ai-draw-io-token-date",
tpmCount: "next-ai-draw-io-tpm-count",
tpmMinute: "next-ai-draw-io-tpm-minute",
// Settings
// Access code for password-protected deployments
accessCode: "next-ai-draw-io-access-code",
accessCodeRequired: "next-ai-draw-io-access-code-required",
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
// Multi-model configuration
modelConfigs: "next-ai-draw-io-model-configs",
selectedModelId: "next-ai-draw-io-selected-model-id",
// Appearance
darkMode: "next-ai-draw-io-dark-mode",
locale: "next-ai-draw-io-locale",
minimalStyle: "next-ai-draw-io-minimal-style",
panelWidth: "next-ai-draw-io-panel-width",
// Chat input preferences
sendShortcut: "next-ai-draw-io-send-shortcut",
@@ -34,8 +27,6 @@ export const STORAGE_KEYS = {
// Output token budget per turn (empty = server default)
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
// 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",
// Langfuse session id
sessionId: "next-ai-draw-io-session-id",
} as const
+23 -4
View File
@@ -2,6 +2,7 @@
import { useState } from "react"
import { toast } from "sonner"
import { formatMessage } from "@/lib/i18n/utils"
import {
extractPdfText,
extractTextFileContent,
@@ -16,11 +17,24 @@ export interface FileData {
isExtracting: boolean
}
/** Toast texts; {name}, {limit} and {size} are filled in */
export interface FileProcessorMessages {
tooLong: string
readFailed: string
}
const DEFAULT_MESSAGES: FileProcessorMessages = {
tooLong: "{name}: Content exceeds {limit}k character limit ({size}k chars)",
readFailed: "Failed to read file: {name}",
}
/**
* Hook for processing file uploads, especially PDFs and text files.
* Handles text extraction, character limit validation, and cleanup.
*/
export function useFileProcessor() {
export function useFileProcessor(
messages: FileProcessorMessages = DEFAULT_MESSAGES,
) {
const [files, setFiles] = useState<File[]>([])
const [pdfData, setPdfData] = useState<Map<File, FileData>>(new Map())
@@ -58,9 +72,12 @@ export function useFileProcessor() {
// Check character limit
if (text.length > MAX_EXTRACTED_CHARS) {
const limitK = MAX_EXTRACTED_CHARS / 1000
toast.error(
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
formatMessage(messages.tooLong, {
name: file.name,
limit: MAX_EXTRACTED_CHARS / 1000,
size: (text.length / 1000).toFixed(1),
}),
)
setPdfData((prev) => {
const next = new Map(prev)
@@ -85,7 +102,9 @@ export function useFileProcessor() {
})
} catch (error) {
console.error("Failed to extract text:", error)
toast.error(`Failed to read file: ${file.name}`)
toast.error(
formatMessage(messages.readFailed, { name: file.name }),
)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
+3 -7
View File
@@ -341,13 +341,9 @@ export function extractDiagramXML(xml_svg_string: string): string {
throw new Error("SVG element does not have a 'content' attribute.")
}
// 3. Decode HTML entities (using a minimal function)
function decodeHtmlEntities(str: string) {
const textarea = document.createElement("textarea") // Use built-in element
textarea.innerHTML = str
return textarea.value
}
const xmlContent = decodeHtmlEntities(encodedContent)
// 3. getAttribute already decoded the entities: decoding again
// would break values that hold them (a page named "R &amp; D")
const xmlContent = encodedContent
// 4. Parse the XML content
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
+47
View File
@@ -0,0 +1,47 @@
import type { ChangeSummary } from "@/lib/diagram-diff"
import { formatMessage } from "@/lib/i18n/utils"
type VersionDict = Record<string, string>
const PARTS: [keyof ChangeSummary, string][] = [
["shapesAdded", "shapesAdded"],
["edgesAdded", "edgesAdded"],
["shapesChanged", "shapesChanged"],
["edgesChanged", "edgesChanged"],
["shapesRemoved", "shapesRemoved"],
["edgesRemoved", "edgesRemoved"],
]
/**
* "Added 2 shapes, changed 1 connector". Each dictionary entry has a
* singular (...One) and plural (...Other) form; languages without plurals
* use the same text for both.
*/
export function describeChanges(
summary: ChangeSummary,
t: VersionDict,
): string {
const pieces = PARTS.filter(([field]) => summary[field] > 0).map(
([field, key]) => {
const count = summary[field]
const template = count === 1 ? t[`${key}One`] : t[`${key}Other`]
return formatMessage(template ?? "", { count })
},
)
if (pieces.length === 0) return t.noChanges ?? ""
const text = pieces.join(t.listSeparator ?? ", ")
// Sentence case for scripts with letter case
return text.charAt(0).toUpperCase() + text.slice(1)
}
/** "3 shapes, 1 connector": what a diagram drawn from scratch holds */
export function describeTotals(summary: ChangeSummary, t: VersionDict): string {
const count = (n: number, key: string) =>
formatMessage((n === 1 ? t[`${key}One`] : t[`${key}Other`]) ?? "", {
count: n,
})
return formatMessage(t.totals ?? "", {
shapes: count(summary.shapesAdded, "shapesTotal"),
edges: count(summary.edgesAdded, "edgesTotal"),
})
}