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

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
- the app's own canvas toolbar, shape library and page tabs replace
  draw.io's chrome
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- phones switch between canvas and chat
- dark mode keeps the diagram on a white sheet and no longer flashes

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to
draw.io's own toolbar and 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-06 16:17:11 +09:00
parent 18a20aa1ca
commit 23d5c83b0d
105 changed files with 12499 additions and 7770 deletions
+31
View File
@@ -133,3 +133,34 @@ 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 ""
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
}
+22
View File
@@ -0,0 +1,22 @@
/** 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)
}
}
}
+220
View File
@@ -0,0 +1,220 @@
import pako from "pako"
/** 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[]
}
export const EMPTY_SUMMARY: ChangeSummary = {
shapesAdded: 0,
shapesRemoved: 0,
shapesChanged: 0,
edgesAdded: 0,
edgesRemoved: 0,
edgesChanged: 0,
}
interface CellInfo {
isEdge: boolean
signature: string
}
/** Inflate a compressed <diagram> payload (base64, raw deflate, URI-encoded) */
function inflateDiagram(text: string): string | null {
try {
const binary = atob(text.trim())
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const inflated = pako.inflateRaw(bytes, { to: "string" })
return decodeURIComponent(inflated)
} catch {
return null
}
}
/** 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 = inflateDiagram(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
}
/**
* The document with every compressed page inflated in place, as draw.io
* saves it after an edit. Returned unchanged when there is nothing to do.
*/
export function inflatePages(xml: string): string {
const doc = parse(xml)
if (!doc) return xml
let changed = false
for (const diagram of Array.from(doc.getElementsByTagName("diagram"))) {
if (diagram.querySelector("mxGraphModel")) continue
const model = modelOfPage(diagram)
if (!model) continue
diagram.textContent = ""
diagram.appendChild(doc.importNode(model, true))
changed = true
}
return changed ? new XMLSerializer().serializeToString(doc) : xml
}
/**
* 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 and style
* entries differently
*/
function signatureOf(node: Element): string {
const attrs = Array.from(node.attributes)
.map((a) => {
if (a.name !== "style") return `${a.name}=${a.value}`
const entries = a.value
.split(";")
.map((e) => e.trim())
.filter(Boolean)
.sort()
return `style=${entries.join(";")}`
})
.sort()
const children = Array.from(node.children).map(signatureOf)
return `<${node.tagName} ${attrs.join(" ")}>${children.join("")}</>`
}
function collectCells(xml: string): Map<string, CellInfo> {
return cellsOf(firstPageModel(xml))
}
function cellsOf(model: Element | null): Map<string, CellInfo> {
const cells = new Map<string, CellInfo>()
if (!model) return cells
for (const cell of Array.from(model.getElementsByTagName("mxCell"))) {
// 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 || id === "0" || id === "1") continue
const node = isWrapped && wrapper ? wrapper : cell
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 }
}
export function isEmptySummary(summary: ChangeSummary): boolean {
return Object.values(summary).every((n) => n === 0)
}
/** Count of shapes and connectors on the first page */
export function countCells(xml: string): { shapes: number; edges: number } {
let shapes = 0
let edges = 0
for (const cell of collectCells(xml).values()) {
if (cell.isEdge) edges++
else shapes++
}
return { shapes, edges }
}
// Where the view was scrolled to, saved with the page; not a change
const VIEW_ATTRIBUTES = new Set(["dx", "dy"])
/**
* Whether two documents hold the same pages (names and cells) and page
* settings. A setting written on one side only is draw.io's default being
* filled in, so only settings both sides have are compared.
*/
export function isSameDocument(a: string, b: string): boolean {
const pagesOf = (xml: string) => {
const doc = parse(xml)
if (!doc) return null
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 pagesA = pagesOf(a)
const pagesB = pagesOf(b)
if (!pagesA || !pagesB) return a === b
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) {
for (const attr of Array.from(pageA.model.attributes)) {
if (VIEW_ATTRIBUTES.has(attr.name)) continue
const other = pageB.model.getAttribute(attr.name)
if (other !== null && other !== attr.value) return false
}
}
const cellsA = cellsOf(pageA.model)
const cellsB = cellsOf(pageB.model)
if (cellsA.size !== cellsB.size) return false
for (const [id, cell] of cellsA) {
if (cellsB.get(id)?.signature !== cell.signature) return false
}
return true
})
}
-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)
)
}
+118
View File
@@ -0,0 +1,118 @@
import { getAssetUrl } from "@/lib/base-path"
import type { Locale } from "@/lib/i18n/config"
/** External draw.io configured at build time (cross-origin, limited features) */
export 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,
}: {
lang: Locale
isElectron: boolean
}): string {
// react-drawio used `new URL(baseUrl)`, so keep an absolute URL, and
// include index.html because 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",
// The diagram always renders on a light sheet; dark mode only
// recolors the table around it
dark: "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(
'<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></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,
}
/**
* CSS injected into draw.io. `customChrome` hides draw.io's own toolbar and
* page tabs because the app draws its own over the canvas.
*/
export function getDrawioCss({
dark,
customChrome,
}: {
dark: boolean
customChrome: boolean
}): string {
const workspace = dark ? "#0f2440" : "#f4f5f7"
// draw.io itself stays light, so its focus color does too
const accent = "#14181d"
let css = `
:root {
--workspace-color: ${workspace};
--focus-color: ${accent};
}
.geDiagramContainer { background-color: ${workspace}; }
.geBackgroundPage { box-shadow: 0 1px 2px rgba(20,24,29,.08), 0 12px 32px -12px rgba(20,24,29,${dark ? ".5" : ".18"}) !important; }
`
if (customChrome) {
css += `
.geSimpleMainMenu, .geTabContainer, .geFooterContainer, .geStatus,
.geToolbarContainer:not(.geFormatContainer *), .geHsplit,
.geSidebarContainer:not(.geFormatContainer) { display: none !important; }
.geDiagramContainer { top: 0 !important; bottom: 0 !important; left: 0 !important; border: 0 !important; }
.geFormatContainer { top: 64px !important; bottom: 64px !important; border-radius: 14px 0 0 14px; box-shadow: 0 10px 30px -8px rgba(20,24,29,.2); border: 0 !important; }
body.nai-shapes-open .geSidebarContainer:not(.geFormatContainer) { display: block !important; top: 64px !important; bottom: 64px !important; left: 12px !important; width: 248px !important; transform: none !important; border-radius: 14px; box-shadow: 0 10px 30px -8px rgba(20,24,29,.2); border: 0 !important; z-index: 3; }
`
} else {
css += `
.geSimpleMainMenu { border-bottom: 1px solid #e4e7eb !important; box-shadow: none !important; }
`
}
return css
}
+738
View File
@@ -0,0 +1,738 @@
/**
* 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 { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
import { useVersionsStore } from "@/stores/versions-store"
type EditorUi = any
type FrameWindow = any
let ui: EditorUi | null = null
let win: FrameWindow | null = null
let frameElement: HTMLIFrameElement | 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,
frame: HTMLIFrameElement,
) {
if (ui === editorUi) return
detachEditor()
ui = editorUi
win = frameWindow
frameElement = frame
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 app's toolbar opens them, 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 sometimes the shape library) on start;
// the app opens them on demand from its own toolbar
function closeSidePanels() {
try {
if (isFormatPanelOpen()) ui.actions?.get?.("format")?.funct()
} catch {
// ignore
}
try {
if (ui.sidebarWindow?.window?.isVisible?.()) {
ui.sidebarWindow.window.setVisible(false)
}
} catch {
// ignore
}
}
export function detachEditor() {
detachListeners?.()
detachListeners = null
clearHighlights()
ui = null
win = null
frameElement = null
previewBase = null
useCanvasStore.getState().set({
hasEditor: false,
selection: [],
selectionRect: null,
canUndo: false,
canRedo: false,
})
}
export function hasEditor() {
return ui !== null
}
/** Ids of the cells on the current page (development checks only) */
/** Development only: the view's numbers, for end-to-end checks */
export function debugView() {
const g = graph()
if (!g) return null
return {
scale: g.view.scale,
tx: g.view.translate.x,
ty: g.view.translate.y,
scrollLeft: g.container.scrollLeft,
scrollTop: g.container.scrollTop,
width: g.container.clientWidth,
hsplit: ui?.hsplitPosition,
containerLeft: g.container.style.left,
}
}
export function debugCellIds(): string[] {
const g = graph()
if (!g) return []
return Object.keys(g.model.cells ?? {}).filter(
(id) => id !== "0" && id !== "1",
)
}
function graph() {
return ui?.editor?.graph ?? null
}
// ---------------------------------------------------------------------------
// Actions (used by the toolbar)
// ---------------------------------------------------------------------------
export function hasAction(name: string): boolean {
if (!ui) return false
const action = ui.actions?.get?.(name)
if (!action) {
warnOnce(`action:${name}`, `Action "${name}" not found, hiding it`)
return false
}
return true
}
export function runAction(name: string) {
const action = ui?.actions?.get?.(name)
if (!action) return
try {
action.funct()
} catch (error) {
console.warn(`[drawio] Action "${name}" failed:`, error)
}
// Some actions change state without firing an event we listen to
setTimeout(syncAll, 0)
}
/** Whether the grid and the page view are on (for the menu's check marks) */
export function viewToggles(): { grid: boolean; pageView: boolean } {
const g = graph()
return { grid: !!g?.isGridEnabled?.(), pageView: !!g?.pageVisible }
}
export function focusCanvas() {
try {
frameElement?.focus()
graph()?.container?.focus()
} catch {
// ignore
}
}
export function stopFreehand() {
const freehand = graph()?.freehand
if (freehand?.isDrawing?.()) freehand.stopDrawing()
syncAll()
}
// ---------------------------------------------------------------------------
// 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)
}
function isEmptyModel(): boolean {
const g = graph()
if (!g) return true
const root = g.model.getRoot()
const layer = root ? g.model.getChildAt(root, 0) : null
return !layer || g.model.getChildCount(layer) === 0
}
/** 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
return pageCount <= 1 && toSinglePageModel(xml) !== null
}
/** 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 of the page in a single-page mxfile, if it has one */
function pageName(xml: string): string | null {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (doc.documentElement?.nodeName !== "mxfile") return null
return doc.getElementsByTagName("diagram")[0]?.getAttribute("name") || null
}
/**
* Apply the final AI result (or a restored version) as a single undo step,
* page name included. 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 && isEmptyXml(previewBase))
if (previewBase !== null) {
const base = previewBase
previewBase = null
// Undo then goes back to the diagram before streaming started
if (base && toSinglePageModel(base)) withoutUndo(() => replace(base))
}
const model = graph()?.model
const name = pageName(xml)
const page = ui?.currentPage
model?.beginUpdate()
try {
replace(xml)
if (name && page && win?.RenamePage && page.getName?.() !== name) {
model.execute(new win.RenamePage(ui, page, name))
}
} finally {
model?.endUpdate()
}
if (wasEmpty) fitDiagram()
}
/** Throw away the preview and go back to the given diagram */
export function revertPreview(targetXml?: string) {
const base = targetXml ?? previewBase ?? null
previewBase = null
if (base) withoutUndo(() => replace(base))
}
/** Forget the preview base (new user turn) without changing the canvas */
export function resetPreview() {
previewBase = null
}
function isEmptyXml(xml: string | null): boolean {
if (!xml) return true
return !/<mxCell\b[^>]*\bid=["'](?!0["']|1["'])/.test(xml)
}
// 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: the app's toolbar sits over the top of the
// canvas and the page tabs over the bottom (half of this on each side)
const FIT_BORDER = 128
/**
* Fit the shapes (not the whole page) into the canvas, at most at 100%,
* clear of the toolbar and the page tabs
*/
export 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()
syncZoom()
}
/** 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,
}
}
export function clearSelection() {
graph()?.clearSelection()
}
// ---------------------------------------------------------------------------
// 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}`),
}))
}
function findPage(id: string) {
return ((ui?.pages ?? []) as any[]).find(
(page) => String(page.getId?.()) === id,
)
}
export function selectPage(id: string) {
const page = findPage(id)
if (page && typeof ui.selectPage === "function") ui.selectPage(page)
syncAll()
}
export function addPage() {
runAction("insertPage")
}
export function renamePage(id: string) {
const page = findPage(id)
if (page && typeof ui.renamePage === "function") ui.renamePage(page)
}
export function duplicatePage(id: string) {
const page = findPage(id)
if (page && typeof ui.duplicatePage === "function") ui.duplicatePage(page)
syncAll()
}
export function removePage(id: string) {
const page = findPage(id)
if (page && typeof ui.removePage === "function") ui.removePage(page)
syncAll()
}
export function canManagePages(): boolean {
return (
!!ui && Array.isArray(ui.pages) && typeof ui.selectPage === "function"
)
}
// ---------------------------------------------------------------------------
// Keeping the store in sync
// ---------------------------------------------------------------------------
function syncZoom() {
const g = graph()
if (!g) return
useCanvasStore.getState().set({ zoom: Math.round(g.view.scale * 100) })
}
function syncSelectionRect() {
useCanvasStore.getState().set({ selectionRect: readSelectionRect() })
}
function syncAll() {
if (!ui) return
const g = graph()
const manager = ui.editor?.undoManager
useCanvasStore.getState().set({
canUndo: !!manager?.canUndo?.(),
canRedo: !!manager?.canRedo?.(),
zoom: g ? Math.round(g.view.scale * 100) : 100,
pages: readPages(),
currentPageId: ui.currentPage?.getId
? String(ui.currentPage.getId())
: null,
selection: readSelection(),
selectionRect: readSelectionRect(),
isFreehand: !!g?.freehand?.isDrawing?.(),
isFormatOpen: isFormatPanelOpen(),
isShapesOpen: isShapesPanelOpen(),
})
}
// draw.io keeps its docked shape library "visible" from the start; the app's
// CSS hides it unless the frame's body has this class
export const SHAPES_OPEN_CLASS = "nai-shapes-open"
function isShapesPanelOpen(): boolean {
return !!win?.document?.body?.classList.contains(SHAPES_OPEN_CLASS)
}
/** Show or hide draw.io's shape library, floating over the canvas */
export function toggleShapesPanel() {
const body = win?.document?.body
if (!body) return
// Only the class: draw.io treats its docked library as open from the
// start, and lays the canvas out next to it while it shows
body.classList.toggle(SHAPES_OPEN_CLASS, !isShapesPanelOpen())
syncAll()
}
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 onModelChange = () => {
syncAll()
}
// An undo step was added, undone or redone: the canvas no longer shows
// a known version. The app's own commits add a step too, and set the
// version flags again right after.
const onUndoableChange = () => {
useVersionsStore.getState().clearCanvasFlags()
syncAll()
}
const onView = () => {
syncZoom()
syncSelectionRect()
}
const onScale = () => {
zoomChanged()
onView()
}
listen(g.getSelectionModel(), mxEvent.CHANGE, onSelection)
listen(g.model, mxEvent.CHANGE, onModelChange)
listen(ui.editor?.undoManager, mxEvent.ADD, onUndoableChange)
listen(ui.editor?.undoManager, mxEvent.UNDO, onUndoableChange)
listen(ui.editor?.undoManager, mxEvent.REDO, onUndoableChange)
listen(ui.editor?.undoManager, mxEvent.CLEAR, syncAll)
listen(g.view, mxEvent.SCALE, onScale)
listen(g.view, mxEvent.TRANSLATE, onView)
listen(g.view, mxEvent.SCALE_AND_TRANSLATE, onScale)
listen(ui.editor, "pageSelected", syncAll)
listen(ui, "pageSelected", syncAll)
listen(ui, "formatWidthChanged", 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),
)
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
}
+227 -116
View File
@@ -13,12 +13,12 @@
"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 +39,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",
@@ -58,24 +57,28 @@
"badResponse": "Bad response",
"clickToEdit": "Click to edit",
"editMessage": "Edit message",
"saveAndSubmit": "Save & Submit",
"ExtractURL": "Extract from URL"
"saveAndSubmit": "Save & submit",
"ExtractURL": "Add a web page",
"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"
},
@@ -84,7 +87,7 @@
"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 +104,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 +115,59 @@
"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.",
"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.",
"enabled": "Enabled",
"disabled": "Disabled",
"customSystemMessage": "Custom System Message",
"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",
"exportButton": "Export",
"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",
"clearEverything": "Clear everything",
"clearSuccess": "Started a fresh chat"
},
"errors": {
@@ -189,6 +192,9 @@
"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}",
"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.",
@@ -220,18 +226,13 @@
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
"reset": "Your limit resets tomorrow. Thanks for understanding.",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
"configModel": "Use Your API Key",
"configModel": "Use your API key",
"selfHost": "Self-host",
"sponsor": "Sponsor",
"learnMore": "Learn more →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "Generate Diagram",
"editDiagram": "Edit Diagram",
"appendDiagram": "Continue Diagram",
"complete": "Complete",
"error": "Error",
"truncated": "Truncated"
},
"file": {
@@ -240,13 +241,14 @@
"removeFile": "Remove file"
},
"url": {
"title": "Extract Content from URL",
"title": "Add content from a web page",
"description": "Paste a URL to extract and analyze its content",
"Extracting": "Extracting...",
"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...",
@@ -277,32 +279,34 @@
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
},
"sessionHistory": {
"tooltip": "Chat History",
"tooltip": "Your diagrams",
"newChat": "New Chat",
"empty": "No chat history yet",
"emptyHint": "Start a conversation to begin",
"empty": "No diagrams yet",
"emptyHint": "Describe a diagram to start",
"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.",
"recentChats": "Recent diagrams",
"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",
"title": "Saved prompts",
"subtitle": "Prompts you reuse, one click away",
"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,42 +314,43 @@
"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",
"title": "Diagram check",
"capturing": "Capturing",
"validating": "Validating",
"validatingWithAttempt": "Validating ({attempt}/{max})",
"valid": "Valid",
"validWithWarnings": "Valid with Warnings",
"issuesFound": "Issues Found",
"validWithWarnings": "Looks fine, with notes",
"issuesFound": "Issues found",
"error": "Error",
"skipped": "Skipped",
"capturedScreenshot": "Captured Screenshot:",
@@ -353,20 +358,20 @@
"suggestions": "Suggestions:",
"passedValidation": "Diagram passed visual validation - no issues detected.",
"improvementRequested": "Improvement requested - check the new diagram below",
"improveWithSuggestions": "Improve with Suggestions",
"improveWithSuggestions": "Improve with these notes",
"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",
"addProvider": "Add provider",
"addModel": "Add model",
"modelId": "Model ID",
"modelLabel": "Display Label",
"streaming": "Enable Streaming",
"deleteProvider": "Delete Provider",
"deleteModel": "Delete Model",
"deleteProvider": "Delete provider",
"deleteModel": "Delete model",
"noModels": "No models configured. Add a model to get started.",
"selectProvider": "Select a provider or add a new one",
"configureMultiple": "Configure multiple AI providers and switch between them easily",
@@ -375,15 +380,15 @@
"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",
"displayName": "Display name",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region",
"awsRegion": "AWS region",
"selectRegion": "Select region",
"apiKey": "API Key",
"apiKey": "API key",
"enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
@@ -404,7 +409,7 @@
"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",
@@ -415,22 +420,24 @@
"delete": "Delete",
"clickToChange": "(click to change)",
"usingServerDefault": "Using server default model",
"selectModel": "Select Model",
"selectModel": "Select model",
"searchModels": "Search models...",
"noVerifiedModels": "No verified models. Test your models first.",
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server Default",
"serverModels": "Server Models",
"userModels": "User Models",
"serverDefault": "Server default",
"serverModels": "Server models",
"userModels": "Your models",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"showUnvalidatedModels": "Show models that haven't been tested",
"allModelsShown": "All models are shown (including unvalidated)",
"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 +582,109 @@
"description": "Timezone for the daily reset boundary."
}
}
},
"workspace": {
"toolbar": "Canvas tools",
"undo": "Undo",
"redo": "Redo",
"select": "Select",
"shapes": "Shapes",
"text": "Text",
"connector": "Connector",
"freehand": "Freehand",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"zoomMenu": "Zoom",
"fitWindow": "Fit to window",
"zoomReset": "Actual size",
"style": "Style",
"more": "More",
"export": "Export",
"openFile": "Open .drawio file",
"findReplace": "Find and replace",
"layers": "Layers",
"outline": "Outline",
"toggleGrid": "Grid",
"togglePageView": "Page view",
"insertImage": "Insert image",
"insertLink": "Insert link",
"selectAll": "Select all",
"editDiagram": "Edit diagram XML",
"print": "Print",
"addPage": "Add page",
"renamePage": "Rename",
"duplicatePage": "Duplicate",
"deletePage": "Delete page",
"canvasLoading": "Loading the canvas…",
"askAi": "Ask AI to change this",
"untitled": "Untitled diagram",
"newDiagram": "New 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",
"restore": "Restore",
"restored": "Restored this version",
"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} shapes, {edges} 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 first page is the same as in version {n}; other pages or the page settings have changed."
},
"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"
}
}
+208 -97
View File
@@ -13,11 +13,11 @@
"nav": {
"about": "概要",
"editor": "エディタ",
"newChat": "新しいチャットを開始",
"newChat": "新しい図",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
"hidePanel": "チャットパネルを非表示",
"showPanel": "チャットパネルを表示",
"aiChat": "AI チャット"
},
"providers": {
@@ -39,12 +39,11 @@
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"inputLabel": "チャット入力",
"send": "送信",
"stopGeneration": "生成を停止",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"diagramHistory": "ダイアグラム履歴",
"saveDiagram": "ダイアグラムを保存",
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
"minimalStyle": "ミニマル",
"styledMode": "スタイル付き",
@@ -59,23 +58,27 @@
"clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信",
"ExtractURL": "URLから抽出"
"ExtractURL": "Web ページを追加",
"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 で使用"
},
@@ -101,17 +104,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,41 +121,48 @@
"httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました",
"diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"diagramValidation": "スクリーンショットで図をチェック(実験的)",
"diagramValidationDescription": "描くたびに、画像を読めるモデルがスクリーンショットを確認し、修正を求めます。画像を読めるモデルが必要です。",
"enabled": "有効",
"disabled": "無効",
"customSystemMessage": "カスタムシステムメッセージ",
"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": "エクスポートしました",
"exportButton": "エクスポート",
"hints": {
"drawio": "すべて編集できるまま保存",
"png": "スライドや文書向けの画像",
"svg": "拡大してもぼやけない画像",
"xmlsvg": "draw.io でも開ける画像"
}
},
"dialogs": {
"clearTitle": "すべてクリアしますか?",
@@ -189,6 +192,9 @@
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
"notADiagramFile": "{name} は draw.io の図ではありません。",
"fileTooLong": "{name}:内容が上限の {limit}k 文字を超えています({size}k 文字)",
"fileReadFailed": "ファイルを読み込めませんでした:{name}",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
@@ -220,18 +226,13 @@
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
"configModel": "APIキーを使用",
"configModel": "自分の API キーを使う",
"selfHost": "セルフホスト",
"sponsor": "スポンサー",
"learnMore": "詳細 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "ダイアグラムを生成",
"editDiagram": "ダイアグラムを編集",
"appendDiagram": "ダイアグラムに追加",
"complete": "完了",
"error": "エラー",
"truncated": "切り捨て"
},
"file": {
@@ -240,13 +241,14 @@
"removeFile": "ファイルを削除"
},
"url": {
"title": "URLからコンテンツを抽出",
"title": "Web ページの内容を追加",
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
"Extracting": "抽出中...",
"extract": "抽出",
"Cancel": "キャンセル",
"enterUrl": "URLを入力してください",
"invalidFormat": "無効なURL形式です"
"invalidFormat": "無効なURL形式です",
"failed": "このページを読み込めませんでした"
},
"reasoning": {
"thinking": "考え中...",
@@ -277,28 +279,30 @@
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"sessionHistory": {
"tooltip": "チャット履歴",
"tooltip": "自分の図",
"newChat": "新しいチャット",
"empty": "チャット履歴はまだありません",
"emptyHint": "会話を始めてください",
"empty": "まだ図はありません",
"emptyHint": "図を説明して始めましょう",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "このチャットを削除しますか?",
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
"recentChats": "最近のチャット",
"deleteTitle": "この図を削除しますか?",
"deleteDescription": "図とその会話は完全に削除されます。",
"recentChats": "最近の図",
"justNow": "たった今",
"searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません"
"searchPlaceholder": "図を検索…",
"noResults": "チャットが見つかりません",
"rename": "名前を変更",
"busyHint": "回答が終わると選べます"
},
"validation": {
"title": "ダイアグラムを検証",
"title": "図のチェック",
"capturing": "キャプチャ中",
"validating": "検証中",
"validatingWithAttempt": "検証中 ({attempt}/{max})",
"valid": "有効",
"validWithWarnings": "有効(警告あり)",
"validWithWarnings": "問題なし(注意点あり)",
"issuesFound": "問題が見つかりました",
"error": "エラー",
"skipped": "スキップ",
@@ -307,7 +311,7 @@
"suggestions": "提案:",
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
"improveWithSuggestions": "提案で改善",
"improveWithSuggestions": "この指摘で改善",
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": {
@@ -329,7 +333,7 @@
"validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
"providers": "プロバイダー",
"addProviderHint": "プロバイダーを追加して開始",
"addProviderHint": "プロバイダーはまだありません",
"verified": "検証済み",
"configuration": "設定",
"displayName": "表示名",
@@ -358,7 +362,7 @@
"noModelsConfigured": "モデルが設定されていません",
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"configureProviders": "AI プロバイダーを設定",
"configureProviders": "AI プロバイダーの設定",
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
@@ -374,29 +378,31 @@
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"serverModels": "サーバーモデル",
"userModels": "ユーザーモデル",
"serverDefault": "サーバーの既定",
"serverModels": "サーバーのモデル",
"userModels": "自分のモデル",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"showUnvalidatedModels": "未テストのモデルも表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示",
"hideValue": "値を非表示"
"hideValue": "値を非表示",
"noProvidersTitle": "このサーバーで設定されたモデルを使用中",
"noProvidersHint": "ほかのモデルを使うには、左のリストの下でプロバイダーを追加し、自分の API キーを入力してください。"
},
"templates": {
"title": "マイテンプレート",
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
"emptyTitle": "テンプレートがありません",
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
"createFirst": "最初のテンプレートを作成",
"title": "保存したプロンプト",
"subtitle": "よく使うプロンプトをワンクリックで",
"emptyTitle": "保存したプロンプトはまだありません",
"emptyDescription": "「…の AWS 構成図を描いて」のようによく使うプロンプトを保存すると、ここに表示されます。",
"createFirst": "最初のプロンプトを保存",
"neverUsed": "未使用",
"usedCount": "{count} 回使用",
"myTemplates": "マイテンプレート",
"createTitle": "テンプレート作成",
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
"myTemplates": "保存したプロンプト",
"createTitle": "プロンプトを保存",
"createDescription": "あとで使えるようにプロンプトを保存します。",
"promptLabel": "プロンプト",
"promptPlaceholder": "作成するダイアグラムを説明してください...",
"promptRequired": "プロンプトは必須です",
@@ -404,33 +410,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 +582,109 @@
"description": "1 日のリセット境界に使用するタイムゾーン。"
}
}
},
"workspace": {
"toolbar": "キャンバスツール",
"undo": "元に戻す",
"redo": "やり直す",
"select": "選択",
"shapes": "図形ライブラリ",
"text": "テキスト",
"connector": "コネクタ",
"freehand": "フリーハンド",
"zoomIn": "拡大",
"zoomOut": "縮小",
"zoomMenu": "ズーム",
"fitWindow": "ウィンドウに合わせる",
"zoomReset": "実際のサイズ",
"style": "スタイル",
"more": "その他",
"export": "エクスポート",
"openFile": ".drawio ファイルを開く",
"findReplace": "検索と置換",
"layers": "レイヤー",
"outline": "アウトライン",
"toggleGrid": "グリッド",
"togglePageView": "ページ表示",
"insertImage": "画像を挿入",
"insertLink": "リンクを挿入",
"selectAll": "すべて選択",
"editDiagram": "図の XML を編集",
"print": "印刷",
"addPage": "ページを追加",
"renamePage": "名前を変更",
"duplicatePage": "複製",
"deletePage": "ページを削除",
"canvasLoading": "キャンバスを読み込み中…",
"askAi": "ここを AI に直してもらう",
"untitled": "無題の図",
"newDiagram": "新しい図",
"mobileCanvas": "キャンバス",
"mobileChat": "チャット"
},
"selection": {
"one": "選択中:{label}",
"many": "{count} 個の図形を選択中",
"shape": "図形",
"connector": "コネクタ",
"remove": "選択した図形を送らない",
"placeholder": "選択した図形をどう変えたいか書いてください"
},
"versions": {
"strip": "バージョン",
"created": "図を描きました",
"edited": "図を修正しました",
"undone": "この変更は取り消しました",
"undo": "変更を取り消す",
"redo": "変更をやり直す",
"restore": "復元",
"restored": "このバージョンに戻しました",
"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} 本",
"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": "手元の図の続きを編集"
}
}
+211 -100
View File
@@ -13,12 +13,12 @@
"nav": {
"about": "關於",
"editor": "編輯器",
"newChat": "開始新對話",
"newChat": "新增圖紙",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
"showPanel": "顯示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
"hidePanel": "隱藏聊天面板",
"showPanel": "顯示聊天面板",
"aiChat": "AI 對話"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
@@ -39,12 +39,11 @@
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"inputLabel": "聊天輸入框",
"send": "傳送",
"stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"diagramHistory": "圖表歷史",
"saveDiagram": "儲存圖表",
"uploadFile": "上傳檔案(圖片、PDF、文字)",
"minimalStyle": "簡約",
"styledMode": "精緻",
@@ -58,24 +57,28 @@
"badResponse": "無幫助",
"clickToEdit": "點擊編輯",
"editMessage": "編輯訊息",
"saveAndSubmit": "儲存並提交",
"ExtractURL": "從 URL 擷取"
"saveAndSubmit": "儲存並送出",
"ExtractURL": "加入網頁",
"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 中使用"
},
@@ -101,17 +104,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 +121,53 @@
"httpsProxy": "HTTPS 代理",
"applyProxy": "套用",
"proxyApplied": "代理設定已套用",
"diagramValidation": "圖表驗證(實驗性)",
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
"diagramValidation": "用截圖檢查圖表(實驗性)",
"diagramValidationDescription": "每次畫完後,讓能看懂圖片的模型檢查截圖並要求修正。需要能讀圖片的模型。",
"enabled": "已啟用",
"disabled": "已停用",
"customSystemMessage": "自訂系統訊息",
"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": "已匯出",
"exportButton": "匯出",
"hints": {
"drawio": "所有內容都能繼續編輯",
"png": "用於簡報和文件的圖片",
"svg": "放大也不會模糊的圖片",
"xmlsvg": "圖片,也能在 draw.io 裡開啟編輯"
}
},
"dialogs": {
"clearTitle": "清除所有內容?",
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
"clearEverything": "清除所有內容",
"clearEverything": "全部清除",
"clearSuccess": "已開始新對話"
},
"errors": {
@@ -189,6 +192,9 @@
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
"notADiagramFile": "{name} 不是 draw.io 圖表檔案。",
"fileTooLong": "{name}:內容超過了 {limit}k 字元的上限({size}k 字元)",
"fileReadFailed": "無法讀取檔案:{name}",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
@@ -220,18 +226,13 @@
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
"reset": "您的限制將在明天重設。感謝您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
"configModel": "使用您的金鑰",
"configModel": "使用自己的 API Key",
"selfHost": "自行託管",
"sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "產生圖表",
"editDiagram": "編輯圖表",
"appendDiagram": "繼續圖表",
"complete": "完成",
"error": "錯誤",
"truncated": "已截斷"
},
"file": {
@@ -240,13 +241,14 @@
"removeFile": "移除檔案"
},
"url": {
"title": "從 URL 擷取內容",
"title": "加入網頁內容",
"description": "貼上 URL 以擷取和分析其內容",
"Extracting": "擷取中...",
"extract": "擷取",
"Cancel": "取消",
"enterUrl": "請輸入 URL",
"invalidFormat": "URL 格式無效"
"invalidFormat": "URL 格式無效",
"failed": "無法讀取這個網頁"
},
"reasoning": {
"thinking": "思考中...",
@@ -277,32 +279,34 @@
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": {
"tooltip": "聊天歷史",
"tooltip": "我的圖紙",
"newChat": "新對話",
"empty": "暫無聊天紀錄",
"emptyHint": "開始對話吧",
"empty": "還沒有圖紙",
"emptyHint": "描述一張圖來開始",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除此對話?",
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
"recentChats": "最近對話",
"deleteTitle": "刪除這張圖紙?",
"deleteDescription": "圖紙和它的對話會被永久刪除。",
"recentChats": "最近的圖紙",
"justNow": "剛剛",
"searchPlaceholder": "搜尋對話...",
"noResults": "未找到對話"
"searchPlaceholder": "搜尋圖紙…",
"noResults": "未找到對話",
"rename": "重新命名",
"busyHint": "回答完成後才能切換"
},
"templates": {
"title": "我的範本",
"subtitle": "快速建立圖表的個人提示庫",
"emptyTitle": "尚無範本",
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
"createFirst": "建立第一個範本",
"title": "常用提示詞",
"subtitle": "常用的提示詞,點一下就能用",
"emptyTitle": "還沒有常用提示詞",
"emptyDescription": "把經常用的提示詞存起來,比如「畫一張……的 AWS 架構圖」,之後會出現在這裡。",
"createFirst": "儲存第一個提示詞",
"neverUsed": "未使用過",
"usedCount": "使用 {count} 次",
"myTemplates": "我的範本",
"createTitle": "建立範本",
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
"myTemplates": "常用提示詞",
"createTitle": "儲存提示詞",
"createDescription": "把提示詞存起來,以後再用。",
"promptLabel": "提示",
"promptPlaceholder": "描述您想建立的圖表...",
"promptRequired": "提示為必填項",
@@ -310,41 +314,42 @@
"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": "驗證圖表",
"title": "圖表檢查",
"capturing": "截圖中",
"validating": "驗證中",
"validatingWithAttempt": "驗證中 ({attempt}/{max})",
"valid": "通過",
"validWithWarnings": "通過(有警告)",
"validWithWarnings": "大致沒問題,有幾點提示",
"issuesFound": "發現問題",
"error": "錯誤",
"skipped": "已跳過",
@@ -353,19 +358,19 @@
"suggestions": "建議:",
"passedValidation": "圖表通過視覺驗證 - 未發現問題。",
"improvementRequested": "改進請求已傳送 - 請查看下方新圖表",
"improveWithSuggestions": "根據建議改進",
"improveWithSuggestions": "依這些建議改進",
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多個 AI 提供商和模型",
"configure": "配置",
"addProvider": "新增提供商",
"addProvider": "新增服務商",
"addModel": "新增模型",
"modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除提供商",
"deleteProvider": "刪除服務商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"selectProvider": "選擇一個提供商或新增",
@@ -375,7 +380,7 @@
"validationError": "驗證失敗",
"addModelFirst": "請先新增至少一個模型以進行驗證",
"providers": "提供商",
"addProviderHint": "新增提供商即可開始使用",
"addProviderHint": "還沒有服務商",
"verified": "已驗證",
"configuration": "配置",
"displayName": "顯示名稱",
@@ -383,7 +388,7 @@
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
"selectRegion": "選擇區域",
"apiKey": "API 金鑰",
"apiKey": "API Key",
"enterApiKey": "輸入您的 API 金鑰",
"enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL",
@@ -404,7 +409,7 @@
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"configureProviders": "設定 AI 服務商",
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
@@ -422,15 +427,17 @@
"default": "預設",
"serverDefault": "伺服器預設",
"serverModels": "伺服器模型",
"userModels": "使用者模型",
"userModels": "我的模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "僅顯示已驗證的模型",
"showUnvalidatedModels": "顯示未驗證的模型",
"showUnvalidatedModels": "顯示還沒測試過的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型",
"showValue": "顯示值",
"hideValue": "隱藏值"
"hideValue": "隱藏值",
"noProvidersTitle": "正在使用伺服器上設定的模型",
"noProvidersHint": "想用其他模型,可以在左側清單下方新增服務商並填寫自己的 API Key。"
},
"admin": {
"title": "管理員設定",
@@ -575,5 +582,109 @@
"description": "每日重置邊界所用的時區。"
}
}
},
"workspace": {
"toolbar": "畫布工具",
"undo": "復原",
"redo": "重做",
"select": "選取",
"shapes": "圖形庫",
"text": "文字",
"connector": "連線",
"freehand": "手繪",
"zoomIn": "放大",
"zoomOut": "縮小",
"zoomMenu": "縮放",
"fitWindow": "符合視窗",
"zoomReset": "實際大小",
"style": "樣式",
"more": "更多",
"export": "匯出",
"openFile": "開啟 .drawio 檔案",
"findReplace": "尋找與取代",
"layers": "圖層",
"outline": "縮圖",
"toggleGrid": "格線",
"togglePageView": "頁面檢視",
"insertImage": "插入圖片",
"insertLink": "插入連結",
"selectAll": "全選",
"editDiagram": "編輯圖表 XML",
"print": "列印",
"addPage": "新增頁面",
"renamePage": "重新命名",
"duplicatePage": "複製頁面",
"deletePage": "刪除頁面",
"canvasLoading": "正在載入畫布…",
"askAi": "讓 AI 改這裡",
"untitled": "未命名圖紙",
"newDiagram": "新增圖紙",
"mobileCanvas": "畫布",
"mobileChat": "對話"
},
"selection": {
"one": "已選取:{label}",
"many": "已選取 {count} 個圖形",
"shape": "圖形",
"connector": "連線",
"remove": "不傳送選取的圖形",
"placeholder": "說說要怎麼改選取的圖形"
},
"versions": {
"strip": "版本",
"created": "畫好了圖表",
"edited": "改好了圖表",
"undone": "這次修改已復原",
"undo": "復原修改",
"redo": "重做修改",
"restore": "還原",
"restored": "已還原到這一版",
"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} 條連線",
"restoreVersion": "還原到 v{n}",
"restoredVersion": "已還原到 v{n}",
"undoRestore": "復原",
"noPreview": "沒有預覽",
"compareSince": "從第 {n} 版到現在,畫布上:",
"isOnCanvas": "畫布現在顯示的就是這一版。",
"differsElsewhere": "第一頁和第 {n} 版相同,其他頁面或頁面設定有改動。"
},
"lobby": {
"title": "說說你要畫什麼",
"lede": "描述一張圖,或上傳截圖、PDF。畫出來的是標準的 draw.io 圖表,可以接著改,也可以匯出。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始",
"openFileHint": "接著編輯已有的圖"
}
}
+209 -98
View File
@@ -13,12 +13,12 @@
"nav": {
"about": "关于",
"editor": "编辑器",
"newChat": "开始新对话",
"newChat": "新建图纸",
"github": "GitHub",
"settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
"hidePanel": "隐藏聊天面板",
"showPanel": "显示聊天面板",
"aiChat": "AI 对话"
},
"providers": {
"useServerDefault": "使用服务器默认值",
@@ -39,12 +39,11 @@
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
"inputLabel": "聊天输入框",
"send": "发送",
"stopGeneration": "停止生成",
"sendMessage": "发送消息",
"clearConversation": "清除对话",
"diagramHistory": "图表历史",
"saveDiagram": "保存图表",
"uploadFile": "上传文件(图片、PDF、文本)",
"minimalStyle": "简约",
"styledMode": "精致",
@@ -59,23 +58,27 @@
"clickToEdit": "点击编辑",
"editMessage": "编辑消息",
"saveAndSubmit": "保存并提交",
"ExtractURL": "从 URL 提取"
"ExtractURL": "添加网页",
"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 中使用"
},
@@ -101,16 +104,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 +121,53 @@
"httpsProxy": "HTTPS 代理",
"applyProxy": "应用",
"proxyApplied": "代理设置已应用",
"diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"diagramValidation": "用截图检查图表(实验性)",
"diagramValidationDescription": "每次画完后,让能看懂图片的模型检查截图并要求修正。需要能读图片的模型。",
"enabled": "已启用",
"disabled": "已禁用",
"customSystemMessage": "自定义系统消息",
"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": "已导出",
"exportButton": "导出",
"hints": {
"drawio": "所有内容都能继续编辑",
"png": "用于幻灯片和文档的图片",
"svg": "放大也不会模糊的图片",
"xmlsvg": "图片,也能在 draw.io 里打开编辑"
}
},
"dialogs": {
"clearTitle": "清除所有内容?",
"clearDescription": "这将清除当前对话并重置图表。此操作无法撤消。",
"clearEverything": "清除所有内容",
"clearEverything": "全部清除",
"clearSuccess": "已开始新对话"
},
"errors": {
@@ -189,6 +192,9 @@
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
"continueWithoutSaving": "不保存,继续",
"notADiagramFile": "{name} 不是 draw.io 图表文件。",
"fileTooLong": "{name}:内容超过了 {limit}k 字符的上限({size}k 字符)",
"fileReadFailed": "无法读取文件:{name}",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
@@ -220,18 +226,13 @@
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
"reset": "您的限制将在明天重置。感谢您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
"configModel": "使用您的密钥",
"configModel": "使用自己的 API Key",
"selfHost": "自托管",
"sponsor": "赞助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "生成图表",
"editDiagram": "编辑图表",
"appendDiagram": "继续图表",
"complete": "完成",
"error": "错误",
"truncated": "已截断"
},
"file": {
@@ -240,13 +241,14 @@
"removeFile": "移除文件"
},
"url": {
"title": "从 URL 提取内容",
"title": "添加网页内容",
"description": "粘贴 URL 以提取和分析其内容",
"Extracting": "提取中...",
"extract": "提取",
"Cancel": "取消",
"enterUrl": "请输入 URL",
"invalidFormat": "URL 格式无效"
"invalidFormat": "URL 格式无效",
"failed": "无法读取这个网页"
},
"reasoning": {
"thinking": "思考中...",
@@ -277,32 +279,34 @@
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
},
"sessionHistory": {
"tooltip": "聊天历史",
"tooltip": "我的图纸",
"newChat": "新对话",
"empty": "暂无聊天记录",
"emptyHint": "开始对话吧",
"empty": "还没有图纸",
"emptyHint": "描述一张图来开始",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"earlier": "更早",
"deleteTitle": "删除此对话?",
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
"recentChats": "最近对话",
"deleteTitle": "删除这张图纸?",
"deleteDescription": "图纸和它的对话会被永久删除。",
"recentChats": "最近的图纸",
"justNow": "刚刚",
"searchPlaceholder": "搜索对话...",
"noResults": "未找到对话"
"searchPlaceholder": "搜索图纸…",
"noResults": "未找到对话",
"rename": "重命名",
"busyHint": "回答完成后才能切换"
},
"templates": {
"title": "我的模板库",
"subtitle": "您的个人 prompt 库,快速创建图表",
"emptyTitle": "暂无模板",
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
"createFirst": "创建第一个模板",
"title": "常用提示词",
"subtitle": "常用的提示词,点一下就能用",
"emptyTitle": "还没有常用提示词",
"emptyDescription": "把经常用的提示词存起来,比如“画一张……的 AWS 架构图”,之后会出现在这里。",
"createFirst": "保存第一个提示词",
"neverUsed": "未使用过",
"usedCount": "使用 {count} 次",
"myTemplates": "我的模板",
"createTitle": "创建模板",
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
"myTemplates": "常用提示词",
"createTitle": "保存提示词",
"createDescription": "把提示词存起来,以后再用。",
"promptLabel": "提示词",
"promptPlaceholder": "描述您想要创建的图表...",
"promptRequired": "提示词不能为空",
@@ -310,41 +314,42 @@
"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": "验证图表",
"title": "图表检查",
"capturing": "截图中",
"validating": "验证中",
"validatingWithAttempt": "验证中 ({attempt}/{max})",
"valid": "通过",
"validWithWarnings": "通过(有警告)",
"validWithWarnings": "基本没问题,有几点提示",
"issuesFound": "发现问题",
"error": "错误",
"skipped": "已跳过",
@@ -353,19 +358,19 @@
"suggestions": "建议:",
"passedValidation": "图表通过视觉验证 - 未发现问题。",
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
"improveWithSuggestions": "根据建议改进",
"improveWithSuggestions": "按这些建议改进",
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型",
"configure": "配置",
"addProvider": "添加提供商",
"addProvider": "添加服务商",
"addModel": "添加模型",
"modelId": "模型 ID",
"modelLabel": "显示名称",
"streaming": "启用流式输出",
"deleteProvider": "删除提供商",
"deleteProvider": "删除服务商",
"deleteModel": "删除模型",
"noModels": "尚未配置模型。添加模型以开始使用。",
"selectProvider": "选择一个提供商或添加新的",
@@ -375,7 +380,7 @@
"validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证",
"providers": "提供商",
"addProviderHint": "添加提供商即可开始使用",
"addProviderHint": "还没有服务商",
"verified": "已验证",
"configuration": "配置",
"displayName": "显示名称",
@@ -383,7 +388,7 @@
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域",
"selectRegion": "选择区域",
"apiKey": "API 密钥",
"apiKey": "API Key",
"enterApiKey": "输入您的 API 密钥",
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
@@ -404,7 +409,7 @@
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"configureProviders": "设置 AI 服务商",
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认",
@@ -422,15 +427,17 @@
"default": "默认",
"serverDefault": "服务器默认",
"serverModels": "服务器模型",
"userModels": "用户模型",
"userModels": "我的模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"showUnvalidatedModels": "显示还没测试过的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型",
"showValue": "显示值",
"hideValue": "隐藏值"
"hideValue": "隐藏值",
"noProvidersTitle": "正在使用服务器上配置的模型",
"noProvidersHint": "想用其他模型,可以在左侧列表下方添加服务商并填写自己的 API Key。"
},
"admin": {
"title": "管理员设置",
@@ -575,5 +582,109 @@
"description": "每日重置边界所用的时区。"
}
}
},
"workspace": {
"toolbar": "画布工具",
"undo": "撤销",
"redo": "重做",
"select": "选择",
"shapes": "图形库",
"text": "文字",
"connector": "连线",
"freehand": "手绘",
"zoomIn": "放大",
"zoomOut": "缩小",
"zoomMenu": "缩放",
"fitWindow": "适合窗口",
"zoomReset": "实际大小",
"style": "样式",
"more": "更多",
"export": "导出",
"openFile": "打开 .drawio 文件",
"findReplace": "查找和替换",
"layers": "图层",
"outline": "缩略图",
"toggleGrid": "网格",
"togglePageView": "页面视图",
"insertImage": "插入图片",
"insertLink": "插入链接",
"selectAll": "全选",
"editDiagram": "编辑图表 XML",
"print": "打印",
"addPage": "新建页面",
"renamePage": "重命名",
"duplicatePage": "复制页面",
"deletePage": "删除页面",
"canvasLoading": "正在加载画布…",
"askAi": "让 AI 改这里",
"untitled": "未命名图纸",
"newDiagram": "新建图纸",
"mobileCanvas": "画布",
"mobileChat": "对话"
},
"selection": {
"one": "已选:{label}",
"many": "已选 {count} 个图形",
"shape": "图形",
"connector": "连线",
"remove": "不发送选中的图形",
"placeholder": "说说要怎么改选中的图形"
},
"versions": {
"strip": "版本",
"created": "画好了图表",
"edited": "改好了图表",
"undone": "这次修改已撤销",
"undo": "撤销修改",
"redo": "重做修改",
"restore": "恢复",
"restored": "已恢复到这一版",
"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} 条连线",
"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",
})
}
+18 -1
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 {
@@ -241,6 +247,16 @@ export async function enforceSessionLimit(): Promise<void> {
}
}
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 +316,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)
+35
View File
@@ -0,0 +1,35 @@
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)
}