diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 9c85c53f1..ab719f961 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -3,6 +3,10 @@
This file records third-party attribution for assets bundled with OpenSquilla.
It covers:
+- The workspace file-tree features in `opensquilla-webui/` (file tree model,
+ lazy per-directory loading store, and virtualized row layout), which are
+ adapted from anomalyco/opencode (MIT) — see the section below.
+
- The bundled skill descriptors under `src/opensquilla/skills/bundled/`, which
include OpenClaw-derived MIT descriptors and OpenSquilla-original descriptors.
- The bundled pptx skill references the python-pptx and PptxGenJS libraries;
@@ -648,3 +652,38 @@ The SquillaRouter bundle contains `.pkl` and `.joblib` artifacts used by the
current V4 Phase 3 runtime. Treat these artifacts as executable-code-equivalent
inputs: load only assets shipped with a trusted OpenSquilla release or assets
whose checksums match `artifact_manifest.json`.
+
+## Workspace File Tree (adapted from anomalyco/opencode)
+
+The Web UI's workspace file-tree model, virtualized row layout, and lazy
+per-directory loading store are adapted (re-implemented for Vue 3 / Pinia and
+OpenSquilla types) from [anomalyco/opencode](https://github.com/anomalyco/opencode),
+MIT License, Copyright (c) 2025 opencode:
+
+- `opensquilla-webui/src/lib/fileTreeModel.ts` ← `packages/app/src/components/file-tree-v2-model.ts`
+- `opensquilla-webui/src/stores/fileTree.ts` ← `packages/app/src/context/file/tree-store.ts`
+- `opensquilla-webui/src/components/SidebarFileTree.vue` ← `packages/app/src/components/file-tree-v2.tsx` (render approach)
+
+Full MIT license text:
+
+ MIT License
+
+ Copyright (c) 2025 opencode
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
diff --git a/opensquilla-webui/package-lock.json b/opensquilla-webui/package-lock.json
index 379b12254..9c9aac606 100644
--- a/opensquilla-webui/package-lock.json
+++ b/opensquilla-webui/package-lock.json
@@ -8,6 +8,7 @@
"name": "opensquilla-webui",
"version": "0.2.1",
"dependencies": {
+ "@tanstack/vue-virtual": "^3.13.36",
"@types/dompurify": "^3.0.5",
"dompurify": "^3.4.12",
"highlight.js": "^11.11.1",
@@ -503,6 +504,32 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.17.8",
+ "resolved": "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz",
+ "integrity": "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/vue-virtual": {
+ "version": "3.13.36",
+ "resolved": "https://registry.npmmirror.com/@tanstack/vue-virtual/-/vue-virtual-3.13.36.tgz",
+ "integrity": "sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.17.8"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "vue": "^2.7.0 || ^3.0.0"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
diff --git a/opensquilla-webui/package.json b/opensquilla-webui/package.json
index bed6b98aa..4ed32a85b 100644
--- a/opensquilla-webui/package.json
+++ b/opensquilla-webui/package.json
@@ -24,6 +24,7 @@
"node": ">=22.12.0"
},
"dependencies": {
+ "@tanstack/vue-virtual": "^3.13.36",
"@types/dompurify": "^3.0.5",
"dompurify": "^3.4.12",
"highlight.js": "^11.11.1",
diff --git a/opensquilla-webui/src/App.vue b/opensquilla-webui/src/App.vue
index 35f05c7b8..a11389af0 100644
--- a/opensquilla-webui/src/App.vue
+++ b/opensquilla-webui/src/App.vue
@@ -77,8 +77,10 @@
-
+
+
+
+
+
+
+
{{ store.activeItem?.title }}
+
+
+
+
+
+
+
@@ -181,6 +225,7 @@ const props = withDefaults(defineProps<{
closeItemLabel?: string
resizeLabel?: string
pixelsLabel?: string
+ tabOverflowLabel?: string
beforeCloseItem?: (item: WorkbenchItem) => boolean | Promise
}>(), {
enabled: true,
@@ -195,6 +240,7 @@ const props = withDefaults(defineProps<{
closeItemLabel: 'Close tab',
resizeLabel: 'Resize workbench',
pixelsLabel: 'pixels',
+ tabOverflowLabel: 'All tabs',
})
const emit = defineEmits<{
@@ -208,10 +254,46 @@ const store = useWorkbenchStore()
const hostRef = ref(null)
const surfaceRef = ref(null)
const resizerRef = ref(null)
-const closeButtonRef = ref(null)
const viewportWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth)
const containerWidth = ref(0)
const containerRect = ref({ top: 0, right: viewportWidth.value, height: 0 })
+
+// Tab overflow dropdown: the strip scrolls horizontally with its scrollbar
+// hidden, so tabs pushed out of view are otherwise unreachable.
+const tabMenuOpen = ref(false)
+const tabMenuRef = ref(null)
+
+function toggleTabMenu() {
+ tabMenuOpen.value = !tabMenuOpen.value
+ if (tabMenuOpen.value) {
+ void nextTick(() => {
+ tabMenuRef.value
+ ?.querySelector('.is-active')
+ ?.scrollIntoView({ block: 'nearest' })
+ })
+ }
+}
+
+function selectTabFromMenu(id: string) {
+ tabMenuOpen.value = false
+ store.activateItem(id)
+}
+
+function collapseFromMenu() {
+ tabMenuOpen.value = false
+ collapseWorkbench()
+}
+
+function onTabMenuMousedown(event: MouseEvent) {
+ if (!tabMenuOpen.value) return
+ const target = event.target as Element | null
+ if (target?.closest('[data-workbench-tab-menu], .workbench-host__tabs-overflow')) return
+ tabMenuOpen.value = false
+}
+
+function onTabMenuKeydown(event: KeyboardEvent) {
+ if (event.key === 'Escape') tabMenuOpen.value = false
+}
const detectedCoarseOnly = ref(false)
const previewWidth = ref(null)
let coarseQuery: MediaQueryList | null = null
@@ -268,7 +350,9 @@ useDialogA11y(
mobileDialogOpen,
collapseWorkbench,
{
- initialFocus: closeButtonRef,
+ // No initialFocus: the dialog always contains focusable controls (tab
+ // strip buttons or the single-title close), so the composable's
+ // first-focusable fallback applies and the focus trap stays consistent.
occludesNativeSurface: false,
},
)
@@ -297,11 +381,10 @@ async function closeWorkbenchItem(id: string) {
return
}
void nextTick(() => {
- const activeTab = hostRef.value?.querySelector(
+ const focusTarget = hostRef.value?.querySelector(
'[role="tab"][aria-selected="true"]',
- )
- ;(activeTab || closeButtonRef.value)
- ?.focus({ preventScroll: true })
+ ) ?? hostRef.value
+ focusTarget?.focus({ preventScroll: true })
})
}
@@ -461,14 +544,10 @@ watch([hostRef, surfaceRef], () => {
scheduleSurfaceRect()
}, { flush: 'post' })
-watch(shouldRender, (visible, previous) => {
- if (visible && !previous && layoutMode.value === 'mobile-dialog') {
- void nextTick(() => closeButtonRef.value?.focus({ preventScroll: true }))
- }
-})
-
onMounted(() => {
window.addEventListener('resize', updateViewportWidth)
+ window.addEventListener('mousedown', onTabMenuMousedown)
+ window.addEventListener('keydown', onTabMenuKeydown)
window.addEventListener('scroll', measureContainer, true)
coarseQuery = window.matchMedia?.('(pointer: coarse) and (hover: none)') ?? null
if (coarseQuery) {
@@ -492,6 +571,8 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewportWidth)
window.removeEventListener('scroll', measureContainer, true)
+ window.removeEventListener('mousedown', onTabMenuMousedown)
+ window.removeEventListener('keydown', onTabMenuKeydown)
coarseQuery?.removeEventListener?.('change', updateCoarseOnly)
surfaceObserver?.disconnect()
containerObserver?.disconnect()
@@ -548,6 +629,7 @@ onBeforeUnmount(() => {
}
.workbench-host__chrome {
+ position: relative;
display: flex;
min-height: 48px;
align-items: center;
@@ -565,6 +647,75 @@ onBeforeUnmount(() => {
scrollbar-width: none;
}
+/* Pinned to the strip's visible right edge: margin-left eats the spare
+ * space when tabs don't fill the strip, sticky keeps it visible when the
+ * strip scrolls horizontally. */
+.workbench-host__tabs-overflow {
+ position: sticky;
+ right: 0;
+ margin-left: auto;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 26px;
+ border: 0;
+ background: var(--bg-surface);
+ color: var(--text-muted);
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+}
+
+.workbench-host__tabs-overflow:hover,
+.workbench-host__tabs-overflow:focus-visible {
+ background: var(--bg-hover);
+ color: var(--text);
+}
+
+.workbench-host__tab-menu {
+ position: absolute;
+ top: 100%;
+ right: var(--sp-3);
+ z-index: 1100;
+ min-width: 180px;
+ max-height: 280px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ padding: 4px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+}
+
+.workbench-host__tab-menu-item {
+ display: block;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ font-size: 12px;
+ text-align: start;
+ padding: 6px 10px;
+ border-radius: var(--radius-xs);
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.workbench-host__tab-menu-item:hover,
+.workbench-host__tab-menu-item:focus-visible {
+ background: var(--bg-hover);
+}
+
+.workbench-host__tab-menu-item.is-active {
+ background: var(--bg-hover);
+ font-weight: 600;
+}
+
.workbench-host__tabs::-webkit-scrollbar {
display: none;
}
@@ -635,8 +786,37 @@ onBeforeUnmount(() => {
}
.workbench-host__single-title {
+ display: flex;
min-width: 0;
flex: 1;
+ align-items: center;
+ gap: var(--sp-2);
+}
+
+.workbench-host__single-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+ border: 0;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+}
+
+.workbench-host__single-close:hover,
+.workbench-host__single-close:focus-visible {
+ background: var(--bg-hover);
+ color: var(--text);
+}
+
+.workbench-host__tab-menu-divider {
+ height: 1px;
+ margin: 4px 6px;
+ background: var(--border);
}
.workbench-host__title {
diff --git a/opensquilla-webui/src/components/workbench/WorkspaceFilePreviewPanel.test.ts b/opensquilla-webui/src/components/workbench/WorkspaceFilePreviewPanel.test.ts
new file mode 100644
index 000000000..cd055db3f
--- /dev/null
+++ b/opensquilla-webui/src/components/workbench/WorkspaceFilePreviewPanel.test.ts
@@ -0,0 +1,306 @@
+// @vitest-environment happy-dom
+
+import { createApp, nextTick, type App } from 'vue'
+import { createI18n } from 'vue-i18n'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import en from '@/locales/en.json'
+import { WORKSPACE_FILES_KEY, type WorkspaceFiles } from '@/modules/workspaceFiles'
+import { WORKSPACE_FILE_ATTACH_EVENT } from '@/workbench/workspaceFileAttachEvent'
+import {
+ createWorkspaceFileWorkbenchItem,
+ workspaceFileFromWorkbenchItem,
+ workspaceFileWorkbenchItemId,
+} from '@/workbench/workspaceFileItems'
+import { createWorkspaceFileWorkbenchDefinition } from './workspaceFileWorkbenchProvider'
+
+const monacoEditor = vi.hoisted(() => {
+ const calls = {
+ modelValue: '',
+ createOptions: null as Record | null,
+ }
+ const state = { hasSelection: false, selectedText: '' }
+ const handlers = { scroll: [] as Array<() => void> }
+ return {
+ calls,
+ state,
+ handlers,
+ reset() {
+ calls.modelValue = ''
+ calls.createOptions = null
+ state.hasSelection = false
+ state.selectedText = ''
+ handlers.scroll = []
+ },
+ }
+})
+
+vi.mock('monaco-editor', () => ({
+ editor: {
+ create: (_container: HTMLElement, options: Record) => {
+ monacoEditor.calls.createOptions = options
+ return {
+ getModel: () => ({
+ getLanguageId: () => 'plaintext',
+ getValue: () => monacoEditor.calls.modelValue,
+ setValue: (value: string) => {
+ monacoEditor.calls.modelValue = value
+ },
+ getValueInRange: () => monacoEditor.state.selectedText,
+ }),
+ getSelection: () => ({
+ isEmpty: () => !monacoEditor.state.hasSelection,
+ }),
+ onDidScrollChange: (callback: () => void) => {
+ monacoEditor.handlers.scroll.push(callback)
+ return { dispose: () => undefined }
+ },
+ dispose: () => undefined,
+ }
+ },
+ setModelLanguage: () => undefined,
+ },
+}))
+vi.mock('monaco-editor/editor/editor.worker.js?worker', () => ({
+ default: class EditorWorker {},
+}))
+
+const readFileMock = vi.fn(async (_workspaceId: string, path: string) => ({
+ path,
+ size: 11,
+ binary: false,
+ truncated: false,
+ content: 'README root\n',
+}))
+
+const filesPort: WorkspaceFiles = {
+ listDir: vi.fn(async (_workspaceId: string, path: string) => ({ path, entries: [] })),
+ readFile: readFileMock,
+}
+
+const writeText = vi.fn<(text: string) => Promise>(async () => undefined)
+
+function stubClipboard() {
+ Object.defineProperty(window.navigator, 'clipboard', {
+ value: { writeText },
+ configurable: true,
+ })
+}
+
+const apps: App[] = []
+let host: HTMLElement | null = null
+
+async function mountPanel(path: string) {
+ host = document.createElement('div')
+ document.body.append(host)
+ const Panel = (await import('./WorkspaceFilePreviewPanel.vue')).default
+ const app = createApp(Panel, {
+ workspace: { id: 'ws-1', name: 'proj', path: 'C:/tmp/proj' },
+ path,
+ rootPath: 'C:/tmp/proj',
+ })
+ app.use(createI18n({ legacy: false, locale: 'en', messages: { en } }))
+ app.provide(WORKSPACE_FILES_KEY, filesPort)
+ apps.push(app)
+ app.mount(host)
+ return host
+}
+
+function ctxMenuElement(): HTMLElement | null {
+ return document.querySelector('[data-ws-file-ctx-menu]')
+}
+
+/** Drive the real DOM path: the panel owns the contextmenu event. */
+function fireContainerContextMenu(clientX = 200, clientY = 150) {
+ const container = document.querySelector('.ws-file-preview__editor')
+ if (!container) throw new Error('editor container not rendered')
+ container.dispatchEvent(new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ clientX,
+ clientY,
+ button: 2,
+ }))
+}
+
+afterEach(() => {
+ apps.splice(0).forEach(app => app.unmount())
+ document.body.innerHTML = ''
+ host = null
+ readFileMock.mockClear()
+ writeText.mockClear()
+ monacoEditor.reset()
+ vi.restoreAllMocks()
+})
+
+describe('WorkspaceFilePreviewPanel', () => {
+ it('fetches once per mount and renders the content in the editor', async () => {
+ await mountPanel('README.md')
+ await vi.waitFor(() => expect(readFileMock).toHaveBeenCalledTimes(1))
+ // The initial mount race (editor created before/after the fetch) must
+ // still deliver the content into the editor exactly once.
+ await vi.waitFor(() => expect(monacoEditor.calls.modelValue).toBe('README root\n'))
+ // Give any accidental state-triggered refetch several macro-task cycles
+ // to manifest before asserting the count stayed at one.
+ await new Promise(resolve => setTimeout(resolve, 50))
+ await new Promise(resolve => setTimeout(resolve, 50))
+ expect(readFileMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('creates the editor with its native context menu disabled', async () => {
+ await mountPanel('README.md')
+ await vi.waitFor(() => expect(monacoEditor.calls.createOptions).not.toBeNull())
+ expect(monacoEditor.calls.createOptions?.contextmenu).toBe(false)
+ })
+
+ it('refetches exactly once when the panel is reused for another path', async () => {
+ await mountPanel('README.md')
+ await vi.waitFor(() => expect(readFileMock).toHaveBeenCalledTimes(1))
+ // Re-mount with a different path simulates workbench panel reuse via
+ // :key-less patching; the panel must load the new file once.
+ apps.splice(0).forEach(app => app.unmount())
+ document.body.innerHTML = ''
+ await mountPanel('src/lib/b.ts')
+ await vi.waitFor(() => expect(readFileMock).toHaveBeenCalledTimes(2))
+ await new Promise(resolve => setTimeout(resolve, 50))
+ expect(readFileMock).toHaveBeenCalledTimes(2)
+ expect(readFileMock.mock.calls[1]?.slice(0, 2)).toEqual(['ws-1', 'src/lib/b.ts'])
+ })
+
+ it('shows the selection context menu only when text is selected', async () => {
+ await mountPanel('README.md')
+ await vi.waitFor(() => expect(monacoEditor.calls.modelValue).not.toBe(''))
+
+ fireContainerContextMenu(120, 80)
+ await nextTick()
+ // No selection: no custom menu, and Monaco's native menu must not
+ // exist either (contextmenu:false).
+ expect(ctxMenuElement()).toBeNull()
+ expect(document.querySelector('.monaco-menu-container')).toBeNull()
+ expect(document.querySelector('.context-view')).toBeNull()
+
+ monacoEditor.state.hasSelection = true
+ monacoEditor.state.selectedText = 'README root'
+ fireContainerContextMenu(120, 80)
+ await nextTick()
+ const menu = ctxMenuElement()
+ expect(menu).not.toBeNull()
+ expect(menu?.style.left).toBe('120px')
+ expect(menu?.style.top).toBe('80px')
+ const items = menu?.querySelectorAll('button')
+ expect(items?.length).toBe(2)
+ expect(items?.[0]?.textContent?.trim()).toBe('Copy')
+ expect(items?.[1]?.textContent?.trim()).toBe('Add to conversation')
+ })
+
+ it('copies the selected text from the context menu', async () => {
+ stubClipboard()
+ await mountPanel('README.md')
+ await vi.waitFor(() => expect(monacoEditor.calls.modelValue).not.toBe(''))
+
+ monacoEditor.state.hasSelection = true
+ monacoEditor.state.selectedText = 'README root'
+ fireContainerContextMenu()
+ await nextTick()
+ const menu = ctxMenuElement()
+ expect(menu).not.toBeNull()
+ ;(menu?.querySelectorAll('button')?.[0] as HTMLButtonElement).click()
+ await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('README root'))
+ await nextTick()
+ expect(ctxMenuElement()).toBeNull()
+ })
+
+ it('attaches the selection to the conversation via the attach event', async () => {
+ await mountPanel('README.md')
+ await vi.waitFor(() => expect(monacoEditor.calls.modelValue).not.toBe(''))
+
+ const attachSpy = vi.fn()
+ window.addEventListener(WORKSPACE_FILE_ATTACH_EVENT, attachSpy)
+
+ monacoEditor.state.hasSelection = true
+ monacoEditor.state.selectedText = 'README root'
+ fireContainerContextMenu()
+ await nextTick()
+ const menu = ctxMenuElement()
+ expect(menu).not.toBeNull()
+ ;(menu?.querySelectorAll('button')?.[1] as HTMLButtonElement).click()
+ await vi.waitFor(() => expect(attachSpy).toHaveBeenCalledTimes(1))
+
+ const detail = (attachSpy.mock.calls[0]?.[0] as CustomEvent).detail
+ expect(detail).toMatchObject({
+ workspaceId: 'ws-1',
+ workspacePath: 'C:/tmp/proj',
+ path: 'README.md',
+ name: 'README.selection.txt',
+ content: 'README root',
+ })
+ window.removeEventListener(WORKSPACE_FILE_ATTACH_EVENT, attachSpy)
+ })
+
+ it('closes the context menu on Escape and on outside mousedown', async () => {
+ await mountPanel('README.md')
+ await vi.waitFor(() => expect(monacoEditor.calls.modelValue).not.toBe(''))
+
+ monacoEditor.state.hasSelection = true
+ monacoEditor.state.selectedText = 'x'
+ fireContainerContextMenu()
+ await nextTick()
+ expect(ctxMenuElement()).not.toBeNull()
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ await nextTick()
+ expect(ctxMenuElement()).toBeNull()
+
+ fireContainerContextMenu()
+ await nextTick()
+ expect(ctxMenuElement()).not.toBeNull()
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
+ await nextTick()
+ expect(ctxMenuElement()).toBeNull()
+ })
+})
+
+describe('workspace file item openNonce', () => {
+ it('stamps a fresh nonce per open while keeping the stable id', () => {
+ const ref = {
+ workspaceId: 'ws-1',
+ workspaceName: 'proj',
+ workspacePath: 'C:/tmp/proj',
+ path: 'README.md',
+ }
+ const first = createWorkspaceFileWorkbenchItem(ref)
+ const second = createWorkspaceFileWorkbenchItem(ref)
+ expect(first.id).toBe(second.id)
+ expect(second.id).toBe(workspaceFileWorkbenchItemId('ws-1', 'README.md'))
+ const firstNonce = workspaceFileFromWorkbenchItem(first)?.openNonce
+ const secondNonce = workspaceFileFromWorkbenchItem(second)?.openNonce
+ expect(typeof firstNonce).toBe('number')
+ expect(secondNonce).toBeGreaterThan(firstNonce as number)
+ })
+
+ it('round-trips the nonce through the deserializer and provider props', () => {
+ const item = createWorkspaceFileWorkbenchItem({
+ workspaceId: 'ws-1',
+ workspaceName: 'proj',
+ workspacePath: 'C:/tmp/proj',
+ path: 'README.md',
+ })
+ const ref = workspaceFileFromWorkbenchItem(item)
+ expect(ref?.openNonce).toBeDefined()
+ const state = {
+ item,
+ active: true,
+ layoutMode: 'split',
+ hostAvailable: true,
+ nativeSurface: false,
+ runtimeState: {},
+ } as Parameters<
+ NonNullable<
+ ReturnType['getProps']
+ >
+ >[1]
+ const props = createWorkspaceFileWorkbenchDefinition().getProps?.(item, state) ?? {}
+ expect(props.openNonce).toBe(ref?.openNonce)
+ expect(props.path).toBe('README.md')
+ })
+})
diff --git a/opensquilla-webui/src/components/workbench/WorkspaceFilePreviewPanel.vue b/opensquilla-webui/src/components/workbench/WorkspaceFilePreviewPanel.vue
new file mode 100644
index 000000000..65afbb958
--- /dev/null
+++ b/opensquilla-webui/src/components/workbench/WorkspaceFilePreviewPanel.vue
@@ -0,0 +1,541 @@
+
+
+
+
+
+
+ {{ fileName }}
+
+ {{ sizeLabel }}
+
+ {{ t('fileTree.previewTruncated') }}
+
+
+
+
+
+
+
+ {{ t('fileTree.loading') }}
+
+
+
{{ errorText }}
+
+ {{ t('fileTree.retry') }}
+
+
+
+
{{ t('fileTree.binaryNotPreviewable') }}
+
{{ sizeLabel }}
+
+
+
+
+
{{ content }}
+
+
+
+ {{ t('fileTree.ctxCopy') }}
+
+
+ {{ t('fileTree.ctxAttach') }}
+
+
+
+
+
+
diff --git a/opensquilla-webui/src/components/workbench/workspaceFileWorkbenchProvider.ts b/opensquilla-webui/src/components/workbench/workspaceFileWorkbenchProvider.ts
new file mode 100644
index 000000000..747769f82
--- /dev/null
+++ b/opensquilla-webui/src/components/workbench/workspaceFileWorkbenchProvider.ts
@@ -0,0 +1,41 @@
+import type { WorkbenchPanelDefinition } from '@/workbench/types'
+import {
+ workspaceFileFromWorkbenchItem,
+} from '@/workbench/workspaceFileItems'
+import WorkspaceFilePreviewPanel from './WorkspaceFilePreviewPanel.vue'
+
+/**
+ * Workspace file preview provider (kind 'file').
+ *
+ * No runtime is created: the panel component owns its own fetch/monaco
+ * lifecycle, so the registry only needs the component + props mapping.
+ */
+export function createWorkspaceFileWorkbenchDefinition(): WorkbenchPanelDefinition {
+ return {
+ kind: 'file',
+ component: WorkspaceFilePreviewPanel,
+ supports: item => workspaceFileFromWorkbenchItem(item) !== null,
+ getHeader: item => {
+ const fileRef = workspaceFileFromWorkbenchItem(item)
+ return {
+ icon: 'fileText',
+ title: item.title,
+ subtitle: fileRef ? fileRef.path : undefined,
+ }
+ },
+ getProps: item => {
+ const fileRef = workspaceFileFromWorkbenchItem(item)
+ if (!fileRef) return {}
+ return {
+ workspace: {
+ id: fileRef.workspaceId,
+ name: fileRef.workspaceName,
+ path: fileRef.workspacePath,
+ },
+ path: fileRef.path,
+ rootPath: fileRef.workspacePath,
+ openNonce: fileRef.openNonce,
+ }
+ },
+ }
+}
diff --git a/opensquilla-webui/src/lib/fileTreeModel.test.ts b/opensquilla-webui/src/lib/fileTreeModel.test.ts
new file mode 100644
index 000000000..890220b8a
--- /dev/null
+++ b/opensquilla-webui/src/lib/fileTreeModel.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildFileTreeModel,
+ flattenFileTreeModel,
+ flattenLiveFileTreeModel,
+ normalizeFileTreePath,
+ type FileNode,
+} from './fileTreeModel'
+
+describe('normalizeFileTreePath', () => {
+ it('normalizes windows separators, duplicate and edge slashes', () => {
+ expect(normalizeFileTreePath('src\\lib\\a.ts')).toBe('src/lib/a.ts')
+ expect(normalizeFileTreePath('/src//lib/b.ts/')).toBe('src/lib/b.ts')
+ expect(normalizeFileTreePath('src/./lib/a.ts')).toBe('src/./lib/a.ts')
+ expect(normalizeFileTreePath('')).toBe('')
+ })
+})
+
+describe('buildFileTreeModel', () => {
+ it('builds a sorted tree and flattens expanded directories', () => {
+ const model = buildFileTreeModel(['src/z.ts', 'src/lib/b.ts', 'src/lib/a.ts', 'README.md', 'docs/guide.md'])
+
+ expect(model.total).toBe(8)
+ const rows = flattenFileTreeModel(model, () => true).map((row) => [row.node.path, row.node.type, row.level])
+ expect(rows).toEqual([
+ ['docs', 'directory', 0],
+ ['docs/guide.md', 'file', 1],
+ ['src', 'directory', 0],
+ ['src/lib', 'directory', 1],
+ ['src/lib/a.ts', 'file', 2],
+ ['src/lib/b.ts', 'file', 2],
+ ['src/z.ts', 'file', 1],
+ ['README.md', 'file', 0],
+ ])
+ })
+
+ it('skips children of collapsed directories', () => {
+ const model = buildFileTreeModel(['src/lib/a.ts', 'src/z.ts'])
+ // The collapsed directory itself still renders; only its children skip.
+ const rows = flattenFileTreeModel(model, (path) => path !== 'src/lib').map((row) => row.node.path)
+ expect(rows).toEqual(['src', 'src/lib', 'src/z.ts'])
+ })
+
+ it('normalizes duplicate and messy paths without duplicating nodes', () => {
+ const model = buildFileTreeModel(['src\\lib\\a.ts', 'src/lib/a.ts', '/src//lib/b.ts/'])
+ expect(model.total).toBe(4)
+ const rows = flattenFileTreeModel(model, () => true).map((row) => row.node.path)
+ expect(rows).toEqual(['src', 'src/lib', 'src/lib/a.ts', 'src/lib/b.ts'])
+ })
+
+ it('sorts case-insensitively among siblings', () => {
+ const model = buildFileTreeModel(['readme.md', 'Zeta.ts', 'alpha.ts'])
+ const rows = flattenFileTreeModel(model, () => true).map((row) => row.node.name)
+ // alpha < readme < zeta, regardless of case.
+ expect(rows).toEqual(['alpha.ts', 'readme.md', 'Zeta.ts'])
+ })
+})
+
+describe('flattenLiveFileTreeModel', () => {
+ it('walks lazily-loaded children only through expanded directories', () => {
+ const nodes: Record = {
+ '': [
+ { name: 'docs', path: 'docs', type: 'directory' },
+ { name: 'app.ts', path: 'app.ts', type: 'file' },
+ ],
+ docs: [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' }],
+ }
+ const rows = flattenLiveFileTreeModel((path) => nodes[path] ?? [], () => true).map(
+ (row) => [row.node.path, row.level] as const,
+ )
+ expect(rows).toEqual([
+ ['docs', 0],
+ ['docs/guide.md', 1],
+ ['app.ts', 0],
+ ])
+
+ const collapsed = flattenLiveFileTreeModel((path) => nodes[path] ?? [], (path) => path !== 'docs').map(
+ (row) => row.node.path,
+ )
+ expect(collapsed).toEqual(['docs', 'app.ts'])
+ })
+})
diff --git a/opensquilla-webui/src/lib/fileTreeModel.ts b/opensquilla-webui/src/lib/fileTreeModel.ts
new file mode 100644
index 000000000..4d1f9aaa6
--- /dev/null
+++ b/opensquilla-webui/src/lib/fileTreeModel.ts
@@ -0,0 +1,123 @@
+/**
+ * File-tree model: pure functions that turn flat path lists or lazy
+ * per-directory children into the flat row list a virtualized renderer
+ * consumes.
+ *
+ * Ported (adapted to the OpenSquilla types and lint rules) from
+ * anomalyco/opencode `packages/app/src/components/file-tree-v2-model.ts`
+ * (MIT, Copyright (c) 2025 opencode). See THIRD_PARTY_NOTICES.md.
+ */
+
+export type FileNode = {
+ name: string
+ path: string
+ type: 'file' | 'directory'
+ size?: number
+ mtime?: number
+}
+
+export type FileTreeRow = {
+ node: FileNode
+ level: number
+}
+
+export type FileTreeModel = {
+ children: ReadonlyMap
+ total: number
+}
+
+export function normalizeFileTreePath(value: string): string {
+ return value
+ .replace(/\\/g, '/')
+ .replace(/^\/+|\/+$/g, '')
+ .replace(/\/{2,}/g, '/')
+}
+
+/**
+ * Build a tree model from a flat list of relative paths (POSIX form, or
+ * Windows backslash paths which are normalized first). Parent directories
+ * are synthesized. Siblings sort directories-first, then case-insensitively
+ * by name.
+ */
+export function buildFileTreeModel(paths: readonly string[]): FileTreeModel {
+ const nodes = new Map()
+
+ paths.forEach((value) => {
+ const file = normalizeFileTreePath(value)
+ if (!file) return
+
+ const parts = file.split('/')
+ parts.forEach((name, index) => {
+ const path = parts.slice(0, index + 1).join('/')
+ if (nodes.has(path)) return
+ nodes.set(path, {
+ name,
+ path,
+ type: index === parts.length - 1 ? 'file' : 'directory',
+ })
+ })
+ })
+
+ const children = new Map()
+ nodes.forEach((node) => {
+ const index = node.path.lastIndexOf('/')
+ const parent = index === -1 ? '' : node.path.slice(0, index)
+ const list = children.get(parent)
+ if (list) list.push(node)
+ else children.set(parent, [node])
+ })
+ children.forEach((siblings) =>
+ siblings.sort((a, b) => {
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
+ }),
+ )
+
+ return { children, total: nodes.size }
+}
+
+/**
+ * Flatten an already-built model into render rows, following only the
+ * directories for which `expanded(path)` is true.
+ */
+export function flattenFileTreeModel(model: FileTreeModel, expanded: (path: string) => boolean): FileTreeRow[] {
+ const rows: FileTreeRow[] = []
+ const stack = [...(model.children.get('') ?? [])].reverse().map((node) => ({ node, level: 0 }))
+
+ while (stack.length > 0) {
+ const row = stack.pop()!
+ rows.push(row)
+ if (row.node.type !== 'directory' || !expanded(row.node.path)) continue
+ const kids = model.children.get(row.node.path) ?? []
+ for (let index = kids.length - 1; index >= 0; index--) {
+ stack.push({ node: kids[index]!, level: row.level + 1 })
+ }
+ }
+
+ return rows
+}
+
+/**
+ * Flatten a lazily-loaded tree: `children(path)` returns the immediate
+ * children of one directory (root is ""), and only expanded directories are
+ * followed. Matches the Pinia store's per-directory listing.
+ */
+export function flattenLiveFileTreeModel(
+ children: (path: string) => readonly FileNode[],
+ expanded: (path: string) => boolean,
+): FileTreeRow[] {
+ const rows: FileTreeRow[] = []
+ const stack = [...children('')].reverse().map((node) => ({ node, level: 0 }))
+
+ while (stack.length > 0) {
+ const row = stack.pop()!
+ rows.push(row)
+ if (row.node.type !== 'directory' || !expanded(row.node.path)) continue
+ const nested = children(row.node.path)
+ for (let index = nested.length - 1; index >= 0; index--) {
+ stack.push({ node: nested[index]!, level: row.level + 1 })
+ }
+ }
+
+ return rows
+}
diff --git a/opensquilla-webui/src/locales/de.json b/opensquilla-webui/src/locales/de.json
index dffe09d48..ded8a956b 100644
--- a/opensquilla-webui/src/locales/de.json
+++ b/opensquilla-webui/src/locales/de.json
@@ -146,12 +146,36 @@
"empty": "Für dieses Element ist keine Vorschau verfügbar.",
"itemLimitReached": "Es sind bereits acht isolierte Vorschauen geöffnet. Schließe eine, bevor du eine weitere öffnest.",
"resources": {
- "title": "Dateien", "count": "Dateien ({count})", "empty": "Noch keine Dateien oder Links.",
- "groups": { "files": "Dateien", "links": "Links", "attachments": "Dateien", "documents": "Dateien", "deliverables": "Dateien", "urls": "Links" },
- "open": "{name} öffnen", "preparing": "Editor wird vorbereitet…", "retry": "Erneut versuchen", "preview": "{name} ansehen", "download": "{name} herunterladen", "edit": "Kopie von {name} bearbeiten", "publish": "{name} veröffentlichen",
- "imported": "{name} kann jetzt bearbeitet werden.", "published": "{name} kann jetzt geteilt werden.",
- "publishUnavailable": "Dieses Dokument hat keine veröffentlichbare aktuelle Version.", "actionFailed": "Die Arbeitsbereichsaktion ist fehlgeschlagen.",
- "unavailableReasons": { "htmlEncodingUnsupported": "Dieses HTML ist kein gültiges UTF-8 und kann nicht sicher angezeigt oder bearbeitet werden.", "htmlValidationFailed": "Dieses HTML konnte nicht für eine sichere Vorschau oder Bearbeitung validiert werden.", "htmlEditTooLarge": "Dieses HTML ist für die Bearbeitung in der App zu groß. Es kann weiterhin heruntergeladen werden.", "htmlPreviewTooLarge": "Dieses HTML ist für eine sichere Vorschau in der App zu groß. Es kann weiterhin heruntergeladen werden.", "officeAdapterNotAvailable": "Die Office-Bearbeitung ist noch nicht verfügbar.", "unsupported": "Diese Aktion ist für diese Ressource nicht verfügbar." }
+ "title": "Dateien",
+ "count": "Dateien ({count})",
+ "empty": "Noch keine Dateien oder Links.",
+ "groups": {
+ "files": "Dateien",
+ "links": "Links",
+ "attachments": "Dateien",
+ "documents": "Dateien",
+ "deliverables": "Dateien",
+ "urls": "Links"
+ },
+ "open": "{name} öffnen",
+ "preparing": "Editor wird vorbereitet…",
+ "retry": "Erneut versuchen",
+ "preview": "{name} ansehen",
+ "download": "{name} herunterladen",
+ "edit": "Kopie von {name} bearbeiten",
+ "publish": "{name} veröffentlichen",
+ "imported": "{name} kann jetzt bearbeitet werden.",
+ "published": "{name} kann jetzt geteilt werden.",
+ "publishUnavailable": "Dieses Dokument hat keine veröffentlichbare aktuelle Version.",
+ "actionFailed": "Die Arbeitsbereichsaktion ist fehlgeschlagen.",
+ "unavailableReasons": {
+ "htmlEncodingUnsupported": "Dieses HTML ist kein gültiges UTF-8 und kann nicht sicher angezeigt oder bearbeitet werden.",
+ "htmlValidationFailed": "Dieses HTML konnte nicht für eine sichere Vorschau oder Bearbeitung validiert werden.",
+ "htmlEditTooLarge": "Dieses HTML ist für die Bearbeitung in der App zu groß. Es kann weiterhin heruntergeladen werden.",
+ "htmlPreviewTooLarge": "Dieses HTML ist für eine sichere Vorschau in der App zu groß. Es kann weiterhin heruntergeladen werden.",
+ "officeAdapterNotAvailable": "Die Office-Bearbeitung ist noch nicht verfügbar.",
+ "unsupported": "Diese Aktion ist für diese Ressource nicht verfügbar."
+ }
},
"browser": {
"back": "Zurück",
@@ -166,11 +190,32 @@
"failedDetail": "Lade den isolierten Browser neu, um fortzufahren."
},
"artifactAnnotation": {
- "start": "Vorschau annotieren", "stop": "Annotieren beenden", "selectElement": "Seitenelement zum Annotieren auswählen", "selectElementShort": "Element wählen", "unavailable": "Vorschauannotation ist nicht verfügbar.", "desktopEditingOnly": "Seitenelemente können Sie in der Desktop-App auswählen und mit KI bearbeiten.",
- "createFailed": "Das ausgewählte Element konnte nicht angehängt werden.", "elementChanged": "Das ausgewählte Element wurde vor dem Anhängen geändert. Wählen Sie es in der aktuellen Vorschau erneut aus.", "rearmFailed": "Der Annotationsmodus wurde beendet, weil die Elementauswahl nicht neu gestartet werden konnte. Aktivieren Sie ihn erneut.", "updateFailed": "Der Annotationsentwurf konnte nicht gespeichert werden.", "discardFailed": "Die Annotation konnte nicht verworfen werden und bleibt für einen erneuten Versuch geöffnet.", "closeFailed": "Der Annotationseditor konnte nicht geschlossen werden. Versuchen Sie es erneut.", "replacementCleanupFailed": "Die neue Annotation wurde beibehalten, aber der vorherige Entwurf konnte nicht entfernt werden.",
+ "start": "Vorschau annotieren",
+ "stop": "Annotieren beenden",
+ "selectElement": "Seitenelement zum Annotieren auswählen",
+ "selectElementShort": "Element wählen",
+ "unavailable": "Vorschauannotation ist nicht verfügbar.",
+ "desktopEditingOnly": "Seitenelemente können Sie in der Desktop-App auswählen und mit KI bearbeiten.",
+ "createFailed": "Das ausgewählte Element konnte nicht angehängt werden.",
+ "elementChanged": "Das ausgewählte Element wurde vor dem Anhängen geändert. Wählen Sie es in der aktuellen Vorschau erneut aus.",
+ "rearmFailed": "Der Annotationsmodus wurde beendet, weil die Elementauswahl nicht neu gestartet werden konnte. Aktivieren Sie ihn erneut.",
+ "updateFailed": "Der Annotationsentwurf konnte nicht gespeichert werden.",
+ "discardFailed": "Die Annotation konnte nicht verworfen werden und bleibt für einen erneuten Versuch geöffnet.",
+ "closeFailed": "Der Annotationseditor konnte nicht geschlossen werden. Versuchen Sie es erneut.",
+ "replacementCleanupFailed": "Die neue Annotation wurde beibehalten, aber der vorherige Entwurf konnte nicht entfernt werden.",
"overlayFallback": "Setzen Sie die Anmerkung unten fort.",
- "fallbackTitle": "Annotation fortsetzen", "fallbackDetail": "Die Vorschau ist vorübergehend ausgeblendet. Ihre Anmerkung bleibt erhalten.", "frozenPreview": "Vorschau des ausgewählten Seitenbereichs",
- "placeholder": "Beschreiben Sie die Änderung für diesen Bereich…", "newlineHint": "{shortcut} für eine neue Zeile", "keepDraft": "Annotation behalten", "submit": "Annotation hinzufügen", "contextLabel": "Aktuelle Auswahl", "bodyLabel": "Seitenanmerkung", "emptyBody": "Beschreiben Sie die gewünschte Änderung.", "reselectHint": "Wählen Sie den passenden Bereich auf der Seite.", "reuseHint": "Die Änderungsanforderung wurde kopiert. Wählen Sie den passenden Bereich auf der Seite."
+ "fallbackTitle": "Annotation fortsetzen",
+ "fallbackDetail": "Die Vorschau ist vorübergehend ausgeblendet. Ihre Anmerkung bleibt erhalten.",
+ "frozenPreview": "Vorschau des ausgewählten Seitenbereichs",
+ "placeholder": "Beschreiben Sie die Änderung für diesen Bereich…",
+ "newlineHint": "{shortcut} für eine neue Zeile",
+ "keepDraft": "Annotation behalten",
+ "submit": "Annotation hinzufügen",
+ "contextLabel": "Aktuelle Auswahl",
+ "bodyLabel": "Seitenanmerkung",
+ "emptyBody": "Beschreiben Sie die gewünschte Änderung.",
+ "reselectHint": "Wählen Sie den passenden Bereich auf der Seite.",
+ "reuseHint": "Die Änderungsanforderung wurde kopiert. Wählen Sie den passenden Bereich auf der Seite."
},
"artifactPreview": {
"refresh": "Vorschau aktualisieren",
@@ -292,8 +337,15 @@
"unsavedSourceCopied": "Nicht gespeicherten Quelltext kopiert",
"copyUnsavedSourceFailed": "Der nicht gespeicherte Quelltext konnte nicht kopiert werden.",
"discardAndLoadLatest": "Verwerfen und neuesten Stand laden",
- "sourceStatus": {"ready":"Bereit","saving":"Wird gespeichert…","dirty":"Nicht gespeichert","saved":"Gespeichert","error":"Aktion erforderlich"}
- }
+ "sourceStatus": {
+ "ready": "Bereit",
+ "saving": "Wird gespeichert…",
+ "dirty": "Nicht gespeichert",
+ "saved": "Gespeichert",
+ "error": "Aktion erforderlich"
+ }
+ },
+ "tabOverflow": "Alle Tabs"
},
"nav": {
"chat": "Aufgabe",
@@ -489,9 +541,18 @@
"retryFailedTitle": "Die Vorschau konnte nicht neu erstellt werden",
"retryFailedDescription": "Diese Anfrage zur erneuten Erstellung wurde nicht abgeschlossen. Das vorherige Ergebnis bleibt unberücksichtigt und es wurden keine Dateien geändert. Versuchen Sie es erneut oder verwerfen Sie den Import.",
"jobStates": {
- "cancelled": {"title": "Vorschauerstellung abgebrochen", "description": "Die isolierte Quelle bleibt 24 Stunden erhalten. Die erneute Erstellung kann höchstens zwei Modellaufrufe ausführen, da ein nicht validiertes Ergebnis einmal wiederholt werden kann; das Ergebnis kann abweichen."},
- "interrupted": {"title": "Vorschauerstellung unterbrochen", "description": "OpenSquilla wurde während der Modellverarbeitung neu gestartet. Die isolierte Quelle bleibt verfügbar. Die erneute Erstellung kann durch eine interne Wiederholung höchstens zwei Modellaufrufe ausführen."},
- "failed": {"title": "Vorschauerstellung fehlgeschlagen", "description": "Dieser Versuch hat keine validierte, prüfbare Vorschau erzeugt. Es wurden keine Importentscheidungen übernommen und keine Dateien geändert. Die isolierte Quelle wurde beibehalten; die erneute Erstellung kann höchstens zwei Modellaufrufe ausführen."}
+ "cancelled": {
+ "title": "Vorschauerstellung abgebrochen",
+ "description": "Die isolierte Quelle bleibt 24 Stunden erhalten. Die erneute Erstellung kann höchstens zwei Modellaufrufe ausführen, da ein nicht validiertes Ergebnis einmal wiederholt werden kann; das Ergebnis kann abweichen."
+ },
+ "interrupted": {
+ "title": "Vorschauerstellung unterbrochen",
+ "description": "OpenSquilla wurde während der Modellverarbeitung neu gestartet. Die isolierte Quelle bleibt verfügbar. Die erneute Erstellung kann durch eine interne Wiederholung höchstens zwei Modellaufrufe ausführen."
+ },
+ "failed": {
+ "title": "Vorschauerstellung fehlgeschlagen",
+ "description": "Dieser Versuch hat keine validierte, prüfbare Vorschau erzeugt. Es wurden keine Importentscheidungen übernommen und keine Dateien geändert. Die isolierte Quelle wurde beibehalten; die erneute Erstellung kann höchstens zwei Modellaufrufe ausführen."
+ }
},
"modelAnalysisTitle": "Modellanalyse (mit den Dateiänderungen abgleichen)",
"previewTitle": "Jede Dateiänderung prüfen",
@@ -557,53 +618,135 @@
"available": "Sicherer Modus verfügbar",
"unavailable": "Sicherer Modus nicht verfügbar",
"builtin": "Integriert",
- "actions": { "add": "Hinzufügen", "remove": "Entfernen", "retry": "Erneut versuchen", "copy": "Kopieren", "saving": "Speichern…", "redetect": "Erneut prüfen" },
+ "actions": {
+ "add": "Hinzufügen",
+ "remove": "Entfernen",
+ "retry": "Erneut versuchen",
+ "copy": "Kopieren",
+ "saving": "Speichern…",
+ "redetect": "Erneut prüfen"
+ },
"mode": {
- "title": "Standardzugriffsmodus", "description": "Startmodus für neue Aufgaben. Safe bleibt bei fehlgeschlagener Prüfung nicht auswählbar.",
- "default": "Standardmodus", "safe": "Safe", "full": "Vollzugriff", "resetWarning": "Warnung beim Start wieder anzeigen"
+ "title": "Standardzugriffsmodus",
+ "description": "Startmodus für neue Aufgaben. Safe bleibt bei fehlgeschlagener Prüfung nicht auswählbar.",
+ "default": "Standardmodus",
+ "safe": "Safe",
+ "full": "Vollzugriff",
+ "resetWarning": "Warnung beim Start wieder anzeigen"
},
"setup": {
- "title": "Safe-Modus einrichten", "description": "OpenSquilla benötigt einmalig eine Administratorfreigabe, um ein isoliertes Konto sowie Datei- und Netzwerkschutz einzurichten.", "descriptionWithDuration": "OpenSquilla benötigt einmalig eine Administratorfreigabe, um ein isoliertes Konto und Schutzmaßnahmen einzurichten. Die Ersteinrichtung dauert normalerweise etwa 20–30 Sekunden. Lassen Sie OpenSquilla geöffnet.",
- "continue": "Weiter", "configuring": "Wird eingerichtet…", "requestingApproval": "Bestätigen Sie die Windows-Abfrage, um fortzufahren.", "configuringProtection": "OpenSquilla richtet den Safe-Modus fertig ein. Lassen Sie die App geöffnet.", "takingLonger": "Die Ersteinrichtung kann einige Minuten dauern. Die Überprüfung läuft automatisch.", "elapsed": "{seconds} s vergangen", "cancelled": "Einrichtung abgebrochen. Der Vollzugriff bleibt unverändert; Sie können es jederzeit erneut versuchen.",
- "failed": "Safe-Modus konnte nicht eingerichtet werden. Der Vollzugriff bleibt unverändert.", "verificationFailed": "Die Einrichtung ist fertig, aber die Sicherheitsprüfung ist fehlgeschlagen. Der Vollzugriff bleibt unverändert.",
- "runInBackground": "Im Hintergrund einrichten", "readyToast": "Der Safe-Modus ist bereit.", "failedToast": "Die Einrichtung des Safe-Modus konnte nicht abgeschlossen werden. Versuchen Sie es erneut über den Safe-Modus."
+ "title": "Safe-Modus einrichten",
+ "description": "OpenSquilla benötigt einmalig eine Administratorfreigabe, um ein isoliertes Konto sowie Datei- und Netzwerkschutz einzurichten.",
+ "descriptionWithDuration": "OpenSquilla benötigt einmalig eine Administratorfreigabe, um ein isoliertes Konto und Schutzmaßnahmen einzurichten. Die Ersteinrichtung dauert normalerweise etwa 20–30 Sekunden. Lassen Sie OpenSquilla geöffnet.",
+ "continue": "Weiter",
+ "configuring": "Wird eingerichtet…",
+ "requestingApproval": "Bestätigen Sie die Windows-Abfrage, um fortzufahren.",
+ "configuringProtection": "OpenSquilla richtet den Safe-Modus fertig ein. Lassen Sie die App geöffnet.",
+ "takingLonger": "Die Ersteinrichtung kann einige Minuten dauern. Die Überprüfung läuft automatisch.",
+ "elapsed": "{seconds} s vergangen",
+ "cancelled": "Einrichtung abgebrochen. Der Vollzugriff bleibt unverändert; Sie können es jederzeit erneut versuchen.",
+ "failed": "Safe-Modus konnte nicht eingerichtet werden. Der Vollzugriff bleibt unverändert.",
+ "verificationFailed": "Die Einrichtung ist fertig, aber die Sicherheitsprüfung ist fehlgeschlagen. Der Vollzugriff bleibt unverändert.",
+ "runInBackground": "Im Hintergrund einrichten",
+ "readyToast": "Der Safe-Modus ist bereit.",
+ "failedToast": "Die Einrichtung des Safe-Modus konnte nicht abgeschlossen werden. Versuchen Sie es erneut über den Safe-Modus."
},
"files": {
- "title": "Dateisicherheit", "description": "Normale Dateien sind lesbar. Änderungen an geschützten Pfaden erfordern eine Freigabe.", "readsAllowed": "Lesen erlaubt",
- "customPath": "Eigener geschützter Pfad", "pathPlaceholder": "Geschützten Pfad hinzufügen", "backupTitle": "Vor destruktiven Dateiänderungen sichern",
- "backupDescription": "Vor dem Löschen oder Ändern vorhandener Dateien wiederherstellbare Kopien anlegen und bei Bedarf die ältesten automatisch entfernen.", "quota": "Backup-Speicherlimit",
+ "title": "Dateisicherheit",
+ "description": "Normale Dateien sind lesbar. Änderungen an geschützten Pfaden erfordern eine Freigabe.",
+ "readsAllowed": "Lesen erlaubt",
+ "customPath": "Eigener geschützter Pfad",
+ "pathPlaceholder": "Geschützten Pfad hinzufügen",
+ "backupTitle": "Vor destruktiven Dateiänderungen sichern",
+ "backupDescription": "Vor dem Löschen oder Ändern vorhandener Dateien wiederherstellbare Kopien anlegen und bei Bedarf die ältesten automatisch entfernen.",
+ "quota": "Backup-Speicherlimit",
"recursiveWarning": "Ohne Backup können Löschen und Ersetzen unwiederbringlich sein. Schlägt das Backup nach dem Entfernen alter Kopien weiter fehl, fragt OpenSquilla vor dem Fortfahren erneut."
},
"commands": {
- "title": "Befehlssicherheit", "description": "Befehle laufen standardmäßig automatisch. Riskante Aktionen wie git push brauchen eine Freigabe.",
- "systemTools": "Systemwerkzeuge", "systemToolsAuto": "Automatisch erlauben", "systemToolsPrompt": "Vorher fragen", "systemToolsDisabled": "Deaktiviert",
- "approvalPrefixes": "Befehlspräfixe mit Freigabe", "autoPrefixes": "Immer automatisch erlaubte Präfixe", "prefixPlaceholder": "Zum Beispiel: git push"
+ "title": "Befehlssicherheit",
+ "description": "Befehle laufen standardmäßig automatisch. Riskante Aktionen wie git push brauchen eine Freigabe.",
+ "systemTools": "Systemwerkzeuge",
+ "systemToolsAuto": "Automatisch erlauben",
+ "systemToolsPrompt": "Vorher fragen",
+ "systemToolsDisabled": "Deaktiviert",
+ "approvalPrefixes": "Befehlspräfixe mit Freigabe",
+ "autoPrefixes": "Immer automatisch erlaubte Präfixe",
+ "prefixPlaceholder": "Zum Beispiel: git push"
},
"network": {
- "title": "Netzwerksicherheit", "description": "Öffentlicher Netzwerkzugriff ist standardmäßig erlaubt; SSRF- und Metadatenschutz bleiben aktiv.",
- "blockAll": "Gesamten Netzwerkzugriff sperren", "blockAllDescription": "Erlaubte Domains bleiben Ausnahmen.", "allowDomains": "Erlaubte Domains", "denyDomains": "Gesperrte Domains"
+ "title": "Netzwerksicherheit",
+ "description": "Öffentlicher Netzwerkzugriff ist standardmäßig erlaubt; SSRF- und Metadatenschutz bleiben aktiv.",
+ "blockAll": "Gesamten Netzwerkzugriff sperren",
+ "blockAllDescription": "Erlaubte Domains bleiben Ausnahmen.",
+ "allowDomains": "Erlaubte Domains",
+ "denyDomains": "Gesperrte Domains"
},
"runtimes": {
- "title": "Laufzeitpakete", "description": "Downloads werden automatisch aktiviert. Sicher bevorzugt installierte Laufzeitpakete; Vollzugriff priorisiert Host-Werkzeuge.", "target": "Laufzeitziel",
- "allowRuntime": "{runtime} erlauben", "progress": "{runtime}-Download: {progress}%",
+ "title": "Laufzeitpakete",
+ "description": "Downloads werden automatisch aktiviert. Sicher bevorzugt installierte Laufzeitpakete; Vollzugriff priorisiert Host-Werkzeuge.",
+ "target": "Laufzeitziel",
+ "allowRuntime": "{runtime} erlauben",
+ "progress": "{runtime}-Download: {progress}%",
"states": {
- "unknown": "Status nicht verfügbar", "notInstalled": "Nicht installiert", "disabled": "Nicht aktiviert", "installed": "Installiert", "installedVersion": "Installiert · {version}", "updatePaused": "Update pausiert",
- "unsupported": "Für dieses System nicht verfügbar", "corrupt": "Reparatur erforderlich", "queued": "Wartet auf Download", "downloading": "Wird heruntergeladen", "downloadingProgress": "Download · {progress}%",
- "verifying": "Download wird geprüft", "extracting": "Wird installiert", "probing": "Laufzeit wird geprüft", "activating": "Installation wird abgeschlossen", "cancelling": "Wird abgebrochen", "queuedRemoval": "Wartet auf Entfernung", "removing": "Wird entfernt",
- "cancelled": "Download abgebrochen", "failed": "Download fehlgeschlagen", "removeFailed": "Entfernen fehlgeschlagen", "removeInterrupted": "Entfernen unterbrochen", "interrupted": "Download pausiert"
+ "unknown": "Status nicht verfügbar",
+ "notInstalled": "Nicht installiert",
+ "disabled": "Nicht aktiviert",
+ "installed": "Installiert",
+ "installedVersion": "Installiert · {version}",
+ "updatePaused": "Update pausiert",
+ "unsupported": "Für dieses System nicht verfügbar",
+ "corrupt": "Reparatur erforderlich",
+ "queued": "Wartet auf Download",
+ "downloading": "Wird heruntergeladen",
+ "downloadingProgress": "Download · {progress}%",
+ "verifying": "Download wird geprüft",
+ "extracting": "Wird installiert",
+ "probing": "Laufzeit wird geprüft",
+ "activating": "Installation wird abgeschlossen",
+ "cancelling": "Wird abgebrochen",
+ "queuedRemoval": "Wartet auf Entfernung",
+ "removing": "Wird entfernt",
+ "cancelled": "Download abgebrochen",
+ "failed": "Download fehlgeschlagen",
+ "removeFailed": "Entfernen fehlgeschlagen",
+ "removeInterrupted": "Entfernen unterbrochen",
+ "interrupted": "Download pausiert"
+ },
+ "actions": {
+ "enable": "Aktivieren",
+ "download": "Herunterladen",
+ "cancel": "Abbrechen",
+ "discardDownload": "Download verwerfen",
+ "resume": "Fortsetzen",
+ "retry": "Erneut versuchen",
+ "repair": "Reparieren",
+ "remove": "Entfernen",
+ "retryRemove": "Entfernen wiederholen"
},
- "actions": { "enable": "Aktivieren", "download": "Herunterladen", "cancel": "Abbrechen", "discardDownload": "Download verwerfen", "resume": "Fortsetzen", "retry": "Erneut versuchen", "repair": "Reparieren", "remove": "Entfernen", "retryRemove": "Entfernen wiederholen" },
- "sources": { "oss": "OSS Peking", "github": "GitHub-Veröffentlichungen" }
+ "sources": {
+ "oss": "OSS Peking",
+ "github": "GitHub-Veröffentlichungen"
+ }
},
"lan": {
- "listen": "Im lokalen Netzwerk lauschen", "listenDescription": "Nach einem Neustart an alle lokalen Schnittstellen binden; öffentliche Peers bleiben gesperrt.",
- "allowedCidrs": "Erlaubte Client-CIDRs", "cidrDescription": "Optional. Leer erlaubt Loopback, RFC1918 und IPv6 ULA; Einträge können den Bereich nur einschränken.",
+ "listen": "Im lokalen Netzwerk lauschen",
+ "listenDescription": "Nach einem Neustart an alle lokalen Schnittstellen binden; öffentliche Peers bleiben gesperrt.",
+ "allowedCidrs": "Erlaubte Client-CIDRs",
+ "cidrDescription": "Optional. Leer erlaubt Loopback, RFC1918 und IPv6 ULA; Einträge können den Bereich nur einschränken.",
"restartRequired": "OpenSquilla neu starten, um die Änderung anzuwenden.",
- "title": "Web-Zugriff und benannte Tokens", "description": "Entfernter Web-Zugriff ohne gültiges Token nutzt den Gast-Sicherheitsmodus; benannte Tokens vergeben die konfigurierte Berechtigung.",
- "guest": "Kein oder ungültiges Token:", "guestDescription": " Gast-Sicherheitsmodus: Normale Dateien sind lesbar, nur der Standard-Arbeitsbereich ist beschreibbar und Anmeldedateien sind nicht lesbar.",
- "authenticated": "Gültiges Token:", "authenticatedDescription": " je nach Berechtigung Sicher oder Vollzugriff.",
- "tokenName": "Token-Name, z. B. Laptop", "hostExecute": "Host-Ausführung / Vollzugriff erlauben", "createToken": "Token erstellen",
- "copyNow": "Jetzt kopieren. Das Token wird nicht erneut angezeigt.", "fullCapable": "Sicher und Vollzugriff", "safeOnly": "Nur Sicher", "revoke": "Widerrufen"
+ "title": "Web-Zugriff und benannte Tokens",
+ "description": "Entfernter Web-Zugriff ohne gültiges Token nutzt den Gast-Sicherheitsmodus; benannte Tokens vergeben die konfigurierte Berechtigung.",
+ "guest": "Kein oder ungültiges Token:",
+ "guestDescription": " Gast-Sicherheitsmodus: Normale Dateien sind lesbar, nur der Standard-Arbeitsbereich ist beschreibbar und Anmeldedateien sind nicht lesbar.",
+ "authenticated": "Gültiges Token:",
+ "authenticatedDescription": " je nach Berechtigung Sicher oder Vollzugriff.",
+ "tokenName": "Token-Name, z. B. Laptop",
+ "hostExecute": "Host-Ausführung / Vollzugriff erlauben",
+ "createToken": "Token erstellen",
+ "copyNow": "Jetzt kopieren. Das Token wird nicht erneut angezeigt.",
+ "fullCapable": "Sicher und Vollzugriff",
+ "safeOnly": "Nur Sicher",
+ "revoke": "Widerrufen"
}
},
"dialog": {
@@ -2541,15 +2684,60 @@
"galleryEyebrow": "AUTOMATISIERUNGSVORLAGEN",
"bulkDeleteConfirm": "{count} ausgewählte Automatisierungen löschen? Dies kann nicht rückgängig gemacht werden.",
"templates": {
- "ai-daily": { "title": "Täglicher KI-Nachrichtenüberblick", "description": "Fasst wichtige KI-Nachrichten der letzten 24 Stunden mit geprüften Quellen zusammen.", "category": "Nachrichten", "schedule": "Täglich um 08:00" },
- "weekly-report": { "title": "Wöchentlicher Arbeitsrückblick", "description": "Fasst Fortschritte, Risiken, Ergebnisse und Prioritäten der nächsten Woche zusammen.", "category": "Produktivität", "schedule": "Freitag um 17:30" },
- "english-five": { "title": "Fünf englische Wörter täglich", "description": "Erstellt eine kurze Vokabellektion mit Beispielen und Quiz.", "category": "Lernen", "schedule": "Täglich um 09:00" },
- "project-risk": { "title": "Projektrisiken prüfen", "description": "Prüft Verzögerungen, Fehler und offene Punkte und schlägt Maßnahmen vor.", "category": "Projekte", "schedule": "Werktags um 10:00" },
- "knowledge-review": { "title": "Wöchentlicher Wissensrückblick", "description": "Ordnet neue Notizen und Besprechungen und erstellt eine Nachbereitungsliste.", "category": "Wissen", "schedule": "Sonntag um 18:00" },
- "daily-idea": { "title": "Tägliche Idee und Wissenshäppchen", "description": "Teilt eine verlässliche Tatsache und eine kleine umsetzbare Idee.", "category": "Alltag", "schedule": "Täglich um 12:00" },
- "bedtime-story": { "title": "Tägliche Gute-Nacht-Geschichte", "description": "Schreibt eine warme, fantasievolle Kurzgeschichte für Familien.", "category": "Familie", "schedule": "Täglich um 20:30" },
- "classic-movie": { "title": "Klassische Filmempfehlung", "description": "Empfiehlt einen anerkannten Filmklassiker ohne Spoiler.", "category": "Unterhaltung", "schedule": "Samstag um 19:00" },
- "today-in-history": { "title": "Heute in der Geschichte", "description": "Stellt ein verlässliches historisches Ereignis aus Wissenschaft, Kultur oder Gesellschaft vor.", "category": "Tägliches Wissen", "schedule": "Täglich um 08:30" }
+ "ai-daily": {
+ "title": "Täglicher KI-Nachrichtenüberblick",
+ "description": "Fasst wichtige KI-Nachrichten der letzten 24 Stunden mit geprüften Quellen zusammen.",
+ "category": "Nachrichten",
+ "schedule": "Täglich um 08:00"
+ },
+ "weekly-report": {
+ "title": "Wöchentlicher Arbeitsrückblick",
+ "description": "Fasst Fortschritte, Risiken, Ergebnisse und Prioritäten der nächsten Woche zusammen.",
+ "category": "Produktivität",
+ "schedule": "Freitag um 17:30"
+ },
+ "english-five": {
+ "title": "Fünf englische Wörter täglich",
+ "description": "Erstellt eine kurze Vokabellektion mit Beispielen und Quiz.",
+ "category": "Lernen",
+ "schedule": "Täglich um 09:00"
+ },
+ "project-risk": {
+ "title": "Projektrisiken prüfen",
+ "description": "Prüft Verzögerungen, Fehler und offene Punkte und schlägt Maßnahmen vor.",
+ "category": "Projekte",
+ "schedule": "Werktags um 10:00"
+ },
+ "knowledge-review": {
+ "title": "Wöchentlicher Wissensrückblick",
+ "description": "Ordnet neue Notizen und Besprechungen und erstellt eine Nachbereitungsliste.",
+ "category": "Wissen",
+ "schedule": "Sonntag um 18:00"
+ },
+ "daily-idea": {
+ "title": "Tägliche Idee und Wissenshäppchen",
+ "description": "Teilt eine verlässliche Tatsache und eine kleine umsetzbare Idee.",
+ "category": "Alltag",
+ "schedule": "Täglich um 12:00"
+ },
+ "bedtime-story": {
+ "title": "Tägliche Gute-Nacht-Geschichte",
+ "description": "Schreibt eine warme, fantasievolle Kurzgeschichte für Familien.",
+ "category": "Familie",
+ "schedule": "Täglich um 20:30"
+ },
+ "classic-movie": {
+ "title": "Klassische Filmempfehlung",
+ "description": "Empfiehlt einen anerkannten Filmklassiker ohne Spoiler.",
+ "category": "Unterhaltung",
+ "schedule": "Samstag um 19:00"
+ },
+ "today-in-history": {
+ "title": "Heute in der Geschichte",
+ "description": "Stellt ein verlässliches historisches Ereignis aus Wissenschaft, Kultur oder Gesellschaft vor.",
+ "category": "Tägliches Wissen",
+ "schedule": "Täglich um 08:30"
+ }
}
},
"deleteDialog": {
@@ -2671,39 +2859,39 @@
"fdChannel": "Ein Kanal",
"fdWebhook": "Ein Webhook",
"enabled": "Aktiviert",
- "saveSchedule": "Zeitplan speichern"
- ,"friendlyNamePlaceholder": "Beispiel: Kundenfeedback täglich zusammenfassen"
- ,"friendlyTypeCron": "Zu einer festen Zeit wiederholen"
- ,"friendlyTypeEvery": "In festem Abstand wiederholen"
- ,"friendlyTypeAt": "Einmal zu einer bestimmten Zeit ausführen"
- ,"executionFrequency": "Häufigkeit"
- ,"daily": "Täglich"
- ,"weekdays": "Werktags"
- ,"weekly": "Wöchentlich"
- ,"monthly": "Monatlich"
- ,"customAdvancedTime": "Erweiterter Zeitplan"
- ,"weekday": "Wochentag"
- ,"date": "Datum"
- ,"monthlyDay": "Jeden Monat am {day}."
- ,"specificTime": "Uhrzeit"
- ,"customTimeHint": "Verwenden Sie eine flexiblere Zeitregel."
- ,"openAdvancedTime": "Erweiterten Zeitplan öffnen"
- ,"everyHowOften": "Wiederholen alle"
- ,"timeUnit": "Zeiteinheit"
- ,"minutes": "Minuten"
- ,"hours": "Stunden"
- ,"days": "Tage"
- ,"dateAndTime": "Datum und Uhrzeit"
- ,"friendlyMessagePlaceholder": "Beispiel: Heutiges Kundenfeedback zusammenfassen und nächste Schritte vorschlagen"
- ,"moreRuntimeSettings": "Weitere Ausführungseinstellungen (optional)"
- ,"advancedTimeHint": "Nur verwenden, wenn die obige Zeitauswahl nicht ausreicht."
- ,"timezoneSimpleHint": "Normalerweise ist keine Änderung erforderlich."
- ,"projectWorkspace": "Projekt-Arbeitsbereich"
- ,"defaultWorkspace": "Standard-Arbeitsbereich verwenden"
- ,"noWorkspace": "Kein Projekt-Arbeitsbereich"
- ,"workspaceUnavailable": "{name} (nicht verfügbar)"
- ,"workspaceRequiredHint": "Wählen Sie das zu prüfende Projekt. Die Aufgabe arbeitet ausschließlich in diesem Projekt."
- ,"workspaceOptionalHint": "Optional. Wählen Sie für allgemeine Aufgaben „Kein Projekt-Arbeitsbereich“ oder ein Projekt, wenn dessen Dateien benötigt werden."
+ "saveSchedule": "Zeitplan speichern",
+ "friendlyNamePlaceholder": "Beispiel: Kundenfeedback täglich zusammenfassen",
+ "friendlyTypeCron": "Zu einer festen Zeit wiederholen",
+ "friendlyTypeEvery": "In festem Abstand wiederholen",
+ "friendlyTypeAt": "Einmal zu einer bestimmten Zeit ausführen",
+ "executionFrequency": "Häufigkeit",
+ "daily": "Täglich",
+ "weekdays": "Werktags",
+ "weekly": "Wöchentlich",
+ "monthly": "Monatlich",
+ "customAdvancedTime": "Erweiterter Zeitplan",
+ "weekday": "Wochentag",
+ "date": "Datum",
+ "monthlyDay": "Jeden Monat am {day}.",
+ "specificTime": "Uhrzeit",
+ "customTimeHint": "Verwenden Sie eine flexiblere Zeitregel.",
+ "openAdvancedTime": "Erweiterten Zeitplan öffnen",
+ "everyHowOften": "Wiederholen alle",
+ "timeUnit": "Zeiteinheit",
+ "minutes": "Minuten",
+ "hours": "Stunden",
+ "days": "Tage",
+ "dateAndTime": "Datum und Uhrzeit",
+ "friendlyMessagePlaceholder": "Beispiel: Heutiges Kundenfeedback zusammenfassen und nächste Schritte vorschlagen",
+ "moreRuntimeSettings": "Weitere Ausführungseinstellungen (optional)",
+ "advancedTimeHint": "Nur verwenden, wenn die obige Zeitauswahl nicht ausreicht.",
+ "timezoneSimpleHint": "Normalerweise ist keine Änderung erforderlich.",
+ "projectWorkspace": "Projekt-Arbeitsbereich",
+ "defaultWorkspace": "Standard-Arbeitsbereich verwenden",
+ "noWorkspace": "Kein Projekt-Arbeitsbereich",
+ "workspaceUnavailable": "{name} (nicht verfügbar)",
+ "workspaceRequiredHint": "Wählen Sie das zu prüfende Projekt. Die Aufgabe arbeitet ausschließlich in diesem Projekt.",
+ "workspaceOptionalHint": "Optional. Wählen Sie für allgemeine Aufgaben „Kein Projekt-Arbeitsbereich“ oder ein Projekt, wenn dessen Dateien benötigt werden."
},
"form": {
"cronPreviewPlaceholder": "Geben Sie einen Cron-Ausdruck mit 5 Feldern ein, um eine Vorschau zu sehen",
@@ -3338,9 +3526,18 @@
"planTitle": "Erhaltungsplan",
"planSummary": "Etwa alle {interval} Min. · bis zu ca. {count} Anfragen · Pause nach {duration} Min.",
"costWarning": "Echte Provider-Anfragen können Token-Kosten verursachen; sie erscheinen nicht im Chat und führen keine Tools aus.",
- "statusLabel": "Status", "targetLabel": "Provider / Modell", "lastHitLabel": "Zuletzt gecachte Tokens",
+ "statusLabel": "Status",
+ "targetLabel": "Provider / Modell",
+ "lastHitLabel": "Zuletzt gecachte Tokens",
"autoPauseLabel": "Automatische Pause",
- "states": { "off": "Aus", "waiting": "Wartet auf stabilen Präfix", "scheduled": "Geplant", "probing": "Prüfung läuft", "paused": "Leerlauflimit erreicht; wartet auf nächste Nachricht", "stopped": "Nach Fehlschlag gestoppt" }
+ "states": {
+ "off": "Aus",
+ "waiting": "Wartet auf stabilen Präfix",
+ "scheduled": "Geplant",
+ "probing": "Prüfung läuft",
+ "paused": "Leerlauflimit erreicht; wartet auf nächste Nachricht",
+ "stopped": "Nach Fehlschlag gestoppt"
+ }
},
"send": "Senden",
"sendQueues": "Senden (wird nach der aktuellen Antwort in die Warteschlange gestellt)",
@@ -3351,18 +3548,57 @@
"stoppingResponse": "Wird sicher beendet… Neue Nachrichten werden eingereiht.",
"messageToSend": "Zu sendende Nachricht",
"promptAnnotations": {
- "label": "Anmerkungen", "draftLabel": "Ausstehende Seitenanmerkungen", "sentLabel": "Seitenanmerkungen", "element": "Ausgewählter Bereich",
+ "label": "Anmerkungen",
+ "draftLabel": "Ausstehende Seitenanmerkungen",
+ "sentLabel": "Seitenanmerkungen",
+ "element": "Ausgewählter Bereich",
"targetLabel": "{kind}: {text}",
- "targetKinds": { "heading": "Überschrift", "button": "Schaltfläche", "link": "Link", "image": "Bild", "input": "Eingabefeld", "form": "Formular", "section": "Abschnitt", "list": "Liste", "table": "Tabelle", "text": "Text", "region": "Bereich", "element": "Ausgewählter Bereich" },
- "emptyDraft": "Anweisung hinzufügen", "stale": "Dieser Bereich wurde auf der aktuellen Seite nicht gefunden",
+ "targetKinds": {
+ "heading": "Überschrift",
+ "button": "Schaltfläche",
+ "link": "Link",
+ "image": "Bild",
+ "input": "Eingabefeld",
+ "form": "Formular",
+ "section": "Abschnitt",
+ "list": "Liste",
+ "table": "Tabelle",
+ "text": "Text",
+ "region": "Bereich",
+ "element": "Ausgewählter Bereich"
+ },
+ "emptyDraft": "Anweisung hinzufügen",
+ "stale": "Dieser Bereich wurde auf der aktuellen Seite nicht gefunden",
"editingBlocked": "Fügen Sie die gerade bearbeitete Anmerkung hinzu oder brechen Sie sie vor dem Senden ab.",
"staleBlocked": "Dieser Bereich wurde auf der aktuellen Seite nicht gefunden. Sie können ihn trotzdem an die KI senden.",
- "emptyBlocked": "Vervollständigen Sie alle Artefaktanweisungen.", "tooLongBlocked": "Kürzen Sie Artefaktanweisungen vor dem Senden auf 16 KiB UTF-8-Text.", "editLabel": "Artefaktanweisung bearbeiten",
- "removeLabel": "Artefaktanweisung entfernen", "updateFailed": "Die Artefaktanweisung konnte nicht gespeichert werden.",
- "discardFailed": "Die Artefaktanweisung konnte nicht entfernt werden.", "focusUnavailable": "Dieser Bereich wurde auf der aktuellen Seite nicht gefunden. Sie können ihn trotzdem an die KI senden.",
- "reuseLabel": "Als neue Annotation kopieren", "reuseDescription": "Kopiert die Änderungsanforderung; wählen Sie anschließend das Ziel auf der Seite.", "reuseUnavailable": "Diese Änderungsanforderung konnte nicht kopiert werden. Öffnen Sie die passende Seite und wählen Sie das Ziel.", "applyPrompt": "Wenden Sie die angehängten Seitenanmerkungen an.",
- "status": { "unknown": "Seite konnte nicht aktualisiert werden", "not_attempted": "Seite konnte nicht aktualisiert werden", "applied": "Seite aktualisiert", "appliedCorrected": "Seite aktualisiert", "not_applied": "Seite konnte nicht aktualisiert werden", "conflict": "Seite konnte nicht aktualisiert werden", "ambiguous": "Aktualisierung wird geprüft" },
- "statusDetail": { "unknown": "Öffnen Sie die Seite, um dies zu prüfen.", "not_attempted": "Die Seite wurde nicht aktualisiert.", "applied": "", "not_applied": "Versuchen Sie es erneut.", "conflict": "Öffnen Sie die Seite und versuchen Sie es erneut.", "ambiguous": "Öffnen Sie die Seite zur Prüfung, bevor Sie es erneut versuchen." }
+ "emptyBlocked": "Vervollständigen Sie alle Artefaktanweisungen.",
+ "tooLongBlocked": "Kürzen Sie Artefaktanweisungen vor dem Senden auf 16 KiB UTF-8-Text.",
+ "editLabel": "Artefaktanweisung bearbeiten",
+ "removeLabel": "Artefaktanweisung entfernen",
+ "updateFailed": "Die Artefaktanweisung konnte nicht gespeichert werden.",
+ "discardFailed": "Die Artefaktanweisung konnte nicht entfernt werden.",
+ "focusUnavailable": "Dieser Bereich wurde auf der aktuellen Seite nicht gefunden. Sie können ihn trotzdem an die KI senden.",
+ "reuseLabel": "Als neue Annotation kopieren",
+ "reuseDescription": "Kopiert die Änderungsanforderung; wählen Sie anschließend das Ziel auf der Seite.",
+ "reuseUnavailable": "Diese Änderungsanforderung konnte nicht kopiert werden. Öffnen Sie die passende Seite und wählen Sie das Ziel.",
+ "applyPrompt": "Wenden Sie die angehängten Seitenanmerkungen an.",
+ "status": {
+ "unknown": "Seite konnte nicht aktualisiert werden",
+ "not_attempted": "Seite konnte nicht aktualisiert werden",
+ "applied": "Seite aktualisiert",
+ "appliedCorrected": "Seite aktualisiert",
+ "not_applied": "Seite konnte nicht aktualisiert werden",
+ "conflict": "Seite konnte nicht aktualisiert werden",
+ "ambiguous": "Aktualisierung wird geprüft"
+ },
+ "statusDetail": {
+ "unknown": "Öffnen Sie die Seite, um dies zu prüfen.",
+ "not_attempted": "Die Seite wurde nicht aktualisiert.",
+ "applied": "",
+ "not_applied": "Versuchen Sie es erneut.",
+ "conflict": "Öffnen Sie die Seite und versuchen Sie es erneut.",
+ "ambiguous": "Öffnen Sie die Seite zur Prüfung, bevor Sie es erneut versuchen."
+ }
},
"placeholder": "Eine Nachricht senden...",
"placeholderCompact": "Nachricht...",
@@ -3460,7 +3696,7 @@
"audioUnsupported": "Dieser Browser kann dieses Audioformat nicht abspielen. Laden Sie die Datei stattdessen herunter.",
"playVideo": "Video abspielen",
"videoLoadFailed": "Video konnte nicht geladen werden.",
- "videoUnsupported": "Dieser Browser kann dieses Videoformat nicht abspielen. Laden Sie die Datei stattdessen herunter.",
+ "videoUnsupported": "Dieser Browser kann dieses Video nicht wiedergeben. Laden Sie es stattdessen herunter.",
"previewOf": "Vorschau: {title}",
"closePreview": "Vorschau schließen",
"previousImage": "Vorheriges Bild",
@@ -3469,7 +3705,6 @@
"previewDownload": "Vorschau herunterladen",
"previewTimedOut": "Zeitüberschreitung bei der Vorschau.",
"previewFailed": "Vorschau konnte nicht geladen werden.",
- "videoUnsupported": "Dieser Browser kann dieses Video nicht wiedergeben. Laden Sie es stattdessen herunter.",
"loadVideoPreview": "Videovorschau laden",
"loadVideoPreviewFor": "Videovorschau für {title} laden",
"videoPreviewLoading": "Die Videovorschau wird geladen. Sie können den Download abbrechen.",
@@ -4380,5 +4615,23 @@
"copyFailed": "Befehl konnte nicht kopiert werden",
"copyLabel": "Gateway-Neustart-Befehl kopieren"
}
+ },
+ "fileTree": {
+ "backToTasks": "Zurück zu Aufgaben",
+ "refresh": "Aktualisieren",
+ "retry": "Erneut versuchen",
+ "empty": "Dieser Arbeitsbereich enthält keine sichtbaren Dateien.",
+ "loading": "Dateien werden geladen…",
+ "viewFiles": "Dateien anzeigen",
+ "attachToChat": "An Chat anhängen",
+ "copyPath": "Pfad kopieren",
+ "previewTruncated": "Vorschau auf 1 MB beschränkt",
+ "binaryNotPreviewable": "Binärdateien können nicht vorgeschaut werden.",
+ "attachTooLarge": "{name} übersteigt 1 MB und kann nicht aus der Dateistruktur angehängt werden.",
+ "attachFailed": "{name} konnte nicht angehängt werden.",
+ "attachUnsupported": "{name} ist keine Textdatei und kann nicht aus der Dateistruktur angehängt werden.",
+ "attached": "{name} an den Composer angehängt.",
+ "ctxCopy": "Kopieren",
+ "ctxAttach": "Zur Unterhaltung hinzufügen"
}
}
diff --git a/opensquilla-webui/src/locales/en.json b/opensquilla-webui/src/locales/en.json
index 930a91a10..9bc5b0df9 100644
--- a/opensquilla-webui/src/locales/en.json
+++ b/opensquilla-webui/src/locales/en.json
@@ -146,12 +146,36 @@
"empty": "No preview is available for this item.",
"itemLimitReached": "Eight isolated previews are already open. Close one before opening another.",
"resources": {
- "title": "Files", "count": "Files ({count})", "empty": "No files or links yet.",
- "groups": { "files": "Files", "links": "Links", "attachments": "Files", "documents": "Files", "deliverables": "Files", "urls": "Links" },
- "open": "Open {name}", "preparing": "Preparing editor…", "retry": "Retry", "preview": "Preview {name}", "download": "Download {name}", "edit": "Edit a copy of {name}", "publish": "Publish {name}",
- "imported": "{name} is ready to edit.", "published": "{name} is ready to share.",
- "publishUnavailable": "This document does not have a publishable current revision.", "actionFailed": "The workbench action failed.",
- "unavailableReasons": { "htmlEncodingUnsupported": "This HTML is not valid UTF-8 and cannot be previewed or edited safely.", "htmlValidationFailed": "This HTML could not be validated for safe preview or editing.", "htmlEditTooLarge": "This HTML is too large to edit in the app. You can still download it.", "htmlPreviewTooLarge": "This HTML is too large to preview safely in the app. You can still download it.", "officeAdapterNotAvailable": "Office editing is not available yet.", "unsupported": "This action is not available for this resource." }
+ "title": "Files",
+ "count": "Files ({count})",
+ "empty": "No files or links yet.",
+ "groups": {
+ "files": "Files",
+ "links": "Links",
+ "attachments": "Files",
+ "documents": "Files",
+ "deliverables": "Files",
+ "urls": "Links"
+ },
+ "open": "Open {name}",
+ "preparing": "Preparing editor…",
+ "retry": "Retry",
+ "preview": "Preview {name}",
+ "download": "Download {name}",
+ "edit": "Edit a copy of {name}",
+ "publish": "Publish {name}",
+ "imported": "{name} is ready to edit.",
+ "published": "{name} is ready to share.",
+ "publishUnavailable": "This document does not have a publishable current revision.",
+ "actionFailed": "The workbench action failed.",
+ "unavailableReasons": {
+ "htmlEncodingUnsupported": "This HTML is not valid UTF-8 and cannot be previewed or edited safely.",
+ "htmlValidationFailed": "This HTML could not be validated for safe preview or editing.",
+ "htmlEditTooLarge": "This HTML is too large to edit in the app. You can still download it.",
+ "htmlPreviewTooLarge": "This HTML is too large to preview safely in the app. You can still download it.",
+ "officeAdapterNotAvailable": "Office editing is not available yet.",
+ "unsupported": "This action is not available for this resource."
+ }
},
"browser": {
"back": "Back",
@@ -166,11 +190,32 @@
"failedDetail": "Reload the isolated browser to continue."
},
"artifactAnnotation": {
- "start": "Annotate preview", "stop": "Stop annotating", "selectElement": "Select a page element to annotate", "selectElementShort": "Select element", "unavailable": "Preview annotation is unavailable.", "desktopEditingOnly": "Select and edit page elements with AI in the desktop app.",
- "createFailed": "The selected element could not be attached.", "elementChanged": "The selected element changed before it could be attached. Select it again in the current preview.", "rearmFailed": "Annotation mode stopped because the element picker could not be restarted. Turn annotation mode on to try again.", "updateFailed": "The annotation draft could not be saved.", "discardFailed": "The annotation could not be discarded. It remains open so you can try again.", "closeFailed": "The annotation editor could not be closed. Try again before selecting another element.", "replacementCleanupFailed": "The new annotation was kept, but the previous draft could not be removed.",
+ "start": "Annotate preview",
+ "stop": "Stop annotating",
+ "selectElement": "Select a page element to annotate",
+ "selectElementShort": "Select element",
+ "unavailable": "Preview annotation is unavailable.",
+ "desktopEditingOnly": "Select and edit page elements with AI in the desktop app.",
+ "createFailed": "The selected element could not be attached.",
+ "elementChanged": "The selected element changed before it could be attached. Select it again in the current preview.",
+ "rearmFailed": "Annotation mode stopped because the element picker could not be restarted. Turn annotation mode on to try again.",
+ "updateFailed": "The annotation draft could not be saved.",
+ "discardFailed": "The annotation could not be discarded. It remains open so you can try again.",
+ "closeFailed": "The annotation editor could not be closed. Try again before selecting another element.",
+ "replacementCleanupFailed": "The new annotation was kept, but the previous draft could not be removed.",
"overlayFallback": "Continue the annotation below.",
- "fallbackTitle": "Continue annotation", "fallbackDetail": "The preview is temporarily hidden. Your annotation is still here.", "frozenPreview": "Preview of the selected page area",
- "placeholder": "Describe the change for this area…", "newlineHint": "{shortcut} for a new line", "keepDraft": "Keep annotation", "submit": "Add annotation", "contextLabel": "Current selection", "bodyLabel": "Page annotation", "emptyBody": "Describe the requested change.", "reselectHint": "Select the matching area on the page.", "reuseHint": "The modification request was copied. Select the matching area on the page."
+ "fallbackTitle": "Continue annotation",
+ "fallbackDetail": "The preview is temporarily hidden. Your annotation is still here.",
+ "frozenPreview": "Preview of the selected page area",
+ "placeholder": "Describe the change for this area…",
+ "newlineHint": "{shortcut} for a new line",
+ "keepDraft": "Keep annotation",
+ "submit": "Add annotation",
+ "contextLabel": "Current selection",
+ "bodyLabel": "Page annotation",
+ "emptyBody": "Describe the requested change.",
+ "reselectHint": "Select the matching area on the page.",
+ "reuseHint": "The modification request was copied. Select the matching area on the page."
},
"artifactPreview": {
"refresh": "Refresh preview",
@@ -299,7 +344,8 @@
"saved": "Saved",
"error": "Needs attention"
}
- }
+ },
+ "tabOverflow": "All tabs"
},
"nav": {
"chat": "Task",
@@ -495,9 +541,18 @@
"retryFailedTitle": "The preview could not be regenerated",
"retryFailedDescription": "This regeneration request did not complete. The previous result remains unadopted and no files were changed. Try again or discard the import.",
"jobStates": {
- "cancelled": {"title": "Preview generation cancelled", "description": "The isolated source is retained for 24 hours. Regeneration may make at most two model calls because one result that does not pass validation can be retried once; the result may differ."},
- "interrupted": {"title": "Preview generation interrupted", "description": "OpenSquilla restarted while the model was processing. The isolated source is still available. Regeneration may make at most two model calls because one result that does not pass validation can be retried once."},
- "failed": {"title": "Preview generation failed", "description": "This attempt did not produce a validated, reviewable preview. No import decisions were adopted and no files were changed. The isolated source was retained; regeneration may make at most two model calls and the result may differ."}
+ "cancelled": {
+ "title": "Preview generation cancelled",
+ "description": "The isolated source is retained for 24 hours. Regeneration may make at most two model calls because one result that does not pass validation can be retried once; the result may differ."
+ },
+ "interrupted": {
+ "title": "Preview generation interrupted",
+ "description": "OpenSquilla restarted while the model was processing. The isolated source is still available. Regeneration may make at most two model calls because one result that does not pass validation can be retried once."
+ },
+ "failed": {
+ "title": "Preview generation failed",
+ "description": "This attempt did not produce a validated, reviewable preview. No import decisions were adopted and no files were changed. The isolated source was retained; regeneration may make at most two model calls and the result may differ."
+ }
},
"modelAnalysisTitle": "Model analysis (verify against the file changes)",
"previewTitle": "Review every file change",
@@ -2629,15 +2684,60 @@
"galleryEyebrow": "AUTOMATION GALLERY",
"bulkDeleteConfirm": "Delete {count} selected automation jobs? This cannot be undone.",
"templates": {
- "ai-daily": { "title": "Daily AI news briefing", "description": "Summarize important AI news from the last 24 hours with verified source links.", "category": "News & intelligence", "schedule": "Daily at 08:00" },
- "weekly-report": { "title": "Weekly work review", "description": "Summarize progress, risks, deliverables, and next week’s priorities.", "category": "Productivity", "schedule": "Friday at 17:30" },
- "english-five": { "title": "Five English words a day", "description": "Create a lightweight vocabulary lesson with examples and a short quiz.", "category": "Learning", "schedule": "Daily at 09:00" },
- "project-risk": { "title": "Project risk check", "description": "Review delays, errors, and open issues and report risk levels with recommendations.", "category": "Projects", "schedule": "Weekdays at 10:00" },
- "knowledge-review": { "title": "Weekly knowledge review", "description": "Organize new notes and meeting records and produce a follow-up reading list.", "category": "Knowledge", "schedule": "Sunday at 18:00" },
- "daily-idea": { "title": "Daily idea and fun fact", "description": "Share one reliable fact and one small, actionable idea.", "category": "Lifestyle", "schedule": "Daily at 12:00" },
- "bedtime-story": { "title": "Daily bedtime story", "description": "Write a warm, imaginative short story suitable for reading with children.", "category": "Family", "schedule": "Daily at 20:30" },
- "classic-movie": { "title": "Classic movie recommendation", "description": "Recommend one acclaimed classic film with a spoiler-free introduction.", "category": "Entertainment", "schedule": "Saturday at 19:00" },
- "today-in-history": { "title": "Today in history", "description": "Select one reliable historical event from science, culture, or society.", "category": "Daily knowledge", "schedule": "Daily at 08:30" }
+ "ai-daily": {
+ "title": "Daily AI news briefing",
+ "description": "Summarize important AI news from the last 24 hours with verified source links.",
+ "category": "News & intelligence",
+ "schedule": "Daily at 08:00"
+ },
+ "weekly-report": {
+ "title": "Weekly work review",
+ "description": "Summarize progress, risks, deliverables, and next week’s priorities.",
+ "category": "Productivity",
+ "schedule": "Friday at 17:30"
+ },
+ "english-five": {
+ "title": "Five English words a day",
+ "description": "Create a lightweight vocabulary lesson with examples and a short quiz.",
+ "category": "Learning",
+ "schedule": "Daily at 09:00"
+ },
+ "project-risk": {
+ "title": "Project risk check",
+ "description": "Review delays, errors, and open issues and report risk levels with recommendations.",
+ "category": "Projects",
+ "schedule": "Weekdays at 10:00"
+ },
+ "knowledge-review": {
+ "title": "Weekly knowledge review",
+ "description": "Organize new notes and meeting records and produce a follow-up reading list.",
+ "category": "Knowledge",
+ "schedule": "Sunday at 18:00"
+ },
+ "daily-idea": {
+ "title": "Daily idea and fun fact",
+ "description": "Share one reliable fact and one small, actionable idea.",
+ "category": "Lifestyle",
+ "schedule": "Daily at 12:00"
+ },
+ "bedtime-story": {
+ "title": "Daily bedtime story",
+ "description": "Write a warm, imaginative short story suitable for reading with children.",
+ "category": "Family",
+ "schedule": "Daily at 20:30"
+ },
+ "classic-movie": {
+ "title": "Classic movie recommendation",
+ "description": "Recommend one acclaimed classic film with a spoiler-free introduction.",
+ "category": "Entertainment",
+ "schedule": "Saturday at 19:00"
+ },
+ "today-in-history": {
+ "title": "Today in history",
+ "description": "Select one reliable historical event from science, culture, or society.",
+ "category": "Daily knowledge",
+ "schedule": "Daily at 08:30"
+ }
}
},
"deleteDialog": {
@@ -2759,39 +2859,39 @@
"fdChannel": "A channel",
"fdWebhook": "A webhook",
"enabled": "Enabled",
- "saveSchedule": "Save schedule"
- ,"friendlyNamePlaceholder": "For example: Organize customer feedback every day"
- ,"friendlyTypeCron": "Repeat at a scheduled time"
- ,"friendlyTypeEvery": "Repeat at a fixed interval"
- ,"friendlyTypeAt": "Run once at a specified time"
- ,"executionFrequency": "Frequency"
- ,"daily": "Daily"
- ,"weekdays": "Weekdays"
- ,"weekly": "Weekly"
- ,"monthly": "Monthly"
- ,"customAdvancedTime": "Custom advanced schedule"
- ,"weekday": "Day of week"
- ,"date": "Date"
- ,"monthlyDay": "Day {day} of each month"
- ,"specificTime": "Time"
- ,"customTimeHint": "Use a more flexible schedule rule."
- ,"openAdvancedTime": "Open advanced schedule"
- ,"everyHowOften": "Repeat every"
- ,"timeUnit": "Time unit"
- ,"minutes": "Minutes"
- ,"hours": "Hours"
- ,"days": "Days"
- ,"dateAndTime": "Date and time"
- ,"friendlyMessagePlaceholder": "For example: Summarize today's customer feedback and suggest next steps"
- ,"moreRuntimeSettings": "More runtime settings (optional)"
- ,"advancedTimeHint": "Use this only when the schedule picker above is not flexible enough."
- ,"timezoneSimpleHint": "Usually you do not need to change this."
- ,"projectWorkspace": "Project workspace"
- ,"defaultWorkspace": "Use the default workspace"
- ,"noWorkspace": "No project workspace"
- ,"workspaceUnavailable": "{name} (unavailable)"
- ,"workspaceRequiredHint": "Choose the project this task should inspect. The task will read and work only in that project."
- ,"workspaceOptionalHint": "Optional. Choose “No project workspace” for general tasks, or select a project when the task needs its files."
+ "saveSchedule": "Save schedule",
+ "friendlyNamePlaceholder": "For example: Organize customer feedback every day",
+ "friendlyTypeCron": "Repeat at a scheduled time",
+ "friendlyTypeEvery": "Repeat at a fixed interval",
+ "friendlyTypeAt": "Run once at a specified time",
+ "executionFrequency": "Frequency",
+ "daily": "Daily",
+ "weekdays": "Weekdays",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "customAdvancedTime": "Custom advanced schedule",
+ "weekday": "Day of week",
+ "date": "Date",
+ "monthlyDay": "Day {day} of each month",
+ "specificTime": "Time",
+ "customTimeHint": "Use a more flexible schedule rule.",
+ "openAdvancedTime": "Open advanced schedule",
+ "everyHowOften": "Repeat every",
+ "timeUnit": "Time unit",
+ "minutes": "Minutes",
+ "hours": "Hours",
+ "days": "Days",
+ "dateAndTime": "Date and time",
+ "friendlyMessagePlaceholder": "For example: Summarize today's customer feedback and suggest next steps",
+ "moreRuntimeSettings": "More runtime settings (optional)",
+ "advancedTimeHint": "Use this only when the schedule picker above is not flexible enough.",
+ "timezoneSimpleHint": "Usually you do not need to change this.",
+ "projectWorkspace": "Project workspace",
+ "defaultWorkspace": "Use the default workspace",
+ "noWorkspace": "No project workspace",
+ "workspaceUnavailable": "{name} (unavailable)",
+ "workspaceRequiredHint": "Choose the project this task should inspect. The task will read and work only in that project.",
+ "workspaceOptionalHint": "Optional. Choose “No project workspace” for general tasks, or select a project when the task needs its files."
},
"form": {
"cronPreviewPlaceholder": "Enter a 5-field cron expression to preview",
@@ -3430,7 +3530,14 @@
"targetLabel": "Provider / model",
"lastHitLabel": "Last cached tokens",
"autoPauseLabel": "Auto-pause",
- "states": { "off": "Off", "waiting": "Waiting for a stable prefix", "scheduled": "Scheduled", "probing": "Probing", "paused": "Idle limit reached; waiting for the next message", "stopped": "Stopped after a miss or error" }
+ "states": {
+ "off": "Off",
+ "waiting": "Waiting for a stable prefix",
+ "scheduled": "Scheduled",
+ "probing": "Probing",
+ "paused": "Idle limit reached; waiting for the next message",
+ "stopped": "Stopped after a miss or error"
+ }
},
"send": "Send",
"sendQueues": "Send (queues for after current response)",
@@ -3441,18 +3548,57 @@
"stoppingResponse": "Stopping safely… New messages will be queued.",
"messageToSend": "Message to send",
"promptAnnotations": {
- "label": "Annotations", "draftLabel": "Pending page annotations", "sentLabel": "Page annotations", "element": "Selected area",
+ "label": "Annotations",
+ "draftLabel": "Pending page annotations",
+ "sentLabel": "Page annotations",
+ "element": "Selected area",
"targetLabel": "{kind}: {text}",
- "targetKinds": { "heading": "Heading", "button": "Button", "link": "Link", "image": "Image", "input": "Input", "form": "Form", "section": "Section", "list": "List", "table": "Table", "text": "Text", "region": "Area", "element": "Selected area" },
- "emptyDraft": "Add an instruction", "stale": "Couldn’t locate this area on the current page",
+ "targetKinds": {
+ "heading": "Heading",
+ "button": "Button",
+ "link": "Link",
+ "image": "Image",
+ "input": "Input",
+ "form": "Form",
+ "section": "Section",
+ "list": "List",
+ "table": "Table",
+ "text": "Text",
+ "region": "Area",
+ "element": "Selected area"
+ },
+ "emptyDraft": "Add an instruction",
+ "stale": "Couldn’t locate this area on the current page",
"editingBlocked": "Add or cancel the annotation you are editing before sending.",
"staleBlocked": "This area couldn’t be located on the current page. You can still send it to AI.",
- "emptyBlocked": "Finish every artifact instruction before sending.", "tooLongBlocked": "Shorten artifact instructions to 16 KiB of UTF-8 text before sending.", "editLabel": "Edit artifact instruction",
- "removeLabel": "Remove artifact instruction", "updateFailed": "The artifact instruction could not be saved.",
- "discardFailed": "The artifact instruction could not be removed.", "focusUnavailable": "Couldn’t locate this area on the current page. You can still send it to AI.",
- "reuseLabel": "Copy as new annotation", "reuseDescription": "Copy the modification request and choose its target on the page.", "reuseUnavailable": "This modification request could not be copied. Open the matching page and choose its target.", "applyPrompt": "Apply the attached page annotations.",
- "status": { "unknown": "Couldn’t update page", "not_attempted": "Couldn’t update page", "applied": "Page updated", "appliedCorrected": "Page updated", "not_applied": "Couldn’t update page", "conflict": "Couldn’t update page", "ambiguous": "Checking update" },
- "statusDetail": { "unknown": "Open the page to check.", "not_attempted": "No page update was made.", "applied": "", "not_applied": "Try again.", "conflict": "Open the page and try again.", "ambiguous": "Open the page to check before retrying." }
+ "emptyBlocked": "Finish every artifact instruction before sending.",
+ "tooLongBlocked": "Shorten artifact instructions to 16 KiB of UTF-8 text before sending.",
+ "editLabel": "Edit artifact instruction",
+ "removeLabel": "Remove artifact instruction",
+ "updateFailed": "The artifact instruction could not be saved.",
+ "discardFailed": "The artifact instruction could not be removed.",
+ "focusUnavailable": "Couldn’t locate this area on the current page. You can still send it to AI.",
+ "reuseLabel": "Copy as new annotation",
+ "reuseDescription": "Copy the modification request and choose its target on the page.",
+ "reuseUnavailable": "This modification request could not be copied. Open the matching page and choose its target.",
+ "applyPrompt": "Apply the attached page annotations.",
+ "status": {
+ "unknown": "Couldn’t update page",
+ "not_attempted": "Couldn’t update page",
+ "applied": "Page updated",
+ "appliedCorrected": "Page updated",
+ "not_applied": "Couldn’t update page",
+ "conflict": "Couldn’t update page",
+ "ambiguous": "Checking update"
+ },
+ "statusDetail": {
+ "unknown": "Open the page to check.",
+ "not_attempted": "No page update was made.",
+ "applied": "",
+ "not_applied": "Try again.",
+ "conflict": "Open the page and try again.",
+ "ambiguous": "Open the page to check before retrying."
+ }
},
"placeholder": "Send a message...",
"placeholderCompact": "Message...",
@@ -3550,7 +3696,7 @@
"audioUnsupported": "This browser cannot play this audio format. Download the file instead.",
"playVideo": "Play video",
"videoLoadFailed": "Video could not be loaded.",
- "videoUnsupported": "This browser cannot play this video format. Download the file instead.",
+ "videoUnsupported": "This browser cannot play this video. Download it instead.",
"previewOf": "Preview: {title}",
"closePreview": "Close preview",
"previousImage": "Previous image",
@@ -3559,7 +3705,6 @@
"previewDownload": "Preview download",
"previewTimedOut": "Preview timed out.",
"previewFailed": "Preview failed to load.",
- "videoUnsupported": "This browser cannot play this video. Download it instead.",
"loadVideoPreview": "Load video preview",
"loadVideoPreviewFor": "Load video preview for {title}",
"videoPreviewLoading": "Loading the video preview. You can cancel while it downloads.",
@@ -4470,5 +4615,23 @@
"copyFailed": "Could not copy the command",
"copyLabel": "Copy the gateway restart command"
}
+ },
+ "fileTree": {
+ "backToTasks": "Back to tasks",
+ "refresh": "Refresh",
+ "retry": "Retry",
+ "empty": "This workspace has no visible files.",
+ "loading": "Loading files…",
+ "viewFiles": "View files",
+ "attachToChat": "Attach to chat",
+ "copyPath": "Copy path",
+ "previewTruncated": "preview limited to 1 MB",
+ "binaryNotPreviewable": "Binary files cannot be previewed.",
+ "attachTooLarge": "{name} is over 1 MB and cannot be attached from the file tree.",
+ "attachFailed": "Could not attach {name}.",
+ "attachUnsupported": "{name} is not attachable as text from the file tree.",
+ "attached": "{name} attached to the composer.",
+ "ctxCopy": "Copy",
+ "ctxAttach": "Add to conversation"
}
}
diff --git a/opensquilla-webui/src/locales/es.json b/opensquilla-webui/src/locales/es.json
index 625973226..e6b0f057e 100644
--- a/opensquilla-webui/src/locales/es.json
+++ b/opensquilla-webui/src/locales/es.json
@@ -146,12 +146,36 @@
"empty": "No hay una vista previa disponible para este elemento.",
"itemLimitReached": "Ya hay ocho vistas previas aisladas abiertas. Cierra una antes de abrir otra.",
"resources": {
- "title": "Archivos", "count": "Archivos ({count})", "empty": "Aún no hay archivos ni enlaces.",
- "groups": { "files": "Archivos", "links": "Enlaces", "attachments": "Archivos", "documents": "Archivos", "deliverables": "Archivos", "urls": "Enlaces" },
- "open": "Abrir {name}", "preparing": "Preparando el editor…", "retry": "Reintentar", "preview": "Vista previa de {name}", "download": "Descargar {name}", "edit": "Editar una copia de {name}", "publish": "Publicar {name}",
- "imported": "Ya puedes editar {name}.", "published": "Ya puedes compartir {name}.",
- "publishUnavailable": "Este documento no tiene una revisión actual publicable.", "actionFailed": "La acción del área de trabajo falló.",
- "unavailableReasons": { "htmlEncodingUnsupported": "Este HTML no es UTF-8 válido y no se puede previsualizar ni editar de forma segura.", "htmlValidationFailed": "No se pudo validar este HTML para una vista previa o edición seguras.", "htmlEditTooLarge": "Este HTML es demasiado grande para editarlo en la aplicación. Aún puedes descargarlo.", "htmlPreviewTooLarge": "Este HTML es demasiado grande para previsualizarlo de forma segura. Aún puedes descargarlo.", "officeAdapterNotAvailable": "La edición de Office aún no está disponible.", "unsupported": "Esta acción no está disponible para este recurso." }
+ "title": "Archivos",
+ "count": "Archivos ({count})",
+ "empty": "Aún no hay archivos ni enlaces.",
+ "groups": {
+ "files": "Archivos",
+ "links": "Enlaces",
+ "attachments": "Archivos",
+ "documents": "Archivos",
+ "deliverables": "Archivos",
+ "urls": "Enlaces"
+ },
+ "open": "Abrir {name}",
+ "preparing": "Preparando el editor…",
+ "retry": "Reintentar",
+ "preview": "Vista previa de {name}",
+ "download": "Descargar {name}",
+ "edit": "Editar una copia de {name}",
+ "publish": "Publicar {name}",
+ "imported": "Ya puedes editar {name}.",
+ "published": "Ya puedes compartir {name}.",
+ "publishUnavailable": "Este documento no tiene una revisión actual publicable.",
+ "actionFailed": "La acción del área de trabajo falló.",
+ "unavailableReasons": {
+ "htmlEncodingUnsupported": "Este HTML no es UTF-8 válido y no se puede previsualizar ni editar de forma segura.",
+ "htmlValidationFailed": "No se pudo validar este HTML para una vista previa o edición seguras.",
+ "htmlEditTooLarge": "Este HTML es demasiado grande para editarlo en la aplicación. Aún puedes descargarlo.",
+ "htmlPreviewTooLarge": "Este HTML es demasiado grande para previsualizarlo de forma segura. Aún puedes descargarlo.",
+ "officeAdapterNotAvailable": "La edición de Office aún no está disponible.",
+ "unsupported": "Esta acción no está disponible para este recurso."
+ }
},
"browser": {
"back": "Atrás",
@@ -166,11 +190,32 @@
"failedDetail": "Vuelve a cargar el navegador aislado para continuar."
},
"artifactAnnotation": {
- "start": "Anotar vista previa", "stop": "Dejar de anotar", "selectElement": "Selecciona un elemento de la página para anotar", "selectElementShort": "Selecciona elemento", "unavailable": "La anotación de vista previa no está disponible.", "desktopEditingOnly": "Selecciona y edita elementos de la página con IA en la aplicación de escritorio.",
- "createFailed": "No se pudo adjuntar el elemento seleccionado.", "elementChanged": "El elemento seleccionado cambió antes de adjuntarse. Vuelva a seleccionarlo en la vista previa actual.", "rearmFailed": "El modo de anotación se detuvo porque no se pudo reiniciar el selector. Vuelva a activarlo para intentarlo de nuevo.", "updateFailed": "No se pudo guardar el borrador de anotación.", "discardFailed": "No se pudo descartar la anotación. Seguirá abierta para volver a intentarlo.", "closeFailed": "No se pudo cerrar el editor de anotaciones. Inténtelo de nuevo.", "replacementCleanupFailed": "Se conservó la anotación nueva, pero no se pudo eliminar el borrador anterior.",
+ "start": "Anotar vista previa",
+ "stop": "Dejar de anotar",
+ "selectElement": "Selecciona un elemento de la página para anotar",
+ "selectElementShort": "Selecciona elemento",
+ "unavailable": "La anotación de vista previa no está disponible.",
+ "desktopEditingOnly": "Selecciona y edita elementos de la página con IA en la aplicación de escritorio.",
+ "createFailed": "No se pudo adjuntar el elemento seleccionado.",
+ "elementChanged": "El elemento seleccionado cambió antes de adjuntarse. Vuelva a seleccionarlo en la vista previa actual.",
+ "rearmFailed": "El modo de anotación se detuvo porque no se pudo reiniciar el selector. Vuelva a activarlo para intentarlo de nuevo.",
+ "updateFailed": "No se pudo guardar el borrador de anotación.",
+ "discardFailed": "No se pudo descartar la anotación. Seguirá abierta para volver a intentarlo.",
+ "closeFailed": "No se pudo cerrar el editor de anotaciones. Inténtelo de nuevo.",
+ "replacementCleanupFailed": "Se conservó la anotación nueva, pero no se pudo eliminar el borrador anterior.",
"overlayFallback": "Continúa la anotación a continuación.",
- "fallbackTitle": "Continuar anotación", "fallbackDetail": "La vista previa está oculta temporalmente. Tu anotación sigue aquí.", "frozenPreview": "Vista previa de la zona seleccionada",
- "placeholder": "Describe el cambio para esta zona…", "newlineHint": "{shortcut} para una línea nueva", "keepDraft": "Conservar anotación", "submit": "Añadir anotación", "contextLabel": "Selección actual", "bodyLabel": "Anotación de página", "emptyBody": "Describe el cambio solicitado.", "reselectHint": "Selecciona la zona correspondiente de la página.", "reuseHint": "Se copió la solicitud de cambio. Selecciona la zona correspondiente de la página."
+ "fallbackTitle": "Continuar anotación",
+ "fallbackDetail": "La vista previa está oculta temporalmente. Tu anotación sigue aquí.",
+ "frozenPreview": "Vista previa de la zona seleccionada",
+ "placeholder": "Describe el cambio para esta zona…",
+ "newlineHint": "{shortcut} para una línea nueva",
+ "keepDraft": "Conservar anotación",
+ "submit": "Añadir anotación",
+ "contextLabel": "Selección actual",
+ "bodyLabel": "Anotación de página",
+ "emptyBody": "Describe el cambio solicitado.",
+ "reselectHint": "Selecciona la zona correspondiente de la página.",
+ "reuseHint": "Se copió la solicitud de cambio. Selecciona la zona correspondiente de la página."
},
"artifactPreview": {
"refresh": "Actualizar vista previa",
@@ -292,8 +337,15 @@
"unsavedSourceCopied": "Código sin guardar copiado",
"copyUnsavedSourceFailed": "No se pudo copiar el código sin guardar.",
"discardAndLoadLatest": "Descartar y cargar la última versión",
- "sourceStatus": {"ready":"Listo","saving":"Guardando…","dirty":"Sin guardar","saved":"Guardado","error":"Requiere atención"}
- }
+ "sourceStatus": {
+ "ready": "Listo",
+ "saving": "Guardando…",
+ "dirty": "Sin guardar",
+ "saved": "Guardado",
+ "error": "Requiere atención"
+ }
+ },
+ "tabOverflow": "Todas las pestañas"
},
"nav": {
"chat": "Tarea",
@@ -489,9 +541,18 @@
"retryFailedTitle": "No se pudo regenerar la vista previa",
"retryFailedDescription": "Esta solicitud de regeneración no se completó. El resultado anterior sigue sin adoptarse y no se modificó ningún archivo. Inténtalo de nuevo o descarta la importación.",
"jobStates": {
- "cancelled": {"title": "Generación de vista previa cancelada", "description": "La fuente aislada se conserva durante 24 horas. La regeneración puede hacer hasta dos llamadas al modelo porque un resultado que no supere la validación puede reintentarse una vez; el resultado puede variar."},
- "interrupted": {"title": "Generación de vista previa interrumpida", "description": "OpenSquilla se reinició durante el procesamiento del modelo. La fuente aislada sigue disponible. La regeneración puede hacer hasta dos llamadas al modelo debido a un reintento interno."},
- "failed": {"title": "Error al generar la vista previa", "description": "Este intento no produjo una vista previa validada y revisable. No se adoptó ninguna decisión de importación ni se modificó ningún archivo. La fuente aislada se conservó; la regeneración puede hacer hasta dos llamadas al modelo."}
+ "cancelled": {
+ "title": "Generación de vista previa cancelada",
+ "description": "La fuente aislada se conserva durante 24 horas. La regeneración puede hacer hasta dos llamadas al modelo porque un resultado que no supere la validación puede reintentarse una vez; el resultado puede variar."
+ },
+ "interrupted": {
+ "title": "Generación de vista previa interrumpida",
+ "description": "OpenSquilla se reinició durante el procesamiento del modelo. La fuente aislada sigue disponible. La regeneración puede hacer hasta dos llamadas al modelo debido a un reintento interno."
+ },
+ "failed": {
+ "title": "Error al generar la vista previa",
+ "description": "Este intento no produjo una vista previa validada y revisable. No se adoptó ninguna decisión de importación ni se modificó ningún archivo. La fuente aislada se conservó; la regeneración puede hacer hasta dos llamadas al modelo."
+ }
},
"modelAnalysisTitle": "Análisis del modelo (contrástalo con los cambios de archivos)",
"previewTitle": "Revisar todos los cambios de archivos",
@@ -557,53 +618,135 @@
"available": "Modo seguro disponible",
"unavailable": "Modo seguro no disponible",
"builtin": "Integrado",
- "actions": { "add": "Añadir", "remove": "Quitar", "retry": "Reintentar", "copy": "Copiar", "saving": "Guardando…", "redetect": "Volver a detectar" },
+ "actions": {
+ "add": "Añadir",
+ "remove": "Quitar",
+ "retry": "Reintentar",
+ "copy": "Copiar",
+ "saving": "Guardando…",
+ "redetect": "Volver a detectar"
+ },
"mode": {
- "title": "Modo de acceso predeterminado", "description": "Elige el modo inicial de las tareas nuevas. Seguro no se puede seleccionar si falla la comprobación.",
- "default": "Modo predeterminado", "safe": "Seguro", "full": "Acceso completo", "resetWarning": "Volver a mostrar el aviso al iniciar"
+ "title": "Modo de acceso predeterminado",
+ "description": "Elige el modo inicial de las tareas nuevas. Seguro no se puede seleccionar si falla la comprobación.",
+ "default": "Modo predeterminado",
+ "safe": "Seguro",
+ "full": "Acceso completo",
+ "resetWarning": "Volver a mostrar el aviso al iniciar"
},
"setup": {
- "title": "Configurar el modo Seguro", "description": "OpenSquilla necesita una autorización de administrador para crear una cuenta aislada y configurar la protección de archivos y red.", "descriptionWithDuration": "OpenSquilla necesita una autorización de administrador para crear una cuenta aislada y configurar las protecciones. La primera configuración suele tardar unos 20–30 segundos. Mantén OpenSquilla abierto.",
- "continue": "Continuar", "configuring": "Configurando…", "requestingApproval": "Confirma el aviso de Windows para continuar.", "configuringProtection": "OpenSquilla está completando la configuración del modo Seguro. Mantén la aplicación abierta.", "takingLonger": "La primera configuración puede tardar unos minutos. La verificación se ejecutará automáticamente.", "elapsed": "{seconds} s transcurridos", "cancelled": "Configuración cancelada. El acceso completo no cambia; puedes volver a intentarlo.",
- "failed": "No se pudo configurar el modo Seguro. El acceso completo no cambia.", "verificationFailed": "La configuración terminó, pero falló la comprobación de seguridad. El acceso completo no cambia.",
- "runInBackground": "Configurar en segundo plano", "readyToast": "El modo Seguro está listo.", "failedToast": "No se pudo completar la configuración del modo Seguro. Vuelve a intentarlo desde el modo Seguro."
+ "title": "Configurar el modo Seguro",
+ "description": "OpenSquilla necesita una autorización de administrador para crear una cuenta aislada y configurar la protección de archivos y red.",
+ "descriptionWithDuration": "OpenSquilla necesita una autorización de administrador para crear una cuenta aislada y configurar las protecciones. La primera configuración suele tardar unos 20–30 segundos. Mantén OpenSquilla abierto.",
+ "continue": "Continuar",
+ "configuring": "Configurando…",
+ "requestingApproval": "Confirma el aviso de Windows para continuar.",
+ "configuringProtection": "OpenSquilla está completando la configuración del modo Seguro. Mantén la aplicación abierta.",
+ "takingLonger": "La primera configuración puede tardar unos minutos. La verificación se ejecutará automáticamente.",
+ "elapsed": "{seconds} s transcurridos",
+ "cancelled": "Configuración cancelada. El acceso completo no cambia; puedes volver a intentarlo.",
+ "failed": "No se pudo configurar el modo Seguro. El acceso completo no cambia.",
+ "verificationFailed": "La configuración terminó, pero falló la comprobación de seguridad. El acceso completo no cambia.",
+ "runInBackground": "Configurar en segundo plano",
+ "readyToast": "El modo Seguro está listo.",
+ "failedToast": "No se pudo completar la configuración del modo Seguro. Vuelve a intentarlo desde el modo Seguro."
},
"files": {
- "title": "Seguridad de archivos", "description": "Los archivos normales son legibles. Los cambios en rutas protegidas requieren aprobación.", "readsAllowed": "Lectura permitida",
- "customPath": "Ruta protegida personalizada", "pathPlaceholder": "Añadir ruta protegida", "backupTitle": "Crear copia antes de cambios destructivos",
- "backupDescription": "Crea copias recuperables antes de eliminar o modificar archivos existentes y borra automáticamente las más antiguas cuando hace falta espacio.", "quota": "Límite de copias",
+ "title": "Seguridad de archivos",
+ "description": "Los archivos normales son legibles. Los cambios en rutas protegidas requieren aprobación.",
+ "readsAllowed": "Lectura permitida",
+ "customPath": "Ruta protegida personalizada",
+ "pathPlaceholder": "Añadir ruta protegida",
+ "backupTitle": "Crear copia antes de cambios destructivos",
+ "backupDescription": "Crea copias recuperables antes de eliminar o modificar archivos existentes y borra automáticamente las más antiguas cuando hace falta espacio.",
+ "quota": "Límite de copias",
"recursiveWarning": "Sin copia, eliminar o reemplazar puede ser irrecuperable. Si la copia sigue fallando tras borrar las antiguas, OpenSquilla vuelve a pedir confirmación."
},
"commands": {
- "title": "Seguridad de comandos", "description": "Los comandos se ejecutan automáticamente; operaciones como git push requieren aprobación.",
- "systemTools": "Herramientas del sistema", "systemToolsAuto": "Permitir automáticamente", "systemToolsPrompt": "Preguntar antes", "systemToolsDisabled": "Desactivado",
- "approvalPrefixes": "Prefijos que requieren aprobación", "autoPrefixes": "Prefijos siempre permitidos", "prefixPlaceholder": "Por ejemplo: git push"
+ "title": "Seguridad de comandos",
+ "description": "Los comandos se ejecutan automáticamente; operaciones como git push requieren aprobación.",
+ "systemTools": "Herramientas del sistema",
+ "systemToolsAuto": "Permitir automáticamente",
+ "systemToolsPrompt": "Preguntar antes",
+ "systemToolsDisabled": "Desactivado",
+ "approvalPrefixes": "Prefijos que requieren aprobación",
+ "autoPrefixes": "Prefijos siempre permitidos",
+ "prefixPlaceholder": "Por ejemplo: git push"
},
"network": {
- "title": "Seguridad de red", "description": "La red pública se permite por defecto y se mantienen las protecciones SSRF y de metadatos.",
- "blockAll": "Bloquear toda la red", "blockAllDescription": "Los dominios permitidos siguen siendo excepciones.", "allowDomains": "Dominios permitidos", "denyDomains": "Dominios rechazados"
+ "title": "Seguridad de red",
+ "description": "La red pública se permite por defecto y se mantienen las protecciones SSRF y de metadatos.",
+ "blockAll": "Bloquear toda la red",
+ "blockAllDescription": "Los dominios permitidos siguen siendo excepciones.",
+ "allowDomains": "Dominios permitidos",
+ "denyDomains": "Dominios rechazados"
},
"runtimes": {
- "title": "Paquetes de ejecución", "description": "Las descargas se activan automáticamente. Seguro prioriza los paquetes instalados; Acceso completo mantiene primero las herramientas del sistema.", "target": "Destino de ejecución",
- "allowRuntime": "Permitir {runtime}", "progress": "Descarga de {runtime}: {progress}%",
+ "title": "Paquetes de ejecución",
+ "description": "Las descargas se activan automáticamente. Seguro prioriza los paquetes instalados; Acceso completo mantiene primero las herramientas del sistema.",
+ "target": "Destino de ejecución",
+ "allowRuntime": "Permitir {runtime}",
+ "progress": "Descarga de {runtime}: {progress}%",
"states": {
- "unknown": "Estado no disponible", "notInstalled": "No instalado", "disabled": "No activado", "installed": "Instalado", "installedVersion": "Instalado · {version}", "updatePaused": "Actualización en pausa",
- "unsupported": "No disponible para este sistema", "corrupt": "Requiere reparación", "queued": "Esperando la descarga", "downloading": "Descargando", "downloadingProgress": "Descargando · {progress}%",
- "verifying": "Verificando la descarga", "extracting": "Instalando", "probing": "Comprobando el entorno", "activating": "Finalizando la instalación", "cancelling": "Cancelando", "queuedRemoval": "Esperando para eliminar", "removing": "Eliminando",
- "cancelled": "Descarga cancelada", "failed": "Error de descarga", "removeFailed": "Error al eliminar", "removeInterrupted": "Eliminación interrumpida", "interrupted": "Descarga pausada"
+ "unknown": "Estado no disponible",
+ "notInstalled": "No instalado",
+ "disabled": "No activado",
+ "installed": "Instalado",
+ "installedVersion": "Instalado · {version}",
+ "updatePaused": "Actualización en pausa",
+ "unsupported": "No disponible para este sistema",
+ "corrupt": "Requiere reparación",
+ "queued": "Esperando la descarga",
+ "downloading": "Descargando",
+ "downloadingProgress": "Descargando · {progress}%",
+ "verifying": "Verificando la descarga",
+ "extracting": "Instalando",
+ "probing": "Comprobando el entorno",
+ "activating": "Finalizando la instalación",
+ "cancelling": "Cancelando",
+ "queuedRemoval": "Esperando para eliminar",
+ "removing": "Eliminando",
+ "cancelled": "Descarga cancelada",
+ "failed": "Error de descarga",
+ "removeFailed": "Error al eliminar",
+ "removeInterrupted": "Eliminación interrumpida",
+ "interrupted": "Descarga pausada"
},
- "actions": { "enable": "Activar", "download": "Descargar", "cancel": "Cancelar", "discardDownload": "Descartar descarga", "resume": "Continuar", "retry": "Reintentar", "repair": "Reparar", "remove": "Eliminar", "retryRemove": "Reintentar eliminación" },
- "sources": { "oss": "OSS de Pekín", "github": "Versiones de GitHub" }
+ "actions": {
+ "enable": "Activar",
+ "download": "Descargar",
+ "cancel": "Cancelar",
+ "discardDownload": "Descartar descarga",
+ "resume": "Continuar",
+ "retry": "Reintentar",
+ "repair": "Reparar",
+ "remove": "Eliminar",
+ "retryRemove": "Reintentar eliminación"
+ },
+ "sources": {
+ "oss": "OSS de Pekín",
+ "github": "Versiones de GitHub"
+ }
},
"lan": {
- "listen": "Escuchar en la red local", "listenDescription": "Se enlaza a todas las interfaces locales tras reiniciar; los pares públicos siguen bloqueados.",
- "allowedCidrs": "CIDR de clientes permitidos", "cidrDescription": "Opcional. Vacío permite loopback, RFC1918 e IPv6 ULA; solo se puede restringir ese rango.",
+ "listen": "Escuchar en la red local",
+ "listenDescription": "Se enlaza a todas las interfaces locales tras reiniciar; los pares públicos siguen bloqueados.",
+ "allowedCidrs": "CIDR de clientes permitidos",
+ "cidrDescription": "Opcional. Vacío permite loopback, RFC1918 e IPv6 ULA; solo se puede restringir ese rango.",
"restartRequired": "Reinicia OpenSquilla para aplicar el cambio.",
- "title": "Acceso Web y tokens con nombre", "description": "El acceso Web remoto sin un token válido usa el modo seguro de invitado; los tokens con nombre conceden los permisos configurados.",
- "guest": "Sin token o token incorrecto:", "guestDescription": " modo seguro de invitado: los archivos normales se pueden leer, solo se puede escribir en el espacio de trabajo predeterminado y no se pueden leer credenciales.",
- "authenticated": "Token válido:", "authenticatedDescription": " puede usar Seguro o Acceso completo según sus capacidades.",
- "tokenName": "Nombre del token, por ejemplo Portátil", "hostExecute": "Permitir host / Acceso completo", "createToken": "Crear token",
- "copyNow": "Cópialo ahora. No volverá a mostrarse.", "fullCapable": "Seguro y Acceso completo", "safeOnly": "Solo Seguro", "revoke": "Revocar"
+ "title": "Acceso Web y tokens con nombre",
+ "description": "El acceso Web remoto sin un token válido usa el modo seguro de invitado; los tokens con nombre conceden los permisos configurados.",
+ "guest": "Sin token o token incorrecto:",
+ "guestDescription": " modo seguro de invitado: los archivos normales se pueden leer, solo se puede escribir en el espacio de trabajo predeterminado y no se pueden leer credenciales.",
+ "authenticated": "Token válido:",
+ "authenticatedDescription": " puede usar Seguro o Acceso completo según sus capacidades.",
+ "tokenName": "Nombre del token, por ejemplo Portátil",
+ "hostExecute": "Permitir host / Acceso completo",
+ "createToken": "Crear token",
+ "copyNow": "Cópialo ahora. No volverá a mostrarse.",
+ "fullCapable": "Seguro y Acceso completo",
+ "safeOnly": "Solo Seguro",
+ "revoke": "Revocar"
}
},
"dialog": {
@@ -2541,15 +2684,60 @@
"galleryEyebrow": "PLANTILLAS DE AUTOMATIZACIÓN",
"bulkDeleteConfirm": "¿Eliminar las {count} automatizaciones seleccionadas? Esta acción no se puede deshacer.",
"templates": {
- "ai-daily": { "title": "Resumen diario de noticias de IA", "description": "Resume las noticias importantes de IA de las últimas 24 horas con fuentes verificadas.", "category": "Noticias", "schedule": "Cada día a las 08:00" },
- "weekly-report": { "title": "Revisión semanal del trabajo", "description": "Resume avances, riesgos, entregas y prioridades de la próxima semana.", "category": "Productividad", "schedule": "Viernes a las 17:30" },
- "english-five": { "title": "Cinco palabras en inglés al día", "description": "Crea una lección breve de vocabulario con ejemplos y un cuestionario.", "category": "Aprendizaje", "schedule": "Cada día a las 09:00" },
- "project-risk": { "title": "Revisión de riesgos del proyecto", "description": "Revisa retrasos, errores y asuntos abiertos y propone acciones.", "category": "Proyectos", "schedule": "Laborables a las 10:00" },
- "knowledge-review": { "title": "Revisión semanal de conocimientos", "description": "Organiza notas y reuniones nuevas y prepara una lista de seguimiento.", "category": "Conocimiento", "schedule": "Domingo a las 18:00" },
- "daily-idea": { "title": "Idea y curiosidad del día", "description": "Comparte un dato fiable y una pequeña idea práctica.", "category": "Estilo de vida", "schedule": "Cada día a las 12:00" },
- "bedtime-story": { "title": "Cuento diario para dormir", "description": "Escribe un cuento corto, cálido e imaginativo para leer en familia.", "category": "Familia", "schedule": "Cada día a las 20:30" },
- "classic-movie": { "title": "Recomendación de cine clásico", "description": "Recomienda una película clásica reconocida con introducción sin spoilers.", "category": "Entretenimiento", "schedule": "Sábado a las 19:00" },
- "today-in-history": { "title": "Tal día como hoy", "description": "Presenta un acontecimiento histórico fiable de ciencia, cultura o sociedad.", "category": "Conocimiento diario", "schedule": "Cada día a las 08:30" }
+ "ai-daily": {
+ "title": "Resumen diario de noticias de IA",
+ "description": "Resume las noticias importantes de IA de las últimas 24 horas con fuentes verificadas.",
+ "category": "Noticias",
+ "schedule": "Cada día a las 08:00"
+ },
+ "weekly-report": {
+ "title": "Revisión semanal del trabajo",
+ "description": "Resume avances, riesgos, entregas y prioridades de la próxima semana.",
+ "category": "Productividad",
+ "schedule": "Viernes a las 17:30"
+ },
+ "english-five": {
+ "title": "Cinco palabras en inglés al día",
+ "description": "Crea una lección breve de vocabulario con ejemplos y un cuestionario.",
+ "category": "Aprendizaje",
+ "schedule": "Cada día a las 09:00"
+ },
+ "project-risk": {
+ "title": "Revisión de riesgos del proyecto",
+ "description": "Revisa retrasos, errores y asuntos abiertos y propone acciones.",
+ "category": "Proyectos",
+ "schedule": "Laborables a las 10:00"
+ },
+ "knowledge-review": {
+ "title": "Revisión semanal de conocimientos",
+ "description": "Organiza notas y reuniones nuevas y prepara una lista de seguimiento.",
+ "category": "Conocimiento",
+ "schedule": "Domingo a las 18:00"
+ },
+ "daily-idea": {
+ "title": "Idea y curiosidad del día",
+ "description": "Comparte un dato fiable y una pequeña idea práctica.",
+ "category": "Estilo de vida",
+ "schedule": "Cada día a las 12:00"
+ },
+ "bedtime-story": {
+ "title": "Cuento diario para dormir",
+ "description": "Escribe un cuento corto, cálido e imaginativo para leer en familia.",
+ "category": "Familia",
+ "schedule": "Cada día a las 20:30"
+ },
+ "classic-movie": {
+ "title": "Recomendación de cine clásico",
+ "description": "Recomienda una película clásica reconocida con introducción sin spoilers.",
+ "category": "Entretenimiento",
+ "schedule": "Sábado a las 19:00"
+ },
+ "today-in-history": {
+ "title": "Tal día como hoy",
+ "description": "Presenta un acontecimiento histórico fiable de ciencia, cultura o sociedad.",
+ "category": "Conocimiento diario",
+ "schedule": "Cada día a las 08:30"
+ }
}
},
"deleteDialog": {
@@ -2671,39 +2859,39 @@
"fdChannel": "Un canal",
"fdWebhook": "Un webhook",
"enabled": "Activada",
- "saveSchedule": "Guardar programación"
- ,"friendlyNamePlaceholder": "Ejemplo: Organizar los comentarios de clientes cada día"
- ,"friendlyTypeCron": "Repetir a una hora programada"
- ,"friendlyTypeEvery": "Repetir a intervalo fijo"
- ,"friendlyTypeAt": "Ejecutar una vez a una hora específica"
- ,"executionFrequency": "Frecuencia"
- ,"daily": "Cada día"
- ,"weekdays": "Días laborables"
- ,"weekly": "Cada semana"
- ,"monthly": "Cada mes"
- ,"customAdvancedTime": "Programación avanzada"
- ,"weekday": "Día de la semana"
- ,"date": "Fecha"
- ,"monthlyDay": "Día {day} de cada mes"
- ,"specificTime": "Hora"
- ,"customTimeHint": "Usa una regla horaria más flexible."
- ,"openAdvancedTime": "Abrir programación avanzada"
- ,"everyHowOften": "Repetir cada"
- ,"timeUnit": "Unidad de tiempo"
- ,"minutes": "Minutos"
- ,"hours": "Horas"
- ,"days": "Días"
- ,"dateAndTime": "Fecha y hora"
- ,"friendlyMessagePlaceholder": "Ejemplo: Resumir los comentarios de clientes de hoy y sugerir próximos pasos"
- ,"moreRuntimeSettings": "Más ajustes de ejecución (opcional)"
- ,"advancedTimeHint": "Úsalo solo si el selector de hora anterior no es suficiente."
- ,"timezoneSimpleHint": "Normalmente no es necesario cambiarlo."
- ,"projectWorkspace": "Espacio de trabajo del proyecto"
- ,"defaultWorkspace": "Usar el espacio de trabajo predeterminado"
- ,"noWorkspace": "Sin espacio de proyecto"
- ,"workspaceUnavailable": "{name} (no disponible)"
- ,"workspaceRequiredHint": "Elige el proyecto que debe revisar esta tarea. La tarea trabajará únicamente dentro de ese proyecto."
- ,"workspaceOptionalHint": "Opcional. Elige «Sin espacio de proyecto» para tareas generales o un proyecto cuando necesite sus archivos."
+ "saveSchedule": "Guardar programación",
+ "friendlyNamePlaceholder": "Ejemplo: Organizar los comentarios de clientes cada día",
+ "friendlyTypeCron": "Repetir a una hora programada",
+ "friendlyTypeEvery": "Repetir a intervalo fijo",
+ "friendlyTypeAt": "Ejecutar una vez a una hora específica",
+ "executionFrequency": "Frecuencia",
+ "daily": "Cada día",
+ "weekdays": "Días laborables",
+ "weekly": "Cada semana",
+ "monthly": "Cada mes",
+ "customAdvancedTime": "Programación avanzada",
+ "weekday": "Día de la semana",
+ "date": "Fecha",
+ "monthlyDay": "Día {day} de cada mes",
+ "specificTime": "Hora",
+ "customTimeHint": "Usa una regla horaria más flexible.",
+ "openAdvancedTime": "Abrir programación avanzada",
+ "everyHowOften": "Repetir cada",
+ "timeUnit": "Unidad de tiempo",
+ "minutes": "Minutos",
+ "hours": "Horas",
+ "days": "Días",
+ "dateAndTime": "Fecha y hora",
+ "friendlyMessagePlaceholder": "Ejemplo: Resumir los comentarios de clientes de hoy y sugerir próximos pasos",
+ "moreRuntimeSettings": "Más ajustes de ejecución (opcional)",
+ "advancedTimeHint": "Úsalo solo si el selector de hora anterior no es suficiente.",
+ "timezoneSimpleHint": "Normalmente no es necesario cambiarlo.",
+ "projectWorkspace": "Espacio de trabajo del proyecto",
+ "defaultWorkspace": "Usar el espacio de trabajo predeterminado",
+ "noWorkspace": "Sin espacio de proyecto",
+ "workspaceUnavailable": "{name} (no disponible)",
+ "workspaceRequiredHint": "Elige el proyecto que debe revisar esta tarea. La tarea trabajará únicamente dentro de ese proyecto.",
+ "workspaceOptionalHint": "Opcional. Elige «Sin espacio de proyecto» para tareas generales o un proyecto cuando necesite sus archivos."
},
"form": {
"cronPreviewPlaceholder": "Introduce una expresión cron de 5 campos para previsualizar",
@@ -3338,9 +3526,18 @@
"planTitle": "Plan de conservación",
"planSummary": "Aproximadamente cada {interval} min · hasta unas {count} solicitudes · pausa tras {duration} min.",
"costWarning": "Las solicitudes reales al proveedor pueden generar costes de tokens; no aparecen en el chat ni ejecutan herramientas.",
- "statusLabel": "Estado", "targetLabel": "Proveedor / modelo", "lastHitLabel": "Últimos tokens en caché",
+ "statusLabel": "Estado",
+ "targetLabel": "Proveedor / modelo",
+ "lastHitLabel": "Últimos tokens en caché",
"autoPauseLabel": "Pausa automática",
- "states": { "off": "Desactivado", "waiting": "Esperando un prefijo estable", "scheduled": "Programado", "probing": "Sondeando", "paused": "Límite de inactividad alcanzado; esperando el siguiente mensaje", "stopped": "Detenido tras fallo" }
+ "states": {
+ "off": "Desactivado",
+ "waiting": "Esperando un prefijo estable",
+ "scheduled": "Programado",
+ "probing": "Sondeando",
+ "paused": "Límite de inactividad alcanzado; esperando el siguiente mensaje",
+ "stopped": "Detenido tras fallo"
+ }
},
"send": "Enviar",
"sendQueues": "Enviar (se pone en cola hasta después de la respuesta actual)",
@@ -3351,18 +3548,57 @@
"stoppingResponse": "Deteniendo de forma segura… Los mensajes nuevos se pondrán en cola.",
"messageToSend": "Mensaje que enviar",
"promptAnnotations": {
- "label": "Anotaciones", "draftLabel": "Anotaciones de página pendientes", "sentLabel": "Anotaciones de página", "element": "Zona seleccionada",
+ "label": "Anotaciones",
+ "draftLabel": "Anotaciones de página pendientes",
+ "sentLabel": "Anotaciones de página",
+ "element": "Zona seleccionada",
"targetLabel": "{kind}: {text}",
- "targetKinds": { "heading": "Título", "button": "Botón", "link": "Enlace", "image": "Imagen", "input": "Campo", "form": "Formulario", "section": "Sección", "list": "Lista", "table": "Tabla", "text": "Texto", "region": "Zona", "element": "Zona seleccionada" },
- "emptyDraft": "Añadir una instrucción", "stale": "No se pudo localizar esta zona en la página actual",
+ "targetKinds": {
+ "heading": "Título",
+ "button": "Botón",
+ "link": "Enlace",
+ "image": "Imagen",
+ "input": "Campo",
+ "form": "Formulario",
+ "section": "Sección",
+ "list": "Lista",
+ "table": "Tabla",
+ "text": "Texto",
+ "region": "Zona",
+ "element": "Zona seleccionada"
+ },
+ "emptyDraft": "Añadir una instrucción",
+ "stale": "No se pudo localizar esta zona en la página actual",
"editingBlocked": "Añade o cancela la anotación que estás editando antes de enviar.",
"staleBlocked": "No se pudo localizar esta zona en la página actual. Aun así, puedes enviarla a la IA.",
- "emptyBlocked": "Completa todas las instrucciones del artefacto.", "tooLongBlocked": "Acorta las instrucciones a 16 KiB de texto UTF-8 antes de enviarlas.", "editLabel": "Editar instrucción del artefacto",
- "removeLabel": "Eliminar instrucción del artefacto", "updateFailed": "No se pudo guardar la instrucción del artefacto.",
- "discardFailed": "No se pudo eliminar la instrucción del artefacto.", "focusUnavailable": "No se pudo localizar esta zona en la página actual. Aun así, puedes enviarla a la IA.",
- "reuseLabel": "Copiar como anotación nueva", "reuseDescription": "Copia la solicitud de cambio y elige su objetivo en la página.", "reuseUnavailable": "No se pudo copiar esta solicitud de cambio. Abre la página correspondiente y elige su objetivo.", "applyPrompt": "Aplica las anotaciones de página adjuntas.",
- "status": { "unknown": "No se pudo actualizar la página", "not_attempted": "No se pudo actualizar la página", "applied": "Página actualizada", "appliedCorrected": "Página actualizada", "not_applied": "No se pudo actualizar la página", "conflict": "No se pudo actualizar la página", "ambiguous": "Comprobando la actualización" },
- "statusDetail": { "unknown": "Abre la página para comprobarlo.", "not_attempted": "La página no se actualizó.", "applied": "", "not_applied": "Inténtalo de nuevo.", "conflict": "Abre la página e inténtalo de nuevo.", "ambiguous": "Abre la página para comprobarlo antes de reintentarlo." }
+ "emptyBlocked": "Completa todas las instrucciones del artefacto.",
+ "tooLongBlocked": "Acorta las instrucciones a 16 KiB de texto UTF-8 antes de enviarlas.",
+ "editLabel": "Editar instrucción del artefacto",
+ "removeLabel": "Eliminar instrucción del artefacto",
+ "updateFailed": "No se pudo guardar la instrucción del artefacto.",
+ "discardFailed": "No se pudo eliminar la instrucción del artefacto.",
+ "focusUnavailable": "No se pudo localizar esta zona en la página actual. Aun así, puedes enviarla a la IA.",
+ "reuseLabel": "Copiar como anotación nueva",
+ "reuseDescription": "Copia la solicitud de cambio y elige su objetivo en la página.",
+ "reuseUnavailable": "No se pudo copiar esta solicitud de cambio. Abre la página correspondiente y elige su objetivo.",
+ "applyPrompt": "Aplica las anotaciones de página adjuntas.",
+ "status": {
+ "unknown": "No se pudo actualizar la página",
+ "not_attempted": "No se pudo actualizar la página",
+ "applied": "Página actualizada",
+ "appliedCorrected": "Página actualizada",
+ "not_applied": "No se pudo actualizar la página",
+ "conflict": "No se pudo actualizar la página",
+ "ambiguous": "Comprobando la actualización"
+ },
+ "statusDetail": {
+ "unknown": "Abre la página para comprobarlo.",
+ "not_attempted": "La página no se actualizó.",
+ "applied": "",
+ "not_applied": "Inténtalo de nuevo.",
+ "conflict": "Abre la página e inténtalo de nuevo.",
+ "ambiguous": "Abre la página para comprobarlo antes de reintentarlo."
+ }
},
"placeholder": "Envía un mensaje...",
"placeholderCompact": "Mensaje...",
@@ -3460,7 +3696,7 @@
"audioUnsupported": "Este navegador no puede reproducir este formato de audio. Descarga el archivo en su lugar.",
"playVideo": "Reproducir vídeo",
"videoLoadFailed": "No se pudo cargar el vídeo.",
- "videoUnsupported": "Este navegador no puede reproducir este formato de vídeo. Descarga el archivo en su lugar.",
+ "videoUnsupported": "Este navegador no puede reproducir este vídeo. Descárgalo en su lugar.",
"previewOf": "Vista previa: {title}",
"closePreview": "Cerrar vista previa",
"previousImage": "Imagen anterior",
@@ -3469,7 +3705,6 @@
"previewDownload": "Descargar vista previa",
"previewTimedOut": "Se agotó el tiempo de la vista previa.",
"previewFailed": "No se pudo cargar la vista previa.",
- "videoUnsupported": "Este navegador no puede reproducir este vídeo. Descárgalo en su lugar.",
"loadVideoPreview": "Cargar vista previa del vídeo",
"loadVideoPreviewFor": "Cargar vista previa del vídeo {title}",
"videoPreviewLoading": "Cargando la vista previa del vídeo. Puedes cancelar la descarga.",
@@ -4380,5 +4615,23 @@
"copyFailed": "No se pudo copiar el comando",
"copyLabel": "Copiar el comando de reinicio del gateway"
}
+ },
+ "fileTree": {
+ "backToTasks": "Volver a tareas",
+ "refresh": "Actualizar",
+ "retry": "Reintentar",
+ "empty": "Este espacio de trabajo no tiene archivos visibles.",
+ "loading": "Cargando archivos…",
+ "viewFiles": "Ver archivos",
+ "attachToChat": "Adjuntar al chat",
+ "copyPath": "Copiar ruta",
+ "previewTruncated": "vista previa limitada a 1 MB",
+ "binaryNotPreviewable": "Los archivos binarios no se pueden previsualizar.",
+ "attachTooLarge": "{name} supera 1 MB y no se puede adjuntar desde el árbol de archivos.",
+ "attachFailed": "No se pudo adjuntar {name}.",
+ "attachUnsupported": "{name} no es un archivo de texto adjuntable desde el árbol de archivos.",
+ "attached": "{name} adjunto al compositor.",
+ "ctxCopy": "Copiar",
+ "ctxAttach": "Añadir a la conversación"
}
}
diff --git a/opensquilla-webui/src/locales/fr.json b/opensquilla-webui/src/locales/fr.json
index aa3366557..622fa06f0 100644
--- a/opensquilla-webui/src/locales/fr.json
+++ b/opensquilla-webui/src/locales/fr.json
@@ -146,12 +146,36 @@
"empty": "Aucun aperçu n’est disponible pour cet élément.",
"itemLimitReached": "Huit aperçus isolés sont déjà ouverts. Fermez-en un avant d’en ouvrir un autre.",
"resources": {
- "title": "Fichiers", "count": "Fichiers ({count})", "empty": "Aucun fichier ni lien pour le moment.",
- "groups": { "files": "Fichiers", "links": "Liens", "attachments": "Fichiers", "documents": "Fichiers", "deliverables": "Fichiers", "urls": "Liens" },
- "open": "Ouvrir {name}", "preparing": "Préparation de l’éditeur…", "retry": "Réessayer", "preview": "Aperçu de {name}", "download": "Télécharger {name}", "edit": "Modifier une copie de {name}", "publish": "Publier {name}",
- "imported": "{name} peut maintenant être modifié.", "published": "{name} peut maintenant être partagé.",
- "publishUnavailable": "Ce document n’a pas de révision actuelle publiable.", "actionFailed": "L’action de l’espace de travail a échoué.",
- "unavailableReasons": { "htmlEncodingUnsupported": "Ce HTML n’est pas un UTF-8 valide et ne peut pas être prévisualisé ou modifié en toute sécurité.", "htmlValidationFailed": "Ce HTML n’a pas pu être validé pour une prévisualisation ou une modification sûre.", "htmlEditTooLarge": "Ce HTML est trop volumineux pour être modifié dans l’application. Vous pouvez toujours le télécharger.", "htmlPreviewTooLarge": "Ce HTML est trop volumineux pour être prévisualisé en toute sécurité. Vous pouvez toujours le télécharger.", "officeAdapterNotAvailable": "L’édition Office n’est pas encore disponible.", "unsupported": "Cette action n’est pas disponible pour cette ressource." }
+ "title": "Fichiers",
+ "count": "Fichiers ({count})",
+ "empty": "Aucun fichier ni lien pour le moment.",
+ "groups": {
+ "files": "Fichiers",
+ "links": "Liens",
+ "attachments": "Fichiers",
+ "documents": "Fichiers",
+ "deliverables": "Fichiers",
+ "urls": "Liens"
+ },
+ "open": "Ouvrir {name}",
+ "preparing": "Préparation de l’éditeur…",
+ "retry": "Réessayer",
+ "preview": "Aperçu de {name}",
+ "download": "Télécharger {name}",
+ "edit": "Modifier une copie de {name}",
+ "publish": "Publier {name}",
+ "imported": "{name} peut maintenant être modifié.",
+ "published": "{name} peut maintenant être partagé.",
+ "publishUnavailable": "Ce document n’a pas de révision actuelle publiable.",
+ "actionFailed": "L’action de l’espace de travail a échoué.",
+ "unavailableReasons": {
+ "htmlEncodingUnsupported": "Ce HTML n’est pas un UTF-8 valide et ne peut pas être prévisualisé ou modifié en toute sécurité.",
+ "htmlValidationFailed": "Ce HTML n’a pas pu être validé pour une prévisualisation ou une modification sûre.",
+ "htmlEditTooLarge": "Ce HTML est trop volumineux pour être modifié dans l’application. Vous pouvez toujours le télécharger.",
+ "htmlPreviewTooLarge": "Ce HTML est trop volumineux pour être prévisualisé en toute sécurité. Vous pouvez toujours le télécharger.",
+ "officeAdapterNotAvailable": "L’édition Office n’est pas encore disponible.",
+ "unsupported": "Cette action n’est pas disponible pour cette ressource."
+ }
},
"browser": {
"back": "Précédent",
@@ -166,11 +190,32 @@
"failedDetail": "Rechargez le navigateur isolé pour continuer."
},
"artifactAnnotation": {
- "start": "Annoter l’aperçu", "stop": "Arrêter l’annotation", "selectElement": "Sélectionnez un élément de page à annoter", "selectElementShort": "Choisir un élément", "unavailable": "L’annotation de l’aperçu est indisponible.", "desktopEditingOnly": "Sélectionnez et modifiez les éléments de la page avec l’IA dans l’application de bureau.",
- "createFailed": "L’élément sélectionné n’a pas pu être joint.", "elementChanged": "L’élément sélectionné a changé avant d’être joint. Sélectionnez-le à nouveau dans l’aperçu actuel.", "rearmFailed": "Le mode annotation s’est arrêté, car le sélecteur n’a pas pu redémarrer. Réactivez-le pour réessayer.", "updateFailed": "Le brouillon d’annotation n’a pas pu être enregistré.", "discardFailed": "L’annotation n’a pas pu être supprimée. Elle reste ouverte pour réessayer.", "closeFailed": "L’éditeur d’annotation n’a pas pu être fermé. Réessayez.", "replacementCleanupFailed": "La nouvelle annotation a été conservée, mais l’ancien brouillon n’a pas pu être supprimé.",
+ "start": "Annoter l’aperçu",
+ "stop": "Arrêter l’annotation",
+ "selectElement": "Sélectionnez un élément de page à annoter",
+ "selectElementShort": "Choisir un élément",
+ "unavailable": "L’annotation de l’aperçu est indisponible.",
+ "desktopEditingOnly": "Sélectionnez et modifiez les éléments de la page avec l’IA dans l’application de bureau.",
+ "createFailed": "L’élément sélectionné n’a pas pu être joint.",
+ "elementChanged": "L’élément sélectionné a changé avant d’être joint. Sélectionnez-le à nouveau dans l’aperçu actuel.",
+ "rearmFailed": "Le mode annotation s’est arrêté, car le sélecteur n’a pas pu redémarrer. Réactivez-le pour réessayer.",
+ "updateFailed": "Le brouillon d’annotation n’a pas pu être enregistré.",
+ "discardFailed": "L’annotation n’a pas pu être supprimée. Elle reste ouverte pour réessayer.",
+ "closeFailed": "L’éditeur d’annotation n’a pas pu être fermé. Réessayez.",
+ "replacementCleanupFailed": "La nouvelle annotation a été conservée, mais l’ancien brouillon n’a pas pu être supprimé.",
"overlayFallback": "Continuez l’annotation ci-dessous.",
- "fallbackTitle": "Continuer l’annotation", "fallbackDetail": "L’aperçu est temporairement masqué. Votre annotation est toujours là.", "frozenPreview": "Aperçu de la zone de page sélectionnée",
- "placeholder": "Décrivez la modification de cette zone…", "newlineHint": "{shortcut} pour aller à la ligne", "keepDraft": "Conserver l’annotation", "submit": "Ajouter l’annotation", "contextLabel": "Sélection actuelle", "bodyLabel": "Annotation de page", "emptyBody": "Décrivez la modification souhaitée.", "reselectHint": "Sélectionnez la zone correspondante sur la page.", "reuseHint": "La demande de modification a été copiée. Sélectionnez la zone correspondante sur la page."
+ "fallbackTitle": "Continuer l’annotation",
+ "fallbackDetail": "L’aperçu est temporairement masqué. Votre annotation est toujours là.",
+ "frozenPreview": "Aperçu de la zone de page sélectionnée",
+ "placeholder": "Décrivez la modification de cette zone…",
+ "newlineHint": "{shortcut} pour aller à la ligne",
+ "keepDraft": "Conserver l’annotation",
+ "submit": "Ajouter l’annotation",
+ "contextLabel": "Sélection actuelle",
+ "bodyLabel": "Annotation de page",
+ "emptyBody": "Décrivez la modification souhaitée.",
+ "reselectHint": "Sélectionnez la zone correspondante sur la page.",
+ "reuseHint": "La demande de modification a été copiée. Sélectionnez la zone correspondante sur la page."
},
"artifactPreview": {
"refresh": "Actualiser l’aperçu",
@@ -292,8 +337,15 @@
"unsavedSourceCopied": "Source non enregistrée copiée",
"copyUnsavedSourceFailed": "Impossible de copier la source non enregistrée.",
"discardAndLoadLatest": "Ignorer et charger la dernière version",
- "sourceStatus": {"ready":"Prêt","saving":"Enregistrement…","dirty":"Non enregistré","saved":"Enregistré","error":"Action requise"}
- }
+ "sourceStatus": {
+ "ready": "Prêt",
+ "saving": "Enregistrement…",
+ "dirty": "Non enregistré",
+ "saved": "Enregistré",
+ "error": "Action requise"
+ }
+ },
+ "tabOverflow": "Tous les onglets"
},
"nav": {
"chat": "Tâche",
@@ -489,9 +541,18 @@
"retryFailedTitle": "L’aperçu n’a pas pu être régénéré",
"retryFailedDescription": "Cette demande de régénération n’a pas abouti. Le résultat précédent reste non adopté et aucun fichier n’a été modifié. Réessayez ou supprimez l’import.",
"jobStates": {
- "cancelled": {"title": "Génération de l’aperçu annulée", "description": "La source isolée est conservée pendant 24 heures. La régénération peut effectuer jusqu’à deux appels au modèle, car un résultat qui ne passe pas la validation peut être réessayé une fois ; le résultat peut différer."},
- "interrupted": {"title": "Génération de l’aperçu interrompue", "description": "OpenSquilla a redémarré pendant le traitement du modèle. La source isolée reste disponible. La régénération peut effectuer jusqu’à deux appels au modèle en raison d’un nouvel essai interne."},
- "failed": {"title": "Échec de la génération de l’aperçu", "description": "Cette tentative n’a produit aucun aperçu validé et vérifiable. Aucune décision d’import n’a été adoptée et aucun fichier n’a été modifié. La source isolée a été conservée ; la régénération peut effectuer jusqu’à deux appels au modèle."}
+ "cancelled": {
+ "title": "Génération de l’aperçu annulée",
+ "description": "La source isolée est conservée pendant 24 heures. La régénération peut effectuer jusqu’à deux appels au modèle, car un résultat qui ne passe pas la validation peut être réessayé une fois ; le résultat peut différer."
+ },
+ "interrupted": {
+ "title": "Génération de l’aperçu interrompue",
+ "description": "OpenSquilla a redémarré pendant le traitement du modèle. La source isolée reste disponible. La régénération peut effectuer jusqu’à deux appels au modèle en raison d’un nouvel essai interne."
+ },
+ "failed": {
+ "title": "Échec de la génération de l’aperçu",
+ "description": "Cette tentative n’a produit aucun aperçu validé et vérifiable. Aucune décision d’import n’a été adoptée et aucun fichier n’a été modifié. La source isolée a été conservée ; la régénération peut effectuer jusqu’à deux appels au modèle."
+ }
},
"modelAnalysisTitle": "Analyse du modèle (à vérifier avec les modifications de fichiers)",
"previewTitle": "Vérifier chaque modification",
@@ -557,53 +618,135 @@
"available": "Mode sûr disponible",
"unavailable": "Mode sûr indisponible",
"builtin": "Intégré",
- "actions": { "add": "Ajouter", "remove": "Retirer", "retry": "Réessayer", "copy": "Copier", "saving": "Enregistrement…", "redetect": "Détecter à nouveau" },
+ "actions": {
+ "add": "Ajouter",
+ "remove": "Retirer",
+ "retry": "Réessayer",
+ "copy": "Copier",
+ "saving": "Enregistrement…",
+ "redetect": "Détecter à nouveau"
+ },
"mode": {
- "title": "Mode d’accès par défaut", "description": "Choisissez le mode initial des nouvelles tâches. Le mode sûr reste indisponible si la vérification échoue.",
- "default": "Mode par défaut", "safe": "Sûr", "full": "Accès complet", "resetWarning": "Réafficher l’avertissement au démarrage"
+ "title": "Mode d’accès par défaut",
+ "description": "Choisissez le mode initial des nouvelles tâches. Le mode sûr reste indisponible si la vérification échoue.",
+ "default": "Mode par défaut",
+ "safe": "Sûr",
+ "full": "Accès complet",
+ "resetWarning": "Réafficher l’avertissement au démarrage"
},
"setup": {
- "title": "Configurer le mode sûr", "description": "OpenSquilla a besoin d’une autorisation administrateur pour créer un compte isolé et configurer la protection des fichiers et du réseau.", "descriptionWithDuration": "OpenSquilla a besoin d’une autorisation administrateur pour créer un compte isolé et configurer les protections. La première configuration prend généralement environ 20 à 30 secondes. Gardez OpenSquilla ouvert.",
- "continue": "Continuer", "configuring": "Configuration…", "requestingApproval": "Confirmez l’invite Windows pour continuer.", "configuringProtection": "OpenSquilla termine la configuration du mode sûr. Gardez l’application ouverte.", "takingLonger": "La première configuration peut prendre quelques minutes. La vérification se lancera automatiquement.", "elapsed": "{seconds} s écoulées", "cancelled": "Configuration annulée. L’accès complet reste inchangé ; vous pouvez réessayer.",
- "failed": "Le mode sûr n’a pas pu être configuré. L’accès complet reste inchangé.", "verificationFailed": "La configuration est terminée, mais la vérification de sécurité a échoué. L’accès complet reste inchangé.",
- "runInBackground": "Configurer en arrière-plan", "readyToast": "Le mode sûr est prêt.", "failedToast": "La configuration du mode sûr n’a pas pu se terminer. Réessayez depuis le mode sûr."
+ "title": "Configurer le mode sûr",
+ "description": "OpenSquilla a besoin d’une autorisation administrateur pour créer un compte isolé et configurer la protection des fichiers et du réseau.",
+ "descriptionWithDuration": "OpenSquilla a besoin d’une autorisation administrateur pour créer un compte isolé et configurer les protections. La première configuration prend généralement environ 20 à 30 secondes. Gardez OpenSquilla ouvert.",
+ "continue": "Continuer",
+ "configuring": "Configuration…",
+ "requestingApproval": "Confirmez l’invite Windows pour continuer.",
+ "configuringProtection": "OpenSquilla termine la configuration du mode sûr. Gardez l’application ouverte.",
+ "takingLonger": "La première configuration peut prendre quelques minutes. La vérification se lancera automatiquement.",
+ "elapsed": "{seconds} s écoulées",
+ "cancelled": "Configuration annulée. L’accès complet reste inchangé ; vous pouvez réessayer.",
+ "failed": "Le mode sûr n’a pas pu être configuré. L’accès complet reste inchangé.",
+ "verificationFailed": "La configuration est terminée, mais la vérification de sécurité a échoué. L’accès complet reste inchangé.",
+ "runInBackground": "Configurer en arrière-plan",
+ "readyToast": "Le mode sûr est prêt.",
+ "failedToast": "La configuration du mode sûr n’a pas pu se terminer. Réessayez depuis le mode sûr."
},
"files": {
- "title": "Sécurité des fichiers", "description": "Les fichiers ordinaires sont lisibles. Les mutations des chemins protégés exigent une approbation.", "readsAllowed": "Lecture autorisée",
- "customPath": "Chemin protégé personnalisé", "pathPlaceholder": "Ajouter un chemin protégé", "backupTitle": "Sauvegarder avant les modifications destructrices",
- "backupDescription": "Crée des copies récupérables avant de supprimer ou modifier des fichiers existants et efface automatiquement les plus anciennes si nécessaire.", "quota": "Limite des sauvegardes",
+ "title": "Sécurité des fichiers",
+ "description": "Les fichiers ordinaires sont lisibles. Les mutations des chemins protégés exigent une approbation.",
+ "readsAllowed": "Lecture autorisée",
+ "customPath": "Chemin protégé personnalisé",
+ "pathPlaceholder": "Ajouter un chemin protégé",
+ "backupTitle": "Sauvegarder avant les modifications destructrices",
+ "backupDescription": "Crée des copies récupérables avant de supprimer ou modifier des fichiers existants et efface automatiquement les plus anciennes si nécessaire.",
+ "quota": "Limite des sauvegardes",
"recursiveWarning": "Sans sauvegarde, une suppression ou un remplacement peut être irrécupérable. Si la sauvegarde échoue encore après le nettoyage, OpenSquilla demande une nouvelle confirmation."
},
"commands": {
- "title": "Sécurité des commandes", "description": "Les commandes s'exécutent automatiquement ; les opérations comme git push exigent une approbation.",
- "systemTools": "Outils système", "systemToolsAuto": "Autoriser automatiquement", "systemToolsPrompt": "Demander avant", "systemToolsDisabled": "Désactivé",
- "approvalPrefixes": "Préfixes exigeant une approbation", "autoPrefixes": "Préfixes toujours autorisés", "prefixPlaceholder": "Par exemple : git push"
+ "title": "Sécurité des commandes",
+ "description": "Les commandes s'exécutent automatiquement ; les opérations comme git push exigent une approbation.",
+ "systemTools": "Outils système",
+ "systemToolsAuto": "Autoriser automatiquement",
+ "systemToolsPrompt": "Demander avant",
+ "systemToolsDisabled": "Désactivé",
+ "approvalPrefixes": "Préfixes exigeant une approbation",
+ "autoPrefixes": "Préfixes toujours autorisés",
+ "prefixPlaceholder": "Par exemple : git push"
},
"network": {
- "title": "Sécurité réseau", "description": "Le réseau public est autorisé par défaut, avec les protections SSRF et métadonnées.",
- "blockAll": "Bloquer tout le réseau", "blockAllDescription": "Les domaines autorisés restent des exceptions.", "allowDomains": "Domaines autorisés", "denyDomains": "Domaines refusés"
+ "title": "Sécurité réseau",
+ "description": "Le réseau public est autorisé par défaut, avec les protections SSRF et métadonnées.",
+ "blockAll": "Bloquer tout le réseau",
+ "blockAllDescription": "Les domaines autorisés restent des exceptions.",
+ "allowDomains": "Domaines autorisés",
+ "denyDomains": "Domaines refusés"
},
"runtimes": {
- "title": "Paquets d'exécution", "description": "Les téléchargements sont activés automatiquement. Sûr les privilégie ; Accès complet conserve la priorité aux outils de l'hôte.", "target": "Cible d'exécution",
- "allowRuntime": "Autoriser {runtime}", "progress": "Téléchargement de {runtime} : {progress}%",
+ "title": "Paquets d'exécution",
+ "description": "Les téléchargements sont activés automatiquement. Sûr les privilégie ; Accès complet conserve la priorité aux outils de l'hôte.",
+ "target": "Cible d'exécution",
+ "allowRuntime": "Autoriser {runtime}",
+ "progress": "Téléchargement de {runtime} : {progress}%",
"states": {
- "unknown": "État indisponible", "notInstalled": "Non installé", "disabled": "Non activé", "installed": "Installé", "installedVersion": "Installé · {version}", "updatePaused": "Mise à jour en pause",
- "unsupported": "Indisponible pour ce système", "corrupt": "Réparation requise", "queued": "En attente de téléchargement", "downloading": "Téléchargement", "downloadingProgress": "Téléchargement · {progress}%",
- "verifying": "Vérification du téléchargement", "extracting": "Installation", "probing": "Vérification de l'environnement", "activating": "Finalisation de l'installation", "cancelling": "Annulation", "queuedRemoval": "En attente de suppression", "removing": "Suppression",
- "cancelled": "Téléchargement annulé", "failed": "Échec du téléchargement", "removeFailed": "Échec de la suppression", "removeInterrupted": "Suppression interrompue", "interrupted": "Téléchargement suspendu"
+ "unknown": "État indisponible",
+ "notInstalled": "Non installé",
+ "disabled": "Non activé",
+ "installed": "Installé",
+ "installedVersion": "Installé · {version}",
+ "updatePaused": "Mise à jour en pause",
+ "unsupported": "Indisponible pour ce système",
+ "corrupt": "Réparation requise",
+ "queued": "En attente de téléchargement",
+ "downloading": "Téléchargement",
+ "downloadingProgress": "Téléchargement · {progress}%",
+ "verifying": "Vérification du téléchargement",
+ "extracting": "Installation",
+ "probing": "Vérification de l'environnement",
+ "activating": "Finalisation de l'installation",
+ "cancelling": "Annulation",
+ "queuedRemoval": "En attente de suppression",
+ "removing": "Suppression",
+ "cancelled": "Téléchargement annulé",
+ "failed": "Échec du téléchargement",
+ "removeFailed": "Échec de la suppression",
+ "removeInterrupted": "Suppression interrompue",
+ "interrupted": "Téléchargement suspendu"
},
- "actions": { "enable": "Activer", "download": "Télécharger", "cancel": "Annuler", "discardDownload": "Supprimer le téléchargement", "resume": "Reprendre", "retry": "Réessayer", "repair": "Réparer", "remove": "Supprimer", "retryRemove": "Réessayer la suppression" },
- "sources": { "oss": "OSS de Pékin", "github": "Versions GitHub" }
+ "actions": {
+ "enable": "Activer",
+ "download": "Télécharger",
+ "cancel": "Annuler",
+ "discardDownload": "Supprimer le téléchargement",
+ "resume": "Reprendre",
+ "retry": "Réessayer",
+ "repair": "Réparer",
+ "remove": "Supprimer",
+ "retryRemove": "Réessayer la suppression"
+ },
+ "sources": {
+ "oss": "OSS de Pékin",
+ "github": "Versions GitHub"
+ }
},
"lan": {
- "listen": "Écouter sur le réseau local", "listenDescription": "Lie toutes les interfaces locales après redémarrage ; les pairs publics restent refusés.",
- "allowedCidrs": "CIDR clients autorisés", "cidrDescription": "Facultatif. Vide autorise loopback, RFC1918 et IPv6 ULA ; les entrées ne peuvent que réduire cette plage.",
+ "listen": "Écouter sur le réseau local",
+ "listenDescription": "Lie toutes les interfaces locales après redémarrage ; les pairs publics restent refusés.",
+ "allowedCidrs": "CIDR clients autorisés",
+ "cidrDescription": "Facultatif. Vide autorise loopback, RFC1918 et IPv6 ULA ; les entrées ne peuvent que réduire cette plage.",
"restartRequired": "Redémarrez OpenSquilla pour appliquer la modification.",
- "title": "Accès Web et jetons nommés", "description": "L'accès Web distant sans jeton valide utilise le mode sûr invité ; les jetons nommés accordent les droits configurés.",
- "guest": "Jeton absent ou incorrect :", "guestDescription": " mode sûr invité : les fichiers ordinaires sont lisibles, seul l'espace de travail par défaut est modifiable et les identifiants sont illisibles.",
- "authenticated": "Jeton valide :", "authenticatedDescription": " peut utiliser Sûr ou Accès complet selon ses capacités.",
- "tokenName": "Nom du jeton, par ex. Portable", "hostExecute": "Autoriser l'hôte / Accès complet", "createToken": "Créer le jeton",
- "copyNow": "Copiez-le maintenant. Il ne sera plus affiché.", "fullCapable": "Sûr et Accès complet", "safeOnly": "Sûr uniquement", "revoke": "Révoquer"
+ "title": "Accès Web et jetons nommés",
+ "description": "L'accès Web distant sans jeton valide utilise le mode sûr invité ; les jetons nommés accordent les droits configurés.",
+ "guest": "Jeton absent ou incorrect :",
+ "guestDescription": " mode sûr invité : les fichiers ordinaires sont lisibles, seul l'espace de travail par défaut est modifiable et les identifiants sont illisibles.",
+ "authenticated": "Jeton valide :",
+ "authenticatedDescription": " peut utiliser Sûr ou Accès complet selon ses capacités.",
+ "tokenName": "Nom du jeton, par ex. Portable",
+ "hostExecute": "Autoriser l'hôte / Accès complet",
+ "createToken": "Créer le jeton",
+ "copyNow": "Copiez-le maintenant. Il ne sera plus affiché.",
+ "fullCapable": "Sûr et Accès complet",
+ "safeOnly": "Sûr uniquement",
+ "revoke": "Révoquer"
}
},
"dialog": {
@@ -2541,15 +2684,60 @@
"galleryEyebrow": "MODÈLES D’AUTOMATISATION",
"bulkDeleteConfirm": "Supprimer les {count} automatisations sélectionnées ? Cette action est irréversible.",
"templates": {
- "ai-daily": { "title": "Brief quotidien sur l’IA", "description": "Résume les actualités IA importantes des dernières 24 heures avec des sources vérifiées.", "category": "Actualités", "schedule": "Chaque jour à 08:00" },
- "weekly-report": { "title": "Bilan de travail hebdomadaire", "description": "Résume les progrès, risques, livrables et priorités de la semaine suivante.", "category": "Productivité", "schedule": "Vendredi à 17:30" },
- "english-five": { "title": "Cinq mots anglais par jour", "description": "Crée une leçon de vocabulaire légère avec exemples et petit quiz.", "category": "Apprentissage", "schedule": "Chaque jour à 09:00" },
- "project-risk": { "title": "Contrôle des risques projet", "description": "Examine les retards, erreurs et problèmes ouverts, puis propose des actions.", "category": "Projets", "schedule": "En semaine à 10:00" },
- "knowledge-review": { "title": "Revue hebdomadaire des connaissances", "description": "Organise les nouvelles notes et réunions et prépare une liste de suivi.", "category": "Connaissances", "schedule": "Dimanche à 18:00" },
- "daily-idea": { "title": "Idée et anecdote du jour", "description": "Partage un fait fiable et une petite idée concrète.", "category": "Mode de vie", "schedule": "Chaque jour à 12:00" },
- "bedtime-story": { "title": "Histoire du soir quotidienne", "description": "Écrit une histoire courte, chaleureuse et imaginative à lire en famille.", "category": "Famille", "schedule": "Chaque jour à 20:30" },
- "classic-movie": { "title": "Recommandation de film classique", "description": "Recommande un grand classique avec une introduction sans divulgâcher.", "category": "Divertissement", "schedule": "Samedi à 19:00" },
- "today-in-history": { "title": "Aujourd’hui dans l’histoire", "description": "Présente un événement historique fiable lié aux sciences, à la culture ou à la société.", "category": "Culture quotidienne", "schedule": "Chaque jour à 08:30" }
+ "ai-daily": {
+ "title": "Brief quotidien sur l’IA",
+ "description": "Résume les actualités IA importantes des dernières 24 heures avec des sources vérifiées.",
+ "category": "Actualités",
+ "schedule": "Chaque jour à 08:00"
+ },
+ "weekly-report": {
+ "title": "Bilan de travail hebdomadaire",
+ "description": "Résume les progrès, risques, livrables et priorités de la semaine suivante.",
+ "category": "Productivité",
+ "schedule": "Vendredi à 17:30"
+ },
+ "english-five": {
+ "title": "Cinq mots anglais par jour",
+ "description": "Crée une leçon de vocabulaire légère avec exemples et petit quiz.",
+ "category": "Apprentissage",
+ "schedule": "Chaque jour à 09:00"
+ },
+ "project-risk": {
+ "title": "Contrôle des risques projet",
+ "description": "Examine les retards, erreurs et problèmes ouverts, puis propose des actions.",
+ "category": "Projets",
+ "schedule": "En semaine à 10:00"
+ },
+ "knowledge-review": {
+ "title": "Revue hebdomadaire des connaissances",
+ "description": "Organise les nouvelles notes et réunions et prépare une liste de suivi.",
+ "category": "Connaissances",
+ "schedule": "Dimanche à 18:00"
+ },
+ "daily-idea": {
+ "title": "Idée et anecdote du jour",
+ "description": "Partage un fait fiable et une petite idée concrète.",
+ "category": "Mode de vie",
+ "schedule": "Chaque jour à 12:00"
+ },
+ "bedtime-story": {
+ "title": "Histoire du soir quotidienne",
+ "description": "Écrit une histoire courte, chaleureuse et imaginative à lire en famille.",
+ "category": "Famille",
+ "schedule": "Chaque jour à 20:30"
+ },
+ "classic-movie": {
+ "title": "Recommandation de film classique",
+ "description": "Recommande un grand classique avec une introduction sans divulgâcher.",
+ "category": "Divertissement",
+ "schedule": "Samedi à 19:00"
+ },
+ "today-in-history": {
+ "title": "Aujourd’hui dans l’histoire",
+ "description": "Présente un événement historique fiable lié aux sciences, à la culture ou à la société.",
+ "category": "Culture quotidienne",
+ "schedule": "Chaque jour à 08:30"
+ }
}
},
"deleteDialog": {
@@ -2671,39 +2859,39 @@
"fdChannel": "Un canal",
"fdWebhook": "Un webhook",
"enabled": "Activé",
- "saveSchedule": "Enregistrer la planification"
- ,"friendlyNamePlaceholder": "Exemple : Organiser les retours clients chaque jour"
- ,"friendlyTypeCron": "Répéter à une heure définie"
- ,"friendlyTypeEvery": "Répéter à intervalle fixe"
- ,"friendlyTypeAt": "Exécuter une seule fois"
- ,"executionFrequency": "Fréquence"
- ,"daily": "Chaque jour"
- ,"weekdays": "Jours ouvrés"
- ,"weekly": "Chaque semaine"
- ,"monthly": "Chaque mois"
- ,"customAdvancedTime": "Planification avancée"
- ,"weekday": "Jour de la semaine"
- ,"date": "Date"
- ,"monthlyDay": "Le {day} de chaque mois"
- ,"specificTime": "Heure"
- ,"customTimeHint": "Utilisez une règle horaire plus flexible."
- ,"openAdvancedTime": "Ouvrir la planification avancée"
- ,"everyHowOften": "Répéter toutes les"
- ,"timeUnit": "Unité de temps"
- ,"minutes": "Minutes"
- ,"hours": "Heures"
- ,"days": "Jours"
- ,"dateAndTime": "Date et heure"
- ,"friendlyMessagePlaceholder": "Exemple : Résumer les retours clients du jour et proposer les prochaines étapes"
- ,"moreRuntimeSettings": "Autres paramètres d’exécution (facultatif)"
- ,"advancedTimeHint": "À utiliser uniquement si le sélecteur ci-dessus ne suffit pas."
- ,"timezoneSimpleHint": "Il n’est généralement pas nécessaire de modifier ce réglage."
- ,"projectWorkspace": "Espace de travail du projet"
- ,"defaultWorkspace": "Utiliser l’espace de travail par défaut"
- ,"noWorkspace": "Aucun espace de projet"
- ,"workspaceUnavailable": "{name} (indisponible)"
- ,"workspaceRequiredHint": "Choisissez le projet à examiner. La tâche travaillera uniquement dans ce projet."
- ,"workspaceOptionalHint": "Facultatif. Choisissez « Aucun espace de projet » pour une tâche générale, ou un projet si ses fichiers sont nécessaires."
+ "saveSchedule": "Enregistrer la planification",
+ "friendlyNamePlaceholder": "Exemple : Organiser les retours clients chaque jour",
+ "friendlyTypeCron": "Répéter à une heure définie",
+ "friendlyTypeEvery": "Répéter à intervalle fixe",
+ "friendlyTypeAt": "Exécuter une seule fois",
+ "executionFrequency": "Fréquence",
+ "daily": "Chaque jour",
+ "weekdays": "Jours ouvrés",
+ "weekly": "Chaque semaine",
+ "monthly": "Chaque mois",
+ "customAdvancedTime": "Planification avancée",
+ "weekday": "Jour de la semaine",
+ "date": "Date",
+ "monthlyDay": "Le {day} de chaque mois",
+ "specificTime": "Heure",
+ "customTimeHint": "Utilisez une règle horaire plus flexible.",
+ "openAdvancedTime": "Ouvrir la planification avancée",
+ "everyHowOften": "Répéter toutes les",
+ "timeUnit": "Unité de temps",
+ "minutes": "Minutes",
+ "hours": "Heures",
+ "days": "Jours",
+ "dateAndTime": "Date et heure",
+ "friendlyMessagePlaceholder": "Exemple : Résumer les retours clients du jour et proposer les prochaines étapes",
+ "moreRuntimeSettings": "Autres paramètres d’exécution (facultatif)",
+ "advancedTimeHint": "À utiliser uniquement si le sélecteur ci-dessus ne suffit pas.",
+ "timezoneSimpleHint": "Il n’est généralement pas nécessaire de modifier ce réglage.",
+ "projectWorkspace": "Espace de travail du projet",
+ "defaultWorkspace": "Utiliser l’espace de travail par défaut",
+ "noWorkspace": "Aucun espace de projet",
+ "workspaceUnavailable": "{name} (indisponible)",
+ "workspaceRequiredHint": "Choisissez le projet à examiner. La tâche travaillera uniquement dans ce projet.",
+ "workspaceOptionalHint": "Facultatif. Choisissez « Aucun espace de projet » pour une tâche générale, ou un projet si ses fichiers sont nécessaires."
},
"form": {
"cronPreviewPlaceholder": "Saisissez une expression cron à 5 champs pour la prévisualiser",
@@ -3338,9 +3526,18 @@
"planTitle": "Plan de maintien",
"planSummary": "Environ toutes les {interval} min · jusqu’à {count} requêtes · pause après {duration} min.",
"costWarning": "Les requêtes réelles au fournisseur peuvent coûter des tokens ; elles n’apparaissent pas dans la discussion et n’exécutent aucun outil.",
- "statusLabel": "État", "targetLabel": "Fournisseur / modèle", "lastHitLabel": "Derniers tokens en cache",
+ "statusLabel": "État",
+ "targetLabel": "Fournisseur / modèle",
+ "lastHitLabel": "Derniers tokens en cache",
"autoPauseLabel": "Pause automatique",
- "states": { "off": "Désactivé", "waiting": "En attente d'un préfixe stable", "scheduled": "Planifié", "probing": "Sonde en cours", "paused": "Limite d'inactivité atteinte ; attente du prochain message", "stopped": "Arrêté après échec" }
+ "states": {
+ "off": "Désactivé",
+ "waiting": "En attente d'un préfixe stable",
+ "scheduled": "Planifié",
+ "probing": "Sonde en cours",
+ "paused": "Limite d'inactivité atteinte ; attente du prochain message",
+ "stopped": "Arrêté après échec"
+ }
},
"send": "Envoyer",
"sendQueues": "Envoyer (mise en file après la réponse actuelle)",
@@ -3351,18 +3548,57 @@
"stoppingResponse": "Arrêt sécurisé en cours… Les nouveaux messages seront mis en file.",
"messageToSend": "Message à envoyer",
"promptAnnotations": {
- "label": "Annotations", "draftLabel": "Annotations de page en attente", "sentLabel": "Annotations de page", "element": "Zone sélectionnée",
+ "label": "Annotations",
+ "draftLabel": "Annotations de page en attente",
+ "sentLabel": "Annotations de page",
+ "element": "Zone sélectionnée",
"targetLabel": "{kind} : {text}",
- "targetKinds": { "heading": "Titre", "button": "Bouton", "link": "Lien", "image": "Image", "input": "Champ", "form": "Formulaire", "section": "Section", "list": "Liste", "table": "Tableau", "text": "Texte", "region": "Zone", "element": "Zone sélectionnée" },
- "emptyDraft": "Ajouter une instruction", "stale": "Cette zone est introuvable sur la page actuelle",
+ "targetKinds": {
+ "heading": "Titre",
+ "button": "Bouton",
+ "link": "Lien",
+ "image": "Image",
+ "input": "Champ",
+ "form": "Formulaire",
+ "section": "Section",
+ "list": "Liste",
+ "table": "Tableau",
+ "text": "Texte",
+ "region": "Zone",
+ "element": "Zone sélectionnée"
+ },
+ "emptyDraft": "Ajouter une instruction",
+ "stale": "Cette zone est introuvable sur la page actuelle",
"editingBlocked": "Ajoutez ou annulez l’annotation en cours de modification avant d’envoyer.",
"staleBlocked": "Cette zone est introuvable sur la page actuelle. Vous pouvez tout de même l’envoyer à l’IA.",
- "emptyBlocked": "Terminez chaque instruction d’artefact.", "tooLongBlocked": "Réduisez les instructions à 16 Kio de texte UTF-8 avant l’envoi.", "editLabel": "Modifier l’instruction d’artefact",
- "removeLabel": "Supprimer l’instruction d’artefact", "updateFailed": "L’instruction d’artefact n’a pas pu être enregistrée.",
- "discardFailed": "L’instruction d’artefact n’a pas pu être supprimée.", "focusUnavailable": "Cette zone est introuvable sur la page actuelle. Vous pouvez tout de même l’envoyer à l’IA.",
- "reuseLabel": "Copier comme nouvelle annotation", "reuseDescription": "Copie la demande de modification ; choisissez ensuite sa cible sur la page.", "reuseUnavailable": "Cette demande de modification n’a pas pu être copiée. Ouvrez la page correspondante et choisissez sa cible.", "applyPrompt": "Appliquez les annotations de page jointes.",
- "status": { "unknown": "La page n’a pas pu être mise à jour", "not_attempted": "La page n’a pas pu être mise à jour", "applied": "Page mise à jour", "appliedCorrected": "Page mise à jour", "not_applied": "La page n’a pas pu être mise à jour", "conflict": "La page n’a pas pu être mise à jour", "ambiguous": "Vérification de la mise à jour" },
- "statusDetail": { "unknown": "Ouvrez la page pour vérifier.", "not_attempted": "La page n’a pas été mise à jour.", "applied": "", "not_applied": "Réessayez.", "conflict": "Ouvrez la page et réessayez.", "ambiguous": "Ouvrez la page pour vérifier avant de réessayer." }
+ "emptyBlocked": "Terminez chaque instruction d’artefact.",
+ "tooLongBlocked": "Réduisez les instructions à 16 Kio de texte UTF-8 avant l’envoi.",
+ "editLabel": "Modifier l’instruction d’artefact",
+ "removeLabel": "Supprimer l’instruction d’artefact",
+ "updateFailed": "L’instruction d’artefact n’a pas pu être enregistrée.",
+ "discardFailed": "L’instruction d’artefact n’a pas pu être supprimée.",
+ "focusUnavailable": "Cette zone est introuvable sur la page actuelle. Vous pouvez tout de même l’envoyer à l’IA.",
+ "reuseLabel": "Copier comme nouvelle annotation",
+ "reuseDescription": "Copie la demande de modification ; choisissez ensuite sa cible sur la page.",
+ "reuseUnavailable": "Cette demande de modification n’a pas pu être copiée. Ouvrez la page correspondante et choisissez sa cible.",
+ "applyPrompt": "Appliquez les annotations de page jointes.",
+ "status": {
+ "unknown": "La page n’a pas pu être mise à jour",
+ "not_attempted": "La page n’a pas pu être mise à jour",
+ "applied": "Page mise à jour",
+ "appliedCorrected": "Page mise à jour",
+ "not_applied": "La page n’a pas pu être mise à jour",
+ "conflict": "La page n’a pas pu être mise à jour",
+ "ambiguous": "Vérification de la mise à jour"
+ },
+ "statusDetail": {
+ "unknown": "Ouvrez la page pour vérifier.",
+ "not_attempted": "La page n’a pas été mise à jour.",
+ "applied": "",
+ "not_applied": "Réessayez.",
+ "conflict": "Ouvrez la page et réessayez.",
+ "ambiguous": "Ouvrez la page pour vérifier avant de réessayer."
+ }
},
"placeholder": "Envoyer un message...",
"placeholderCompact": "Message...",
@@ -3460,7 +3696,7 @@
"audioUnsupported": "Ce navigateur ne peut pas lire ce format audio. Téléchargez plutôt le fichier.",
"playVideo": "Lire la vidéo",
"videoLoadFailed": "Impossible de charger la vidéo.",
- "videoUnsupported": "Ce navigateur ne peut pas lire ce format vidéo. Téléchargez plutôt le fichier.",
+ "videoUnsupported": "Ce navigateur ne peut pas lire cette vidéo. Téléchargez-la à la place.",
"previewOf": "Aperçu : {title}",
"closePreview": "Fermer l'aperçu",
"previousImage": "Image précédente",
@@ -3469,7 +3705,6 @@
"previewDownload": "Télécharger l'aperçu",
"previewTimedOut": "L'aperçu a expiré.",
"previewFailed": "Échec du chargement de l'aperçu.",
- "videoUnsupported": "Ce navigateur ne peut pas lire cette vidéo. Téléchargez-la à la place.",
"loadVideoPreview": "Charger l’aperçu vidéo",
"loadVideoPreviewFor": "Charger l’aperçu vidéo de {title}",
"videoPreviewLoading": "Chargement de l’aperçu vidéo. Vous pouvez annuler le téléchargement.",
@@ -4380,5 +4615,23 @@
"copyFailed": "Impossible de copier la commande",
"copyLabel": "Copier la commande de redémarrage de la passerelle"
}
+ },
+ "fileTree": {
+ "backToTasks": "Retour aux tâches",
+ "refresh": "Actualiser",
+ "retry": "Réessayer",
+ "empty": "Aucun fichier visible dans cet espace de travail.",
+ "loading": "Chargement des fichiers…",
+ "viewFiles": "Voir les fichiers",
+ "attachToChat": "Joindre au chat",
+ "copyPath": "Copier le chemin",
+ "previewTruncated": "aperçu limité à 1 Mo",
+ "binaryNotPreviewable": "Les fichiers binaires ne peuvent pas être présentés.",
+ "attachTooLarge": "{name} dépasse 1 Mo et ne peut pas être joint depuis l’arborescence.",
+ "attachFailed": "Impossible de joindre {name}.",
+ "attachUnsupported": "{name} n’est pas un fichier texte joignable depuis l’arborescence.",
+ "attached": "{name} ajouté au composeur.",
+ "ctxCopy": "Copier",
+ "ctxAttach": "Ajouter à la conversation"
}
}
diff --git a/opensquilla-webui/src/locales/ja.json b/opensquilla-webui/src/locales/ja.json
index 7acde8b1c..da7b49173 100644
--- a/opensquilla-webui/src/locales/ja.json
+++ b/opensquilla-webui/src/locales/ja.json
@@ -146,12 +146,36 @@
"empty": "この項目には利用できるプレビューがありません。",
"itemLimitReached": "隔離プレビューが 8 件開いています。別のプレビューを開く前に 1 件閉じてください。",
"resources": {
- "title": "ファイル", "count": "ファイル({count})", "empty": "ファイルまたはリンクはまだありません。",
- "groups": { "files": "ファイル", "links": "リンク", "attachments": "ファイル", "documents": "ファイル", "deliverables": "ファイル", "urls": "リンク" },
- "open": "{name} を開く", "preparing": "エディターを準備しています…", "retry": "再試行", "preview": "{name} をプレビュー", "download": "{name} をダウンロード", "edit": "{name} のコピーを編集", "publish": "{name} を公開",
- "imported": "{name} を編集できます。", "published": "{name} を共有できます。",
- "publishUnavailable": "このドキュメントには公開可能な現在のリビジョンがありません。", "actionFailed": "ワークベンチ操作に失敗しました。",
- "unavailableReasons": { "htmlEncodingUnsupported": "この HTML は有効な UTF-8 ではないため、安全にプレビューまたは編集できません。", "htmlValidationFailed": "この HTML は安全なプレビューまたは編集の検証に失敗しました。", "htmlEditTooLarge": "この HTML はアプリ内で編集するには大きすぎます。ダウンロードは引き続き可能です。", "htmlPreviewTooLarge": "この HTML はアプリ内で安全にプレビューするには大きすぎます。ダウンロードは引き続き可能です。", "officeAdapterNotAvailable": "Office の編集機能はまだ利用できません。", "unsupported": "この操作はこのリソースでは利用できません。" }
+ "title": "ファイル",
+ "count": "ファイル({count})",
+ "empty": "ファイルまたはリンクはまだありません。",
+ "groups": {
+ "files": "ファイル",
+ "links": "リンク",
+ "attachments": "ファイル",
+ "documents": "ファイル",
+ "deliverables": "ファイル",
+ "urls": "リンク"
+ },
+ "open": "{name} を開く",
+ "preparing": "エディターを準備しています…",
+ "retry": "再試行",
+ "preview": "{name} をプレビュー",
+ "download": "{name} をダウンロード",
+ "edit": "{name} のコピーを編集",
+ "publish": "{name} を公開",
+ "imported": "{name} を編集できます。",
+ "published": "{name} を共有できます。",
+ "publishUnavailable": "このドキュメントには公開可能な現在のリビジョンがありません。",
+ "actionFailed": "ワークベンチ操作に失敗しました。",
+ "unavailableReasons": {
+ "htmlEncodingUnsupported": "この HTML は有効な UTF-8 ではないため、安全にプレビューまたは編集できません。",
+ "htmlValidationFailed": "この HTML は安全なプレビューまたは編集の検証に失敗しました。",
+ "htmlEditTooLarge": "この HTML はアプリ内で編集するには大きすぎます。ダウンロードは引き続き可能です。",
+ "htmlPreviewTooLarge": "この HTML はアプリ内で安全にプレビューするには大きすぎます。ダウンロードは引き続き可能です。",
+ "officeAdapterNotAvailable": "Office の編集機能はまだ利用できません。",
+ "unsupported": "この操作はこのリソースでは利用できません。"
+ }
},
"browser": {
"back": "戻る",
@@ -166,11 +190,32 @@
"failedDetail": "隔離ブラウザーを再読み込みして続行してください。"
},
"artifactAnnotation": {
- "start": "プレビューに注釈", "stop": "注釈を終了", "selectElement": "注釈するページ要素を選択", "selectElementShort": "要素を選択", "unavailable": "プレビュー注釈は利用できません。", "desktopEditingOnly": "ページ要素の選択と AI 編集はデスクトップアプリで利用できます。",
- "createFailed": "選択した要素を添付できませんでした。", "elementChanged": "添付前に選択した要素が変更されました。現在のプレビューでもう一度選択してください。", "rearmFailed": "要素ピッカーを再起動できなかったため、注釈モードを終了しました。もう一度有効にしてください。", "updateFailed": "注釈の下書きを保存できませんでした。", "discardFailed": "注釈を破棄できませんでした。再試行できるよう開いたままにします。", "closeFailed": "注釈エディターを閉じられませんでした。もう一度お試しください。", "replacementCleanupFailed": "新しい注釈は保持されましたが、以前の下書きを削除できませんでした。",
+ "start": "プレビューに注釈",
+ "stop": "注釈を終了",
+ "selectElement": "注釈するページ要素を選択",
+ "selectElementShort": "要素を選択",
+ "unavailable": "プレビュー注釈は利用できません。",
+ "desktopEditingOnly": "ページ要素の選択と AI 編集はデスクトップアプリで利用できます。",
+ "createFailed": "選択した要素を添付できませんでした。",
+ "elementChanged": "添付前に選択した要素が変更されました。現在のプレビューでもう一度選択してください。",
+ "rearmFailed": "要素ピッカーを再起動できなかったため、注釈モードを終了しました。もう一度有効にしてください。",
+ "updateFailed": "注釈の下書きを保存できませんでした。",
+ "discardFailed": "注釈を破棄できませんでした。再試行できるよう開いたままにします。",
+ "closeFailed": "注釈エディターを閉じられませんでした。もう一度お試しください。",
+ "replacementCleanupFailed": "新しい注釈は保持されましたが、以前の下書きを削除できませんでした。",
"overlayFallback": "以下で注釈を続けてください。",
- "fallbackTitle": "注釈を続ける", "fallbackDetail": "プレビューは一時的に非表示です。注釈は保持されています。", "frozenPreview": "選択したページ領域のプレビュー",
- "placeholder": "この領域の変更内容を入力…", "newlineHint": "{shortcut} で改行", "keepDraft": "注釈を保持", "submit": "注釈を追加", "contextLabel": "現在の選択", "bodyLabel": "ページの注釈", "emptyBody": "希望する変更を記入してください。", "reselectHint": "ページ上の対応する領域を選択してください。", "reuseHint": "変更要求をコピーしました。対応する領域を選択してください。"
+ "fallbackTitle": "注釈を続ける",
+ "fallbackDetail": "プレビューは一時的に非表示です。注釈は保持されています。",
+ "frozenPreview": "選択したページ領域のプレビュー",
+ "placeholder": "この領域の変更内容を入力…",
+ "newlineHint": "{shortcut} で改行",
+ "keepDraft": "注釈を保持",
+ "submit": "注釈を追加",
+ "contextLabel": "現在の選択",
+ "bodyLabel": "ページの注釈",
+ "emptyBody": "希望する変更を記入してください。",
+ "reselectHint": "ページ上の対応する領域を選択してください。",
+ "reuseHint": "変更要求をコピーしました。対応する領域を選択してください。"
},
"artifactPreview": {
"refresh": "プレビューを更新",
@@ -292,8 +337,15 @@
"unsavedSourceCopied": "未保存のソースをコピーしました",
"copyUnsavedSourceFailed": "未保存のソースをコピーできませんでした。",
"discardAndLoadLatest": "破棄して最新版を読み込む",
- "sourceStatus": {"ready":"準備完了","saving":"保存中…","dirty":"未保存","saved":"保存済み","error":"確認が必要"}
- }
+ "sourceStatus": {
+ "ready": "準備完了",
+ "saving": "保存中…",
+ "dirty": "未保存",
+ "saved": "保存済み",
+ "error": "確認が必要"
+ }
+ },
+ "tabOverflow": "すべてのタブ"
},
"nav": {
"chat": "タスク",
@@ -489,9 +541,18 @@
"retryFailedTitle": "プレビューを再生成できませんでした",
"retryFailedDescription": "今回の再生成リクエストは完了しませんでした。以前の結果は採用されておらず、ファイルも変更されていません。再試行するか、インポートを破棄してください。",
"jobStates": {
- "cancelled": {"title": "プレビュー生成をキャンセルしました", "description": "隔離された元データは24時間保持されます。再生成では、検証に合格しない結果を1回だけ再試行できるため、モデルを最大2回呼び出し、結果が異なる場合があります。"},
- "interrupted": {"title": "プレビュー生成が中断されました", "description": "モデル処理中に OpenSquilla が再起動しました。隔離された元データは引き続き利用できます。再生成では内部再試行によりモデルを最大2回呼び出す場合があります。"},
- "failed": {"title": "プレビュー生成に失敗しました", "description": "この試行では検証済みで確認可能なプレビューを生成できませんでした。インポート判断は採用されず、ファイルも変更されていません。隔離された元データは保持され、再生成ではモデルを最大2回呼び出す場合があります。"}
+ "cancelled": {
+ "title": "プレビュー生成をキャンセルしました",
+ "description": "隔離された元データは24時間保持されます。再生成では、検証に合格しない結果を1回だけ再試行できるため、モデルを最大2回呼び出し、結果が異なる場合があります。"
+ },
+ "interrupted": {
+ "title": "プレビュー生成が中断されました",
+ "description": "モデル処理中に OpenSquilla が再起動しました。隔離された元データは引き続き利用できます。再生成では内部再試行によりモデルを最大2回呼び出す場合があります。"
+ },
+ "failed": {
+ "title": "プレビュー生成に失敗しました",
+ "description": "この試行では検証済みで確認可能なプレビューを生成できませんでした。インポート判断は採用されず、ファイルも変更されていません。隔離された元データは保持され、再生成ではモデルを最大2回呼び出す場合があります。"
+ }
},
"modelAnalysisTitle": "モデル分析(ファイル変更と照合してください)",
"previewTitle": "すべてのファイル変更を確認",
@@ -557,53 +618,135 @@
"available": "安全モード利用可能",
"unavailable": "安全モード利用不可",
"builtin": "組み込み",
- "actions": { "add": "追加", "remove": "削除", "retry": "再試行", "copy": "コピー", "saving": "保存中…", "redetect": "再検出" },
+ "actions": {
+ "add": "追加",
+ "remove": "削除",
+ "retry": "再試行",
+ "copy": "コピー",
+ "saving": "保存中…",
+ "redetect": "再検出"
+ },
"mode": {
- "title": "既定のアクセスモード", "description": "新しいタスクの開始モードを選びます。能力検証に失敗した場合、Safe は選択できません。",
- "default": "既定モード", "safe": "Safe", "full": "完全アクセス", "resetWarning": "起動時の警告を再表示"
+ "title": "既定のアクセスモード",
+ "description": "新しいタスクの開始モードを選びます。能力検証に失敗した場合、Safe は選択できません。",
+ "default": "既定モード",
+ "safe": "Safe",
+ "full": "完全アクセス",
+ "resetWarning": "起動時の警告を再表示"
},
"setup": {
- "title": "Safe モードを設定", "description": "OpenSquilla は、分離アカウントとファイル・ネットワーク保護を設定するために、一度だけ管理者の承認を必要とします。", "descriptionWithDuration": "OpenSquilla は、分離アカウントと安全保護を設定するために、一度だけ管理者の承認を必要とします。初回設定には通常約 20~30 秒かかります。設定中は OpenSquilla を開いたままにしてください。",
- "continue": "続ける", "configuring": "設定中…", "requestingApproval": "続行するには、Windows の確認画面で承認してください。", "configuringProtection": "OpenSquilla が Safe モードの設定を完了しています。アプリは開いたままにしてください。", "takingLonger": "初回設定には数分かかる場合があります。確認は自動的に実行されます。", "elapsed": "経過 {seconds} 秒", "cancelled": "設定をキャンセルしました。完全アクセスは変更されていません。いつでも再試行できます。",
- "failed": "Safe モードを設定できませんでした。完全アクセスは変更されていません。", "verificationFailed": "設定は完了しましたが、安全性の確認に失敗しました。完全アクセスは変更されていません。",
- "runInBackground": "バックグラウンドで設定", "readyToast": "Safe モードの準備ができました。", "failedToast": "Safe モードの設定を完了できませんでした。Safe モードから再試行してください。"
+ "title": "Safe モードを設定",
+ "description": "OpenSquilla は、分離アカウントとファイル・ネットワーク保護を設定するために、一度だけ管理者の承認を必要とします。",
+ "descriptionWithDuration": "OpenSquilla は、分離アカウントと安全保護を設定するために、一度だけ管理者の承認を必要とします。初回設定には通常約 20~30 秒かかります。設定中は OpenSquilla を開いたままにしてください。",
+ "continue": "続ける",
+ "configuring": "設定中…",
+ "requestingApproval": "続行するには、Windows の確認画面で承認してください。",
+ "configuringProtection": "OpenSquilla が Safe モードの設定を完了しています。アプリは開いたままにしてください。",
+ "takingLonger": "初回設定には数分かかる場合があります。確認は自動的に実行されます。",
+ "elapsed": "経過 {seconds} 秒",
+ "cancelled": "設定をキャンセルしました。完全アクセスは変更されていません。いつでも再試行できます。",
+ "failed": "Safe モードを設定できませんでした。完全アクセスは変更されていません。",
+ "verificationFailed": "設定は完了しましたが、安全性の確認に失敗しました。完全アクセスは変更されていません。",
+ "runInBackground": "バックグラウンドで設定",
+ "readyToast": "Safe モードの準備ができました。",
+ "failedToast": "Safe モードの設定を完了できませんでした。Safe モードから再試行してください。"
},
"files": {
- "title": "ファイル保護", "description": "通常のファイルは読み取り可能です。保護パスの変更には承認が必要です。", "readsAllowed": "読み取り許可",
- "customPath": "カスタム保護パス", "pathPlaceholder": "保護パスを追加", "backupTitle": "破壊的なファイル変更の前にバックアップ",
- "backupDescription": "既存ファイルの削除や変更前に復元可能なコピーを作成し、必要に応じて古いバックアップを自動削除します。", "quota": "バックアップ容量上限",
+ "title": "ファイル保護",
+ "description": "通常のファイルは読み取り可能です。保護パスの変更には承認が必要です。",
+ "readsAllowed": "読み取り許可",
+ "customPath": "カスタム保護パス",
+ "pathPlaceholder": "保護パスを追加",
+ "backupTitle": "破壊的なファイル変更の前にバックアップ",
+ "backupDescription": "既存ファイルの削除や変更前に復元可能なコピーを作成し、必要に応じて古いバックアップを自動削除します。",
+ "quota": "バックアップ容量上限",
"recursiveWarning": "バックアップがない削除や上書きは復元できない場合があります。古いコピーを削除してもバックアップできない場合は、続行前に再確認します。"
},
"commands": {
- "title": "コマンド保護", "description": "コマンドは原則自動実行され、git push など影響の大きい操作だけ承認を求めます。",
- "systemTools": "システムツール", "systemToolsAuto": "自動許可", "systemToolsPrompt": "事前確認", "systemToolsDisabled": "無効",
- "approvalPrefixes": "承認が必要なコマンド接頭辞", "autoPrefixes": "常に自動許可する接頭辞", "prefixPlaceholder": "例: git push"
+ "title": "コマンド保護",
+ "description": "コマンドは原則自動実行され、git push など影響の大きい操作だけ承認を求めます。",
+ "systemTools": "システムツール",
+ "systemToolsAuto": "自動許可",
+ "systemToolsPrompt": "事前確認",
+ "systemToolsDisabled": "無効",
+ "approvalPrefixes": "承認が必要なコマンド接頭辞",
+ "autoPrefixes": "常に自動許可する接頭辞",
+ "prefixPlaceholder": "例: git push"
},
"network": {
- "title": "ネットワーク保護", "description": "公開ネットワークは既定で許可し、SSRF とメタデータ保護を維持します。",
- "blockAll": "すべてのネットワークを遮断", "blockAllDescription": "許可ドメインだけを例外にします。", "allowDomains": "許可ドメイン", "denyDomains": "拒否ドメイン"
+ "title": "ネットワーク保護",
+ "description": "公開ネットワークは既定で許可し、SSRF とメタデータ保護を維持します。",
+ "blockAll": "すべてのネットワークを遮断",
+ "blockAllDescription": "許可ドメインだけを例外にします。",
+ "allowDomains": "許可ドメイン",
+ "denyDomains": "拒否ドメイン"
},
"runtimes": {
- "title": "ランタイムパック", "description": "ダウンロード後は自動的に有効になります。安全モードはインストール済みパックを優先し、フルアクセスはホストツールを優先します。", "target": "ランタイム対象",
- "allowRuntime": "{runtime} を許可", "progress": "{runtime} のダウンロード:{progress}%",
+ "title": "ランタイムパック",
+ "description": "ダウンロード後は自動的に有効になります。安全モードはインストール済みパックを優先し、フルアクセスはホストツールを優先します。",
+ "target": "ランタイム対象",
+ "allowRuntime": "{runtime} を許可",
+ "progress": "{runtime} のダウンロード:{progress}%",
"states": {
- "unknown": "状態を取得できません", "notInstalled": "未インストール", "disabled": "無効", "installed": "インストール済み", "installedVersion": "インストール済み · {version}", "updatePaused": "更新を一時停止",
- "unsupported": "このシステムでは利用できません", "corrupt": "修復が必要です", "queued": "ダウンロード待ち", "downloading": "ダウンロード中", "downloadingProgress": "ダウンロード中 · {progress}%",
- "verifying": "ダウンロードを検証中", "extracting": "インストール中", "probing": "ランタイムを確認中", "activating": "インストールを完了中", "cancelling": "キャンセル中", "queuedRemoval": "削除待ち", "removing": "削除中",
- "cancelled": "ダウンロードをキャンセルしました", "failed": "ダウンロードに失敗しました", "removeFailed": "削除に失敗しました", "removeInterrupted": "削除が中断されました", "interrupted": "ダウンロードを一時停止しました"
+ "unknown": "状態を取得できません",
+ "notInstalled": "未インストール",
+ "disabled": "無効",
+ "installed": "インストール済み",
+ "installedVersion": "インストール済み · {version}",
+ "updatePaused": "更新を一時停止",
+ "unsupported": "このシステムでは利用できません",
+ "corrupt": "修復が必要です",
+ "queued": "ダウンロード待ち",
+ "downloading": "ダウンロード中",
+ "downloadingProgress": "ダウンロード中 · {progress}%",
+ "verifying": "ダウンロードを検証中",
+ "extracting": "インストール中",
+ "probing": "ランタイムを確認中",
+ "activating": "インストールを完了中",
+ "cancelling": "キャンセル中",
+ "queuedRemoval": "削除待ち",
+ "removing": "削除中",
+ "cancelled": "ダウンロードをキャンセルしました",
+ "failed": "ダウンロードに失敗しました",
+ "removeFailed": "削除に失敗しました",
+ "removeInterrupted": "削除が中断されました",
+ "interrupted": "ダウンロードを一時停止しました"
},
- "actions": { "enable": "有効にする", "download": "ダウンロード", "cancel": "キャンセル", "discardDownload": "ダウンロードを削除", "resume": "再開", "retry": "再試行", "repair": "修復", "remove": "削除", "retryRemove": "削除を再試行" },
- "sources": { "oss": "北京 OSS", "github": "GitHub リリース" }
+ "actions": {
+ "enable": "有効にする",
+ "download": "ダウンロード",
+ "cancel": "キャンセル",
+ "discardDownload": "ダウンロードを削除",
+ "resume": "再開",
+ "retry": "再試行",
+ "repair": "修復",
+ "remove": "削除",
+ "retryRemove": "削除を再試行"
+ },
+ "sources": {
+ "oss": "北京 OSS",
+ "github": "GitHub リリース"
+ }
},
"lan": {
- "listen": "ローカルネットワークで待ち受ける", "listenDescription": "再起動後に全ローカルインターフェイスへバインドします。公開ピアは引き続き拒否されます。",
- "allowedCidrs": "許可するクライアント CIDR", "cidrDescription": "任意。空欄では loopback、RFC1918、IPv6 ULA を許可し、指定時は範囲を狭めることだけができます。",
+ "listen": "ローカルネットワークで待ち受ける",
+ "listenDescription": "再起動後に全ローカルインターフェイスへバインドします。公開ピアは引き続き拒否されます。",
+ "allowedCidrs": "許可するクライアント CIDR",
+ "cidrDescription": "任意。空欄では loopback、RFC1918、IPv6 ULA を許可し、指定時は範囲を狭めることだけができます。",
"restartRequired": "変更を適用するには OpenSquilla を再起動してください。",
- "title": "Web アクセスと名前付きトークン", "description": "有効なトークンのないリモート Web アクセスはゲスト安全モードを使用し、名前付きトークンは設定された権限を付与します。",
- "guest": "トークンなし・誤り:", "guestDescription": " ゲスト安全モード:通常ファイルは読み取り可能、書き込みは既定ワークスペースのみ、認証情報ファイルは読み取り不可です。",
- "authenticated": "有効なトークン:", "authenticatedDescription": " 権限に応じて安全またはフルアクセスを利用できます。",
- "tokenName": "トークン名(例: Laptop)", "hostExecute": "ホスト実行 / フルアクセスを許可", "createToken": "トークンを作成",
- "copyNow": "今すぐコピーしてください。再表示されません。", "fullCapable": "安全とフルアクセス", "safeOnly": "安全のみ", "revoke": "無効化"
+ "title": "Web アクセスと名前付きトークン",
+ "description": "有効なトークンのないリモート Web アクセスはゲスト安全モードを使用し、名前付きトークンは設定された権限を付与します。",
+ "guest": "トークンなし・誤り:",
+ "guestDescription": " ゲスト安全モード:通常ファイルは読み取り可能、書き込みは既定ワークスペースのみ、認証情報ファイルは読み取り不可です。",
+ "authenticated": "有効なトークン:",
+ "authenticatedDescription": " 権限に応じて安全またはフルアクセスを利用できます。",
+ "tokenName": "トークン名(例: Laptop)",
+ "hostExecute": "ホスト実行 / フルアクセスを許可",
+ "createToken": "トークンを作成",
+ "copyNow": "今すぐコピーしてください。再表示されません。",
+ "fullCapable": "安全とフルアクセス",
+ "safeOnly": "安全のみ",
+ "revoke": "無効化"
}
},
"dialog": {
@@ -2541,15 +2684,60 @@
"galleryEyebrow": "自動化テンプレート",
"bulkDeleteConfirm": "選択した {count} 件の自動化を削除しますか?この操作は取り消せません。",
"templates": {
- "ai-daily": { "title": "毎日のAIニュース要約", "description": "過去24時間の重要なAIニュースを信頼できるリンク付きでまとめます。", "category": "ニュース", "schedule": "毎日 08:00" },
- "weekly-report": { "title": "週間業務レビュー", "description": "進捗、リスク、成果物、翌週の優先事項を整理します。", "category": "生産性", "schedule": "金曜日 17:30" },
- "english-five": { "title": "毎日5つの英単語", "description": "例文と短いクイズ付きの軽量な語彙レッスンを作成します。", "category": "学習", "schedule": "毎日 09:00" },
- "project-risk": { "title": "プロジェクトリスク確認", "description": "遅延、エラー、未解決事項を確認し、リスクと提案を報告します。", "category": "プロジェクト", "schedule": "平日 10:00" },
- "knowledge-review": { "title": "週間ナレッジレビュー", "description": "新しいメモや会議記録を整理し、次に読む項目をまとめます。", "category": "ナレッジ", "schedule": "日曜日 18:00" },
- "daily-idea": { "title": "今日のアイデアと豆知識", "description": "信頼できる豆知識と実行しやすい小さなアイデアを届けます。", "category": "ライフスタイル", "schedule": "毎日 12:00" },
- "bedtime-story": { "title": "毎日の読み聞かせ物語", "description": "親子で読める温かく想像力豊かな短編物語を作ります。", "category": "家族", "schedule": "毎日 20:30" },
- "classic-movie": { "title": "名作映画のおすすめ", "description": "高評価の名作映画をネタバレなしで紹介します。", "category": "エンタメ", "schedule": "土曜日 19:00" },
- "today-in-history": { "title": "歴史上の今日", "description": "科学、文化、社会から信頼できる歴史的出来事を紹介します。", "category": "毎日の知識", "schedule": "毎日 08:30" }
+ "ai-daily": {
+ "title": "毎日のAIニュース要約",
+ "description": "過去24時間の重要なAIニュースを信頼できるリンク付きでまとめます。",
+ "category": "ニュース",
+ "schedule": "毎日 08:00"
+ },
+ "weekly-report": {
+ "title": "週間業務レビュー",
+ "description": "進捗、リスク、成果物、翌週の優先事項を整理します。",
+ "category": "生産性",
+ "schedule": "金曜日 17:30"
+ },
+ "english-five": {
+ "title": "毎日5つの英単語",
+ "description": "例文と短いクイズ付きの軽量な語彙レッスンを作成します。",
+ "category": "学習",
+ "schedule": "毎日 09:00"
+ },
+ "project-risk": {
+ "title": "プロジェクトリスク確認",
+ "description": "遅延、エラー、未解決事項を確認し、リスクと提案を報告します。",
+ "category": "プロジェクト",
+ "schedule": "平日 10:00"
+ },
+ "knowledge-review": {
+ "title": "週間ナレッジレビュー",
+ "description": "新しいメモや会議記録を整理し、次に読む項目をまとめます。",
+ "category": "ナレッジ",
+ "schedule": "日曜日 18:00"
+ },
+ "daily-idea": {
+ "title": "今日のアイデアと豆知識",
+ "description": "信頼できる豆知識と実行しやすい小さなアイデアを届けます。",
+ "category": "ライフスタイル",
+ "schedule": "毎日 12:00"
+ },
+ "bedtime-story": {
+ "title": "毎日の読み聞かせ物語",
+ "description": "親子で読める温かく想像力豊かな短編物語を作ります。",
+ "category": "家族",
+ "schedule": "毎日 20:30"
+ },
+ "classic-movie": {
+ "title": "名作映画のおすすめ",
+ "description": "高評価の名作映画をネタバレなしで紹介します。",
+ "category": "エンタメ",
+ "schedule": "土曜日 19:00"
+ },
+ "today-in-history": {
+ "title": "歴史上の今日",
+ "description": "科学、文化、社会から信頼できる歴史的出来事を紹介します。",
+ "category": "毎日の知識",
+ "schedule": "毎日 08:30"
+ }
}
},
"deleteDialog": {
@@ -2671,39 +2859,39 @@
"fdChannel": "チャンネル",
"fdWebhook": "Webhook",
"enabled": "有効",
- "saveSchedule": "スケジュールを保存"
- ,"friendlyNamePlaceholder": "例:毎日顧客フィードバックを整理"
- ,"friendlyTypeCron": "指定時刻に繰り返す"
- ,"friendlyTypeEvery": "一定間隔で繰り返す"
- ,"friendlyTypeAt": "指定時刻に一度だけ実行"
- ,"executionFrequency": "実行頻度"
- ,"daily": "毎日"
- ,"weekdays": "平日"
- ,"weekly": "毎週"
- ,"monthly": "毎月"
- ,"customAdvancedTime": "高度なスケジュール"
- ,"weekday": "曜日"
- ,"date": "日付"
- ,"monthlyDay": "毎月 {day} 日"
- ,"specificTime": "時刻"
- ,"customTimeHint": "より柔軟な時間ルールを使用できます。"
- ,"openAdvancedTime": "高度な時間設定を開く"
- ,"everyHowOften": "実行間隔"
- ,"timeUnit": "時間単位"
- ,"minutes": "分"
- ,"hours": "時間"
- ,"days": "日"
- ,"dateAndTime": "実行日時"
- ,"friendlyMessagePlaceholder": "例:今日の顧客フィードバックを整理し、次の対応を提案"
- ,"moreRuntimeSettings": "その他の実行設定(任意)"
- ,"advancedTimeHint": "上の時間選択で対応できない場合にのみ使用します。"
- ,"timezoneSimpleHint": "通常は変更不要です。"
- ,"projectWorkspace": "プロジェクトワークスペース"
- ,"defaultWorkspace": "既定のワークスペースを使用"
- ,"noWorkspace": "プロジェクトワークスペースなし"
- ,"workspaceUnavailable": "{name}(利用不可)"
- ,"workspaceRequiredHint": "このタスクが確認するプロジェクトを選択してください。タスクは選択したプロジェクト内だけを処理します。"
- ,"workspaceOptionalHint": "任意。一般的なタスクは「プロジェクトワークスペースなし」を選び、ファイルが必要な場合はプロジェクトを選択します。"
+ "saveSchedule": "スケジュールを保存",
+ "friendlyNamePlaceholder": "例:毎日顧客フィードバックを整理",
+ "friendlyTypeCron": "指定時刻に繰り返す",
+ "friendlyTypeEvery": "一定間隔で繰り返す",
+ "friendlyTypeAt": "指定時刻に一度だけ実行",
+ "executionFrequency": "実行頻度",
+ "daily": "毎日",
+ "weekdays": "平日",
+ "weekly": "毎週",
+ "monthly": "毎月",
+ "customAdvancedTime": "高度なスケジュール",
+ "weekday": "曜日",
+ "date": "日付",
+ "monthlyDay": "毎月 {day} 日",
+ "specificTime": "時刻",
+ "customTimeHint": "より柔軟な時間ルールを使用できます。",
+ "openAdvancedTime": "高度な時間設定を開く",
+ "everyHowOften": "実行間隔",
+ "timeUnit": "時間単位",
+ "minutes": "分",
+ "hours": "時間",
+ "days": "日",
+ "dateAndTime": "実行日時",
+ "friendlyMessagePlaceholder": "例:今日の顧客フィードバックを整理し、次の対応を提案",
+ "moreRuntimeSettings": "その他の実行設定(任意)",
+ "advancedTimeHint": "上の時間選択で対応できない場合にのみ使用します。",
+ "timezoneSimpleHint": "通常は変更不要です。",
+ "projectWorkspace": "プロジェクトワークスペース",
+ "defaultWorkspace": "既定のワークスペースを使用",
+ "noWorkspace": "プロジェクトワークスペースなし",
+ "workspaceUnavailable": "{name}(利用不可)",
+ "workspaceRequiredHint": "このタスクが確認するプロジェクトを選択してください。タスクは選択したプロジェクト内だけを処理します。",
+ "workspaceOptionalHint": "任意。一般的なタスクは「プロジェクトワークスペースなし」を選び、ファイルが必要な場合はプロジェクトを選択します。"
},
"form": {
"cronPreviewPlaceholder": "プレビューするには 5 フィールドの cron 式を入力してください",
@@ -3338,9 +3526,18 @@
"planTitle": "保活プラン",
"planSummary": "約 {interval} 分ごと · 最大約 {count} 回 · {duration} 分後に一時停止。",
"costWarning": "実際のプロバイダーリクエストにはトークン料金が発生する場合があります。会話には追加されず、ツールも実行しません。",
- "statusLabel": "状態", "targetLabel": "プロバイダー / モデル", "lastHitLabel": "前回のキャッシュトークン",
+ "statusLabel": "状態",
+ "targetLabel": "プロバイダー / モデル",
+ "lastHitLabel": "前回のキャッシュトークン",
"autoPauseLabel": "自動一時停止",
- "states": { "off": "オフ", "waiting": "安定したプレフィックスを待機", "scheduled": "予定済み", "probing": "プローブ中", "paused": "アイドル上限に到達;次のメッセージを待機中", "stopped": "失敗後に停止" }
+ "states": {
+ "off": "オフ",
+ "waiting": "安定したプレフィックスを待機",
+ "scheduled": "予定済み",
+ "probing": "プローブ中",
+ "paused": "アイドル上限に到達;次のメッセージを待機中",
+ "stopped": "失敗後に停止"
+ }
},
"send": "送信",
"sendQueues": "送信(現在の応答後にキューへ追加)",
@@ -3351,18 +3548,57 @@
"stoppingResponse": "安全に停止しています… 新しいメッセージはキューに入ります。",
"messageToSend": "送信するメッセージ",
"promptAnnotations": {
- "label": "注釈", "draftLabel": "保留中のページ注釈", "sentLabel": "ページ注釈", "element": "選択した領域",
+ "label": "注釈",
+ "draftLabel": "保留中のページ注釈",
+ "sentLabel": "ページ注釈",
+ "element": "選択した領域",
"targetLabel": "{kind}:{text}",
- "targetKinds": { "heading": "見出し", "button": "ボタン", "link": "リンク", "image": "画像", "input": "入力欄", "form": "フォーム", "section": "セクション", "list": "リスト", "table": "表", "text": "テキスト", "region": "領域", "element": "選択した領域" },
- "emptyDraft": "指示を追加", "stale": "現在のページでこの部分を特定できません",
+ "targetKinds": {
+ "heading": "見出し",
+ "button": "ボタン",
+ "link": "リンク",
+ "image": "画像",
+ "input": "入力欄",
+ "form": "フォーム",
+ "section": "セクション",
+ "list": "リスト",
+ "table": "表",
+ "text": "テキスト",
+ "region": "領域",
+ "element": "選択した領域"
+ },
+ "emptyDraft": "指示を追加",
+ "stale": "現在のページでこの部分を特定できません",
"editingBlocked": "編集中の注釈を追加またはキャンセルしてから送信してください。",
"staleBlocked": "現在のページでこの部分を特定できませんが、そのまま AI に送信できます。",
- "emptyBlocked": "すべての成果物指示を完成してください。", "tooLongBlocked": "成果物指示を UTF-8 で 16 KiB 以内に短くしてから送信してください。", "editLabel": "成果物指示を編集",
- "removeLabel": "成果物指示を削除", "updateFailed": "成果物指示を保存できませんでした。",
- "discardFailed": "成果物指示を削除できませんでした。", "focusUnavailable": "現在のページでこの部分を特定できませんが、そのまま AI に送信できます。",
- "reuseLabel": "新しい注釈としてコピー", "reuseDescription": "変更要求をコピーし、ページ上で対象を選択します。", "reuseUnavailable": "この変更要求をコピーできませんでした。対応するページを開いて対象を選択してください。", "applyPrompt": "添付されたページ注釈を適用してください。",
- "status": { "unknown": "ページを更新できませんでした", "not_attempted": "ページを更新できませんでした", "applied": "ページを更新しました", "appliedCorrected": "ページを更新しました", "not_applied": "ページを更新できませんでした", "conflict": "ページを更新できませんでした", "ambiguous": "更新を確認中" },
- "statusDetail": { "unknown": "ページを開いて確認してください。", "not_attempted": "ページは更新されませんでした。", "applied": "", "not_applied": "もう一度お試しください。", "conflict": "ページを開いてもう一度お試しください。", "ambiguous": "再試行する前にページを開いて確認してください。" }
+ "emptyBlocked": "すべての成果物指示を完成してください。",
+ "tooLongBlocked": "成果物指示を UTF-8 で 16 KiB 以内に短くしてから送信してください。",
+ "editLabel": "成果物指示を編集",
+ "removeLabel": "成果物指示を削除",
+ "updateFailed": "成果物指示を保存できませんでした。",
+ "discardFailed": "成果物指示を削除できませんでした。",
+ "focusUnavailable": "現在のページでこの部分を特定できませんが、そのまま AI に送信できます。",
+ "reuseLabel": "新しい注釈としてコピー",
+ "reuseDescription": "変更要求をコピーし、ページ上で対象を選択します。",
+ "reuseUnavailable": "この変更要求をコピーできませんでした。対応するページを開いて対象を選択してください。",
+ "applyPrompt": "添付されたページ注釈を適用してください。",
+ "status": {
+ "unknown": "ページを更新できませんでした",
+ "not_attempted": "ページを更新できませんでした",
+ "applied": "ページを更新しました",
+ "appliedCorrected": "ページを更新しました",
+ "not_applied": "ページを更新できませんでした",
+ "conflict": "ページを更新できませんでした",
+ "ambiguous": "更新を確認中"
+ },
+ "statusDetail": {
+ "unknown": "ページを開いて確認してください。",
+ "not_attempted": "ページは更新されませんでした。",
+ "applied": "",
+ "not_applied": "もう一度お試しください。",
+ "conflict": "ページを開いてもう一度お試しください。",
+ "ambiguous": "再試行する前にページを開いて確認してください。"
+ }
},
"placeholder": "メッセージを送信...",
"placeholderCompact": "メッセージ...",
@@ -3460,7 +3696,7 @@
"audioUnsupported": "このブラウザではこの音声形式を再生できません。代わりにファイルをダウンロードしてください。",
"playVideo": "動画を再生",
"videoLoadFailed": "動画を読み込めませんでした。",
- "videoUnsupported": "このブラウザではこの動画形式を再生できません。代わりにファイルをダウンロードしてください。",
+ "videoUnsupported": "このブラウザーでは動画を再生できません。代わりにダウンロードしてください。",
"previewOf": "プレビュー: {title}",
"closePreview": "プレビューを閉じる",
"previousImage": "前の画像",
@@ -3469,7 +3705,6 @@
"previewDownload": "プレビューのダウンロード",
"previewTimedOut": "プレビューがタイムアウトしました。",
"previewFailed": "プレビューの読み込みに失敗しました。",
- "videoUnsupported": "このブラウザーでは動画を再生できません。代わりにダウンロードしてください。",
"loadVideoPreview": "動画プレビューを読み込む",
"loadVideoPreviewFor": "{title} の動画プレビューを読み込む",
"videoPreviewLoading": "動画プレビューを読み込んでいます。ダウンロード中にキャンセルできます。",
@@ -4380,5 +4615,23 @@
"copyFailed": "コマンドをコピーできませんでした",
"copyLabel": "ゲートウェイ再起動コマンドをコピー"
}
+ },
+ "fileTree": {
+ "backToTasks": "タスクに戻る",
+ "refresh": "更新",
+ "retry": "再試行",
+ "empty": "表示できるファイルがありません。",
+ "loading": "ファイルを読み込み中…",
+ "viewFiles": "ファイルを表示",
+ "attachToChat": "チャトに添付",
+ "copyPath": "パスをコピー",
+ "previewTruncated": "プレビューは 1 MB 以内",
+ "binaryNotPreviewable": "ビンラリファイルはプレビューできません。",
+ "attachTooLarge": "{name} は 1 MB を超えており、ファイルツリによって添付できません。",
+ "attachFailed": "{name} を添付できませんでした。",
+ "attachUnsupported": "{name} はテキストではないため、ファイルツリによって添付できません。",
+ "attached": "{name} を入力フィードに添付しました。",
+ "ctxCopy": "コピー",
+ "ctxAttach": "会話に追加"
}
}
diff --git a/opensquilla-webui/src/locales/zh-Hans.json b/opensquilla-webui/src/locales/zh-Hans.json
index 2de803aa7..9b7445c67 100644
--- a/opensquilla-webui/src/locales/zh-Hans.json
+++ b/opensquilla-webui/src/locales/zh-Hans.json
@@ -146,12 +146,36 @@
"empty": "此项目没有可用的预览。",
"itemLimitReached": "已打开 8 个隔离预览。请先关闭一个再继续。",
"resources": {
- "title": "文件", "count": "文件({count})", "empty": "暂无文件或链接。",
- "groups": { "files": "文件", "links": "链接", "attachments": "文件", "documents": "文件", "deliverables": "文件", "urls": "链接" },
- "open": "打开 {name}", "preparing": "正在准备编辑器…", "retry": "重试", "preview": "预览 {name}", "download": "下载 {name}", "edit": "创建 {name} 的可编辑副本", "publish": "发布 {name}",
- "imported": "{name} 已可编辑。", "published": "{name} 已可分享。",
- "publishUnavailable": "此文档没有可发布的当前版本。", "actionFailed": "工作台操作失败。",
- "unavailableReasons": { "htmlEncodingUnsupported": "此 HTML 不是有效的 UTF-8,无法安全预览或编辑。", "htmlValidationFailed": "此 HTML 未通过安全校验,无法预览或编辑。", "htmlEditTooLarge": "此 HTML 过大,无法在应用内编辑;仍可下载。", "htmlPreviewTooLarge": "此 HTML 过大,无法在应用内安全预览;仍可下载。", "officeAdapterNotAvailable": "Office 编辑能力尚未启用。", "unsupported": "此资源暂不支持此操作。" }
+ "title": "文件",
+ "count": "文件({count})",
+ "empty": "暂无文件或链接。",
+ "groups": {
+ "files": "文件",
+ "links": "链接",
+ "attachments": "文件",
+ "documents": "文件",
+ "deliverables": "文件",
+ "urls": "链接"
+ },
+ "open": "打开 {name}",
+ "preparing": "正在准备编辑器…",
+ "retry": "重试",
+ "preview": "预览 {name}",
+ "download": "下载 {name}",
+ "edit": "创建 {name} 的可编辑副本",
+ "publish": "发布 {name}",
+ "imported": "{name} 已可编辑。",
+ "published": "{name} 已可分享。",
+ "publishUnavailable": "此文档没有可发布的当前版本。",
+ "actionFailed": "工作台操作失败。",
+ "unavailableReasons": {
+ "htmlEncodingUnsupported": "此 HTML 不是有效的 UTF-8,无法安全预览或编辑。",
+ "htmlValidationFailed": "此 HTML 未通过安全校验,无法预览或编辑。",
+ "htmlEditTooLarge": "此 HTML 过大,无法在应用内编辑;仍可下载。",
+ "htmlPreviewTooLarge": "此 HTML 过大,无法在应用内安全预览;仍可下载。",
+ "officeAdapterNotAvailable": "Office 编辑能力尚未启用。",
+ "unsupported": "此资源暂不支持此操作。"
+ }
},
"browser": {
"back": "后退",
@@ -166,11 +190,32 @@
"failedDetail": "重新加载隔离浏览器以继续。"
},
"artifactAnnotation": {
- "start": "批注预览", "stop": "退出批注模式", "selectElement": "选择页面元素进行批注", "selectElementShort": "选择元素", "unavailable": "当前无法使用预览批注。", "desktopEditingOnly": "请在桌面客户端中选择页面元素并让 AI 编辑。",
- "createFailed": "无法附加所选页面元素。", "elementChanged": "所选元素在附加前发生了变化,请在当前预览中重新选择。", "rearmFailed": "元素选择器无法重新启动,批注模式已退出。请重新开启批注模式后再试。", "updateFailed": "无法保存批注草稿。", "discardFailed": "无法丢弃该批注。编辑框将保持打开,请重试。", "closeFailed": "无法关闭批注编辑框。请重试后再选择其他元素。", "replacementCleanupFailed": "新批注已保留,但无法删除之前的草稿。",
+ "start": "批注预览",
+ "stop": "退出批注模式",
+ "selectElement": "选择页面元素进行批注",
+ "selectElementShort": "选择元素",
+ "unavailable": "当前无法使用预览批注。",
+ "desktopEditingOnly": "请在桌面客户端中选择页面元素并让 AI 编辑。",
+ "createFailed": "无法附加所选页面元素。",
+ "elementChanged": "所选元素在附加前发生了变化,请在当前预览中重新选择。",
+ "rearmFailed": "元素选择器无法重新启动,批注模式已退出。请重新开启批注模式后再试。",
+ "updateFailed": "无法保存批注草稿。",
+ "discardFailed": "无法丢弃该批注。编辑框将保持打开,请重试。",
+ "closeFailed": "无法关闭批注编辑框。请重试后再选择其他元素。",
+ "replacementCleanupFailed": "新批注已保留,但无法删除之前的草稿。",
"overlayFallback": "请在下方继续编辑批注。",
- "fallbackTitle": "继续编辑批注", "fallbackDetail": "预览暂时隐藏,你的批注仍在。", "frozenPreview": "所选页面区域的预览",
- "placeholder": "描述希望对这个区域进行的修改…", "newlineHint": "{shortcut} 换行", "keepDraft": "保留批注", "submit": "添加批注", "contextLabel": "当前选区", "bodyLabel": "页面批注", "emptyBody": "请描述希望的修改。", "reselectHint": "请在页面中选择对应区域。", "reuseHint": "已复制修改要求,请在页面中选择对应区域。"
+ "fallbackTitle": "继续编辑批注",
+ "fallbackDetail": "预览暂时隐藏,你的批注仍在。",
+ "frozenPreview": "所选页面区域的预览",
+ "placeholder": "描述希望对这个区域进行的修改…",
+ "newlineHint": "{shortcut} 换行",
+ "keepDraft": "保留批注",
+ "submit": "添加批注",
+ "contextLabel": "当前选区",
+ "bodyLabel": "页面批注",
+ "emptyBody": "请描述希望的修改。",
+ "reselectHint": "请在页面中选择对应区域。",
+ "reuseHint": "已复制修改要求,请在页面中选择对应区域。"
},
"artifactPreview": {
"refresh": "刷新预览",
@@ -299,7 +344,8 @@
"saved": "已保存",
"error": "需要处理"
}
- }
+ },
+ "tabOverflow": "全部页签"
},
"nav": {
"chat": "任务",
@@ -495,9 +541,18 @@
"retryFailedTitle": "未能重新生成预览",
"retryFailedDescription": "本次重新生成请求未成功。上一次处理结果仍未被采纳,也没有修改文件;可以再次尝试或丢弃导入。",
"jobStates": {
- "cancelled": {"title": "已取消生成预览", "description": "隔离原文会保留 24 小时。重新生成时若首个结果未通过校验,会自动重试一次,因此最多调用模型两次,结果可能不同。"},
- "interrupted": {"title": "预览生成已中断", "description": "模型处理期间 OpenSquilla 发生重启。隔离原文仍可用;重新生成时若首个结果未通过校验,会自动重试一次,因此最多调用模型两次。"},
- "failed": {"title": "本次预览生成失败", "description": "本次处理没有生成通过校验且可核对的预览。未采纳任何导入判断,也未修改任何文件。隔离原文已保留;重新生成最多调用模型两次,结果可能不同。"}
+ "cancelled": {
+ "title": "已取消生成预览",
+ "description": "隔离原文会保留 24 小时。重新生成时若首个结果未通过校验,会自动重试一次,因此最多调用模型两次,结果可能不同。"
+ },
+ "interrupted": {
+ "title": "预览生成已中断",
+ "description": "模型处理期间 OpenSquilla 发生重启。隔离原文仍可用;重新生成时若首个结果未通过校验,会自动重试一次,因此最多调用模型两次。"
+ },
+ "failed": {
+ "title": "本次预览生成失败",
+ "description": "本次处理没有生成通过校验且可核对的预览。未采纳任何导入判断,也未修改任何文件。隔离原文已保留;重新生成最多调用模型两次,结果可能不同。"
+ }
},
"modelAnalysisTitle": "模型分析(请以文件差异为准)",
"previewTitle": "核对每一处文件变更",
@@ -2629,15 +2684,60 @@
"galleryEyebrow": "自动化模板库",
"bulkDeleteConfirm": "确定删除选中的 {count} 个自动化任务吗?此操作无法撤销。",
"templates": {
- "ai-daily": { "title": "每日 AI 资讯简报", "description": "检索过去 24 小时的重要 AI 新闻,去重并生成带来源链接的简报。", "category": "资讯与情报", "schedule": "每天 08:00" },
- "weekly-report": { "title": "每周工作复盘", "description": "汇总本周任务与交付,提炼进展、风险和下周优先事项。", "category": "效率办公", "schedule": "每周五 17:30" },
- "english-five": { "title": "每天 5 个英语单词", "description": "结合例句与小测验,生成轻量、可坚持的英语学习卡片。", "category": "学习成长", "schedule": "每天 09:00" },
- "project-risk": { "title": "项目风险巡检", "description": "检查延期事项、异常日志与待处理问题,输出风险等级和建议。", "category": "项目研发", "schedule": "工作日 10:00" },
- "knowledge-review": { "title": "知识库周回顾", "description": "整理本周新增笔记与会议纪要,补充标签并生成待消化清单。", "category": "知识管理", "schedule": "每周日 18:00" },
- "daily-idea": { "title": "每日灵感与冷知识", "description": "每天送上一条可靠、有出处的知识和一个可行动的小灵感。", "category": "轻松生活", "schedule": "每天 12:00" },
- "bedtime-story": { "title": "每日儿童睡前故事", "description": "生成一篇温暖、有启发、适合亲子共读的短篇故事。", "category": "亲子陪伴", "schedule": "每天 20:30" },
- "classic-movie": { "title": "经典电影推荐", "description": "每周推荐一部高质量经典电影,并附无剧透导读。", "category": "休闲娱乐", "schedule": "每周六 19:00" },
- "today-in-history": { "title": "历史上的今天", "description": "从科技、文化与社会领域挑选一件可靠的历史事件。", "category": "每日知识", "schedule": "每天 08:30" }
+ "ai-daily": {
+ "title": "每日 AI 资讯简报",
+ "description": "检索过去 24 小时的重要 AI 新闻,去重并生成带来源链接的简报。",
+ "category": "资讯与情报",
+ "schedule": "每天 08:00"
+ },
+ "weekly-report": {
+ "title": "每周工作复盘",
+ "description": "汇总本周任务与交付,提炼进展、风险和下周优先事项。",
+ "category": "效率办公",
+ "schedule": "每周五 17:30"
+ },
+ "english-five": {
+ "title": "每天 5 个英语单词",
+ "description": "结合例句与小测验,生成轻量、可坚持的英语学习卡片。",
+ "category": "学习成长",
+ "schedule": "每天 09:00"
+ },
+ "project-risk": {
+ "title": "项目风险巡检",
+ "description": "检查延期事项、异常日志与待处理问题,输出风险等级和建议。",
+ "category": "项目研发",
+ "schedule": "工作日 10:00"
+ },
+ "knowledge-review": {
+ "title": "知识库周回顾",
+ "description": "整理本周新增笔记与会议纪要,补充标签并生成待消化清单。",
+ "category": "知识管理",
+ "schedule": "每周日 18:00"
+ },
+ "daily-idea": {
+ "title": "每日灵感与冷知识",
+ "description": "每天送上一条可靠、有出处的知识和一个可行动的小灵感。",
+ "category": "轻松生活",
+ "schedule": "每天 12:00"
+ },
+ "bedtime-story": {
+ "title": "每日儿童睡前故事",
+ "description": "生成一篇温暖、有启发、适合亲子共读的短篇故事。",
+ "category": "亲子陪伴",
+ "schedule": "每天 20:30"
+ },
+ "classic-movie": {
+ "title": "经典电影推荐",
+ "description": "每周推荐一部高质量经典电影,并附无剧透导读。",
+ "category": "休闲娱乐",
+ "schedule": "每周六 19:00"
+ },
+ "today-in-history": {
+ "title": "历史上的今天",
+ "description": "从科技、文化与社会领域挑选一件可靠的历史事件。",
+ "category": "每日知识",
+ "schedule": "每天 08:30"
+ }
}
},
"deleteDialog": {
@@ -2759,39 +2859,39 @@
"fdChannel": "一个渠道",
"fdWebhook": "一个 webhook",
"enabled": "已启用",
- "saveSchedule": "保存定时任务"
- ,"friendlyNamePlaceholder": "例如:每天整理客户反馈"
- ,"friendlyTypeCron": "按固定时间重复"
- ,"friendlyTypeEvery": "每隔一段时间执行"
- ,"friendlyTypeAt": "只在指定时间执行一次"
- ,"executionFrequency": "执行频率"
- ,"daily": "每天"
- ,"weekdays": "每个工作日"
- ,"weekly": "每周"
- ,"monthly": "每月"
- ,"customAdvancedTime": "自定义高级时间"
- ,"weekday": "星期"
- ,"date": "日期"
- ,"monthlyDay": "每月 {day} 日"
- ,"specificTime": "具体时间"
- ,"customTimeHint": "可输入更灵活的时间规则。"
- ,"openAdvancedTime": "打开高级时间设置"
- ,"everyHowOften": "每隔多久执行"
- ,"timeUnit": "时间单位"
- ,"minutes": "分钟"
- ,"hours": "小时"
- ,"days": "天"
- ,"dateAndTime": "执行日期和时间"
- ,"friendlyMessagePlaceholder": "例如:整理今天收到的客户反馈,归纳主要问题并给出处理建议"
- ,"moreRuntimeSettings": "更多运行设置(可选)"
- ,"advancedTimeHint": "仅在上面的时间选择无法满足需求时使用。"
- ,"timezoneSimpleHint": "通常无需修改。"
- ,"projectWorkspace": "项目空间"
- ,"defaultWorkspace": "使用默认工作区"
- ,"noWorkspace": "无项目空间"
- ,"workspaceUnavailable": "{name}(不可用)"
- ,"workspaceRequiredHint": "请选择该任务要检查的项目。任务只会在所选项目中读取和处理文件。"
- ,"workspaceOptionalHint": "可选。通用任务可选择“无项目空间”;需要读取项目文件时请选择对应项目。"
+ "saveSchedule": "保存定时任务",
+ "friendlyNamePlaceholder": "例如:每天整理客户反馈",
+ "friendlyTypeCron": "按固定时间重复",
+ "friendlyTypeEvery": "每隔一段时间执行",
+ "friendlyTypeAt": "只在指定时间执行一次",
+ "executionFrequency": "执行频率",
+ "daily": "每天",
+ "weekdays": "每个工作日",
+ "weekly": "每周",
+ "monthly": "每月",
+ "customAdvancedTime": "自定义高级时间",
+ "weekday": "星期",
+ "date": "日期",
+ "monthlyDay": "每月 {day} 日",
+ "specificTime": "具体时间",
+ "customTimeHint": "可输入更灵活的时间规则。",
+ "openAdvancedTime": "打开高级时间设置",
+ "everyHowOften": "每隔多久执行",
+ "timeUnit": "时间单位",
+ "minutes": "分钟",
+ "hours": "小时",
+ "days": "天",
+ "dateAndTime": "执行日期和时间",
+ "friendlyMessagePlaceholder": "例如:整理今天收到的客户反馈,归纳主要问题并给出处理建议",
+ "moreRuntimeSettings": "更多运行设置(可选)",
+ "advancedTimeHint": "仅在上面的时间选择无法满足需求时使用。",
+ "timezoneSimpleHint": "通常无需修改。",
+ "projectWorkspace": "项目空间",
+ "defaultWorkspace": "使用默认工作区",
+ "noWorkspace": "无项目空间",
+ "workspaceUnavailable": "{name}(不可用)",
+ "workspaceRequiredHint": "请选择该任务要检查的项目。任务只会在所选项目中读取和处理文件。",
+ "workspaceOptionalHint": "可选。通用任务可选择“无项目空间”;需要读取项目文件时请选择对应项目。"
},
"form": {
"cronPreviewPlaceholder": "输入 5 字段的 cron 表达式以预览",
@@ -3430,7 +3530,14 @@
"targetLabel": "供应商 / 模型",
"lastHitLabel": "上次缓存命中 token",
"autoPauseLabel": "自动暂停",
- "states": { "off": "已关闭", "waiting": "等待稳定前缀", "scheduled": "已计划", "probing": "正在探测", "paused": "空闲超时,等待下一条消息", "stopped": "未命中或出错后已停止" }
+ "states": {
+ "off": "已关闭",
+ "waiting": "等待稳定前缀",
+ "scheduled": "已计划",
+ "probing": "正在探测",
+ "paused": "空闲超时,等待下一条消息",
+ "stopped": "未命中或出错后已停止"
+ }
},
"send": "发送",
"sendQueues": "发送(排队等待当前回复结束)",
@@ -3441,18 +3548,57 @@
"stoppingResponse": "正在安全停止… 新消息将进入队列。",
"messageToSend": "要发送的消息",
"promptAnnotations": {
- "label": "批注", "draftLabel": "待发送的页面批注", "sentLabel": "页面批注", "element": "选中的区域",
+ "label": "批注",
+ "draftLabel": "待发送的页面批注",
+ "sentLabel": "页面批注",
+ "element": "选中的区域",
"targetLabel": "{kind}:{text}",
- "targetKinds": { "heading": "标题", "button": "按钮", "link": "链接", "image": "图片", "input": "输入框", "form": "表单", "section": "区块", "list": "列表", "table": "表格", "text": "文字", "region": "区域", "element": "选中的区域" },
- "emptyDraft": "输入修改要求", "stale": "无法在当前页面定位这部分内容",
+ "targetKinds": {
+ "heading": "标题",
+ "button": "按钮",
+ "link": "链接",
+ "image": "图片",
+ "input": "输入框",
+ "form": "表单",
+ "section": "区块",
+ "list": "列表",
+ "table": "表格",
+ "text": "文字",
+ "region": "区域",
+ "element": "选中的区域"
+ },
+ "emptyDraft": "输入修改要求",
+ "stale": "无法在当前页面定位这部分内容",
"editingBlocked": "请先添加或取消正在编辑的批注,再发送消息。",
"staleBlocked": "无法在当前页面定位这部分内容,仍可直接发送给 AI。",
- "emptyBlocked": "请先完成所有交付件批注,再发送消息。", "tooLongBlocked": "交付件批注的 UTF-8 内容不能超过 16 KiB,请缩短后再发送。", "editLabel": "编辑交付件批注",
- "removeLabel": "删除交付件批注", "updateFailed": "无法保存交付件批注。",
- "discardFailed": "无法删除交付件批注。", "focusUnavailable": "无法在当前页面定位这部分内容,仍可直接发送给 AI。",
- "reuseLabel": "复制为新批注", "reuseDescription": "复制修改要求,并在页面中选择目标。", "reuseUnavailable": "无法复制这条修改要求,请打开对应页面并选择目标。", "applyPrompt": "请按照附带的页面批注完成修改。",
- "status": { "unknown": "未能更新页面", "not_attempted": "未能更新页面", "applied": "页面已更新", "appliedCorrected": "页面已更新", "not_applied": "未能更新页面", "conflict": "未能更新页面", "ambiguous": "正在确认" },
- "statusDetail": { "unknown": "请打开页面检查。", "not_attempted": "未更新页面。", "applied": "", "not_applied": "请重试。", "conflict": "请打开最新页面后重试。", "ambiguous": "重试前请先打开页面检查。" }
+ "emptyBlocked": "请先完成所有交付件批注,再发送消息。",
+ "tooLongBlocked": "交付件批注的 UTF-8 内容不能超过 16 KiB,请缩短后再发送。",
+ "editLabel": "编辑交付件批注",
+ "removeLabel": "删除交付件批注",
+ "updateFailed": "无法保存交付件批注。",
+ "discardFailed": "无法删除交付件批注。",
+ "focusUnavailable": "无法在当前页面定位这部分内容,仍可直接发送给 AI。",
+ "reuseLabel": "复制为新批注",
+ "reuseDescription": "复制修改要求,并在页面中选择目标。",
+ "reuseUnavailable": "无法复制这条修改要求,请打开对应页面并选择目标。",
+ "applyPrompt": "请按照附带的页面批注完成修改。",
+ "status": {
+ "unknown": "未能更新页面",
+ "not_attempted": "未能更新页面",
+ "applied": "页面已更新",
+ "appliedCorrected": "页面已更新",
+ "not_applied": "未能更新页面",
+ "conflict": "未能更新页面",
+ "ambiguous": "正在确认"
+ },
+ "statusDetail": {
+ "unknown": "请打开页面检查。",
+ "not_attempted": "未更新页面。",
+ "applied": "",
+ "not_applied": "请重试。",
+ "conflict": "请打开最新页面后重试。",
+ "ambiguous": "重试前请先打开页面检查。"
+ }
},
"placeholder": "发送消息……",
"placeholderCompact": "消息……",
@@ -3550,7 +3696,7 @@
"audioUnsupported": "此浏览器无法播放这种音频格式,请改为下载文件。",
"playVideo": "播放视频",
"videoLoadFailed": "无法加载视频。",
- "videoUnsupported": "此浏览器无法播放这种视频格式,请改为下载文件。",
+ "videoUnsupported": "此浏览器无法播放该视频。请改为下载。",
"previewOf": "预览:{title}",
"closePreview": "关闭预览",
"previousImage": "上一张图片",
@@ -3559,7 +3705,6 @@
"previewDownload": "预览下载",
"previewTimedOut": "预览超时。",
"previewFailed": "预览加载失败。",
- "videoUnsupported": "此浏览器无法播放该视频。请改为下载。",
"loadVideoPreview": "加载视频预览",
"loadVideoPreviewFor": "加载视频预览 {title}",
"videoPreviewLoading": "正在加载视频预览。下载过程中可以取消。",
@@ -4470,5 +4615,23 @@
"copyFailed": "无法复制命令",
"copyLabel": "复制网关重启命令"
}
+ },
+ "fileTree": {
+ "backToTasks": "返回任务",
+ "refresh": "刷新",
+ "retry": "重试",
+ "empty": "此工作空间没有可见文件。",
+ "loading": "正在加载文件…",
+ "viewFiles": "查看文件",
+ "attachToChat": "附加到对话",
+ "copyPath": "复制路径",
+ "previewTruncated": "预览仅限 1 MB",
+ "binaryNotPreviewable": "二进制文件无法预览。",
+ "attachTooLarge": "{name} 超过 1 MB,无法从文件树附加。",
+ "attachFailed": "无法附加 {name}。",
+ "attachUnsupported": "{name} 不是文本文件,无法从文件树附加。",
+ "attached": "{name} 已附加到输入框。",
+ "ctxCopy": "复制",
+ "ctxAttach": "添加到对话"
}
}
diff --git a/opensquilla-webui/src/main.ts b/opensquilla-webui/src/main.ts
index ecdfcab96..c78660e87 100644
--- a/opensquilla-webui/src/main.ts
+++ b/opensquilla-webui/src/main.ts
@@ -15,6 +15,7 @@ import { TURN_COMMANDS_KEY } from './modules/turnCommands'
import { PENDING_INPUT_QUEUE_KEY } from './modules/pendingInputQueue'
import { APPROVAL_CENTER_KEY } from './modules/approvalCenter'
import { GOAL_CENTER_KEY } from './modules/goalCenter'
+import { WORKSPACE_FILES_KEY } from './modules/workspaceFiles'
import 'katex/dist/katex.min.css'
import './assets/base.css'
import './themes/tokens' // eagerly bundles every value theme's token block
@@ -54,6 +55,7 @@ app.provide(TURN_COMMANDS_KEY, gatewayAdapters.turnCommands)
app.provide(PENDING_INPUT_QUEUE_KEY, gatewayAdapters.pendingInputQueue)
app.provide(APPROVAL_CENTER_KEY, gatewayAdapters.approvalCenter)
app.provide(GOAL_CENTER_KEY, gatewayAdapters.goalCenter)
+app.provide(WORKSPACE_FILES_KEY, gatewayAdapters.workspaceFiles)
router.afterEach(() => {
rpcStore.applyLinkTokenFromUrl()
})
diff --git a/opensquilla-webui/src/modules/workspaceFiles.ts b/opensquilla-webui/src/modules/workspaceFiles.ts
new file mode 100644
index 000000000..ef34e96c1
--- /dev/null
+++ b/opensquilla-webui/src/modules/workspaceFiles.ts
@@ -0,0 +1,66 @@
+import type { InjectionKey } from 'vue'
+
+export type WorkspaceFilesErrorKind = 'not-found' | 'invalid' | 'unavailable'
+
+/** Transport-independent error exposed by the WorkspaceFiles seam. */
+export class WorkspaceFilesError extends Error {
+ readonly name = 'WorkspaceFilesError'
+
+ constructor(
+ readonly kind: WorkspaceFilesErrorKind,
+ message: string,
+ readonly cause?: unknown,
+ ) {
+ super(message)
+ }
+}
+
+/** One visible child of a listed workspace directory. */
+export interface WorkspaceFileEntry {
+ name: string
+ path: string
+ type: 'file' | 'directory'
+ size?: number
+ mtime?: number
+}
+
+/** One bounded, gitignore-aware read of a workspace text file. */
+export interface WorkspaceFileContent {
+ path: string
+ size: number
+ binary: boolean
+ truncated: boolean
+ content: string | null
+}
+
+export interface WorkspaceFileListing {
+ path: string
+ entries: WorkspaceFileEntry[]
+}
+
+export interface WorkspaceFilesRequestOptions {
+ signal?: AbortSignal
+ timeoutMs?: number
+}
+
+/**
+ * Read-only workspace file access owned by the Gateway.
+ *
+ * Domain consumers call this seam instead of building raw HTTP requests;
+ * endpoints, auth headers, and response decoding stay inside the Gateway
+ * Adapter.
+ */
+export interface WorkspaceFiles {
+ listDir(
+ workspaceId: string,
+ path: string,
+ options?: WorkspaceFilesRequestOptions,
+ ): Promise
+ readFile(
+ workspaceId: string,
+ path: string,
+ options?: WorkspaceFilesRequestOptions,
+ ): Promise
+}
+
+export const WORKSPACE_FILES_KEY: InjectionKey = Symbol('WorkspaceFiles')
diff --git a/opensquilla-webui/src/stores/fileTree.test.ts b/opensquilla-webui/src/stores/fileTree.test.ts
new file mode 100644
index 000000000..8a08bf0a9
--- /dev/null
+++ b/opensquilla-webui/src/stores/fileTree.test.ts
@@ -0,0 +1,198 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useFileTreeStore } from './fileTree'
+import type { FileNode } from '@/lib/fileTreeModel'
+import {
+ WorkspaceFilesError,
+ type WorkspaceFileListing,
+ type WorkspaceFiles,
+} from '@/modules/workspaceFiles'
+
+const WS = { id: 'ws-1', name: 'proj', path: '/tmp/proj' }
+
+function fileNode(name: string, path: string): FileNode {
+ return { name, path, type: 'file' }
+}
+
+function dirNode(name: string, path: string): FileNode {
+ return { name, path, type: 'directory' }
+}
+
+/**
+ * Fake WorkspaceFiles seam. Routes listings by directory path (root = ''),
+ * optionally failing for specific paths.
+ */
+function createFilesPort(
+ responses: Record,
+ failPaths: string[] = [],
+) {
+ const listDir = vi.fn(
+ async (_workspaceId: string, dir: string): Promise => {
+ if (failPaths.includes(dir)) {
+ throw new WorkspaceFilesError('unavailable', 'boom')
+ }
+ return { path: dir, entries: responses[dir] ?? [] }
+ },
+ )
+ const port: WorkspaceFiles = { listDir, readFile: vi.fn() }
+ return { port, listDir }
+}
+
+const ROOT_ENTRIES: FileNode[] = [dirNode('src', 'src'), fileNode('README.md', 'README.md')]
+const SRC_ENTRIES: FileNode[] = [fileNode('a.ts', 'src/a.ts')]
+
+describe('fileTree store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('lists the root when a workspace opens and exposes flattened rows', async () => {
+ const { port, listDir } = createFilesPort({ '': ROOT_ENTRIES, src: SRC_ENTRIES })
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ await vi.waitFor(() => expect(store.ready).toBe(true))
+
+ expect(listDir).toHaveBeenCalledWith('ws-1', '')
+ expect(store.rows.map((row) => row.node.path)).toEqual(['src', 'README.md'])
+ })
+
+ it('expanding a directory fetches its children once; re-expanding reuses the cache', async () => {
+ const { port, listDir } = createFilesPort({ '': ROOT_ENTRIES, src: SRC_ENTRIES })
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ await vi.waitFor(() => expect(store.ready).toBe(true))
+ const callsAfterRoot = listDir.mock.calls.length
+
+ store.expandDir('src')
+ await vi.waitFor(() => expect(store.dirState('src')?.loaded).toBe(true))
+ expect(store.rows.map((row) => row.node.path)).toEqual(['src', 'src/a.ts', 'README.md'])
+ const callsAfterExpand = listDir.mock.calls.length
+ expect(callsAfterExpand).toBe(callsAfterRoot + 1)
+
+ store.collapseDir('src')
+ expect(store.rows.map((row) => row.node.path)).toEqual(['src', 'README.md'])
+ store.expandDir('src')
+ await vi.waitFor(() => expect(store.dirState('src')?.expanded).toBe(true))
+ expect(listDir.mock.calls.length).toBe(callsAfterExpand)
+ })
+
+ it('concurrent expand of the same directory issues a single request', async () => {
+ let release!: () => void
+ const pending = new Promise((resolve) => (release = resolve))
+ const listDir = vi.fn(async () => {
+ await pending
+ return { path: '', entries: [] } satisfies WorkspaceFileListing
+ })
+ const port: WorkspaceFiles = { listDir, readFile: vi.fn() }
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ const first = store.listDir('')
+ const second = store.listDir('')
+ release()
+ await Promise.all([first, second])
+ expect(listDir).toHaveBeenCalledTimes(1)
+ })
+
+ it('records load errors per directory and on the root', async () => {
+ const { port } = createFilesPort({}, ['', 'src'])
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ await vi.waitFor(() => expect(store.rootError).not.toBeNull())
+ expect(store.ready).toBe(false)
+
+ store.expandDir('src')
+ await vi.waitFor(() => expect(store.dirState('src')?.error).not.toBeNull())
+ expect(store.dirState('src')?.loading).toBe(false)
+ })
+
+ it('refreshDir force-refetches an already loaded directory', async () => {
+ const { port, listDir } = createFilesPort({ '': ROOT_ENTRIES })
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ await vi.waitFor(() => expect(store.ready).toBe(true))
+ const callsAfterOpen = listDir.mock.calls.length
+
+ await store.refreshDir('')
+ expect(listDir.mock.calls.length).toBe(callsAfterOpen + 1)
+ })
+
+ it('removes nodes that disappeared from a refreshed listing, with subtree cleanup', async () => {
+ let call = 0
+ const listDir = vi.fn(async (): Promise => {
+ call += 1
+ const entries =
+ call === 1
+ ? [dirNode('gone', 'gone'), fileNode('stay.txt', 'stay.txt')]
+ : [fileNode('stay.txt', 'stay.txt')]
+ return { path: '', entries }
+ })
+ const port: WorkspaceFiles = { listDir, readFile: vi.fn() }
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ await vi.waitFor(() => expect(store.ready).toBe(true))
+ expect(store.children('').map((n) => n.name)).toEqual(['gone', 'stay.txt'])
+
+ await store.refreshDir('')
+ expect(store.children('').map((n) => n.name)).toEqual(['stay.txt'])
+ })
+
+ it('a workspace switch invalidates stale in-flight listings', async () => {
+ let releaseRoot!: () => void
+ const pending = new Promise((resolve) => (releaseRoot = resolve))
+ const listDir = vi.fn(async (
+ workspaceId: string,
+ ): Promise => {
+ // Only ws-1's root listing is the slow/stale one; ws-2's root resolves
+ // immediately with empty entries.
+ if (workspaceId === 'ws-1') {
+ await pending
+ return { path: '', entries: [fileNode('stale.txt', 'stale.txt')] }
+ }
+ return { path: '', entries: [] }
+ })
+ const port: WorkspaceFiles = { listDir, readFile: vi.fn() }
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ const firstOpen = store.listDir('')
+ // Switch before the first root listing resolves.
+ store.openWorkspace({ ...WS, id: 'ws-2' })
+ releaseRoot()
+ await firstOpen.catch(() => {})
+
+ await vi.waitFor(() => expect(store.ready).toBe(true))
+ // The stale listing must not have landed in the new workspace's state.
+ expect(store.children('')).toEqual([])
+ })
+
+ it('closeWorkspace resets state', async () => {
+ const { port } = createFilesPort({ '': ROOT_ENTRIES })
+ const store = useFileTreeStore()
+ store.attachFiles(port)
+
+ store.openWorkspace(WS)
+ await vi.waitFor(() => expect(store.ready).toBe(true))
+ store.closeWorkspace()
+ expect(store.workspace).toBeNull()
+ expect(store.rows).toEqual([])
+ expect(store.ready).toBe(false)
+ })
+})
diff --git a/opensquilla-webui/src/stores/fileTree.ts b/opensquilla-webui/src/stores/fileTree.ts
new file mode 100644
index 000000000..69d04012e
--- /dev/null
+++ b/opensquilla-webui/src/stores/fileTree.ts
@@ -0,0 +1,255 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+
+import {
+ flattenLiveFileTreeModel,
+ normalizeFileTreePath,
+ type FileNode,
+ type FileTreeRow,
+} from '@/lib/fileTreeModel'
+import {
+ WorkspaceFilesError,
+ type WorkspaceFileListing,
+ type WorkspaceFiles,
+} from '@/modules/workspaceFiles'
+
+export interface FileTreeWorkspace {
+ id: string
+ name: string
+ path: string
+}
+
+interface DirectoryState {
+ expanded: boolean
+ loaded: boolean
+ loading: boolean
+ error: string | null
+ children: string[]
+}
+
+/**
+ * Lazy-loading file-tree state for the workspace sidebar view.
+ *
+ * Ported (adapted from the SolidJS createFileTreeStore) from
+ * anomalyco/opencode `packages/app/src/context/file/tree-store.ts`
+ * (MIT, Copyright (c) 2025 opencode). See THIRD_PARTY_NOTICES.md.
+ */
+export const useFileTreeStore = defineStore('fileTree', () => {
+ const workspace = ref(null)
+ const nodes = ref>({})
+ const dirs = ref>({
+ '': { expanded: true, loaded: false, loading: false, error: null, children: [] },
+ })
+ const rootLoading = ref(false)
+ const rootError = ref(null)
+
+ const inflight = new Map>()
+ let scopeSeq = 0
+ let filesPort: WorkspaceFiles | null = null
+
+ /**
+ * Attach the WorkspaceFiles seam. The consuming component resolves it from
+ * the app-level provide; the store itself stays transport-agnostic.
+ */
+ function attachFiles(port: WorkspaceFiles): void {
+ filesPort = port
+ }
+
+ function dirState(path: string): DirectoryState | undefined {
+ return dirs.value[path]
+ }
+
+ function ensureDir(path: string) {
+ if (!dirs.value[path]) {
+ dirs.value[path] = { expanded: false, loaded: false, loading: false, error: null, children: [] }
+ }
+ }
+
+ function scopeId(): number {
+ return scopeSeq
+ }
+
+ async function listDir(path: string, options?: { force?: boolean }): Promise {
+ if (!workspace.value) return
+ const dir = normalizeFileTreePath(path)
+ ensureDir(dir)
+
+ const current = dirs.value[dir]
+ if (!options?.force && current?.loaded) return
+
+ const pending = inflight.get(dir)
+ if (pending) return pending
+
+ const wsId = workspace.value.id
+ const seq = scopeId()
+ current.loading = true
+ current.error = null
+ if (dir === '') rootLoading.value = true
+
+ const promise = (async () => {
+ if (!filesPort) {
+ throw new WorkspaceFilesError('unavailable', 'WorkspaceFiles seam is not attached.')
+ }
+ const listing: WorkspaceFileListing = await filesPort.listDir(wsId, dir)
+ if (seq !== scopeId() || workspace.value?.id !== wsId) return
+
+ const nextChildren = listing.entries.map((entry) => entry.path)
+ const nextSet = new Set(nextChildren)
+
+ // Drop nodes that disappeared (files deleted/renamed away), including
+ // any subtree under a removed directory.
+ const prevChildren = dirs.value[dir]?.children ?? []
+ const removedDirs = prevChildren.filter(
+ (child) => !nextSet.has(child) && nodes.value[child]?.type === 'directory',
+ )
+ for (const key of Object.keys(nodes.value)) {
+ if (removedDirs.some((removed) => key === removed || key.startsWith(`${removed}/`))) {
+ delete nodes.value[key]
+ }
+ if (dir !== '' && key === dir) {
+ /* the directory node itself is owned by its parent listing */
+ }
+ }
+ for (const entry of listing.entries) {
+ nodes.value[entry.path] = entry
+ }
+
+ dirs.value[dir] = {
+ ...(dirs.value[dir] ?? { expanded: false }),
+ expanded: dirs.value[dir]?.expanded ?? (dir === ''),
+ loaded: true,
+ loading: false,
+ error: null,
+ children: nextChildren,
+ }
+ if (dir === '') {
+ rootLoading.value = false
+ rootError.value = null
+ }
+ })().catch((error: unknown) => {
+ if (seq !== scopeId() || workspace.value?.id !== wsId) return
+ const message = error instanceof Error ? error.message : String(error)
+ if (dirs.value[dir]) {
+ dirs.value[dir].loading = false
+ dirs.value[dir].error = message
+ }
+ if (dir === '') {
+ rootLoading.value = false
+ rootError.value = message
+ }
+ throw error
+ })
+
+ inflight.set(dir, promise)
+ try {
+ await promise
+ } finally {
+ inflight.delete(dir)
+ }
+ }
+
+ function expandDir(path: string) {
+ const dir = normalizeFileTreePath(path)
+ ensureDir(dir)
+ dirs.value[dir].expanded = true
+ void listDir(dir).catch(() => {
+ /* error state is recorded on the directory */
+ })
+ }
+
+ function collapseDir(path: string) {
+ const dir = normalizeFileTreePath(path)
+ ensureDir(dir)
+ dirs.value[dir].expanded = false
+ }
+
+ function toggleDir(path: string) {
+ if (dirState(path)?.expanded) collapseDir(path)
+ else expandDir(path)
+ }
+
+ /** Re-fetch an already-loaded directory (manual refresh button). */
+ function refreshDir(path: string) {
+ return listDir(normalizeFileTreePath(path), { force: true }).catch(() => {
+ /* error state is recorded on the directory */
+ })
+ }
+
+ /** Refresh every loaded directory (root + expanded). */
+ async function refreshAll() {
+ const loaded = Object.keys(dirs.value).filter(
+ (path) => dirs.value[path]?.loaded || path === '',
+ )
+ await Promise.allSettled(loaded.map((path) => refreshDir(path)))
+ }
+
+ function openWorkspace(next: FileTreeWorkspace) {
+ // A workspace switch invalidates all in-flight requests via the scope seq.
+ scopeSeq += 1
+ inflight.clear()
+ workspace.value = next
+ nodes.value = {}
+ dirs.value = {
+ '': { expanded: true, loaded: false, loading: false, error: null, children: [] },
+ }
+ rootLoading.value = false
+ rootError.value = null
+ void listDir('').catch(() => {
+ /* error state is recorded on the root */
+ })
+ }
+
+ function closeWorkspace() {
+ scopeSeq += 1
+ inflight.clear()
+ workspace.value = null
+ nodes.value = {}
+ dirs.value = {
+ '': { expanded: true, loaded: false, loading: false, error: null, children: [] },
+ }
+ rootLoading.value = false
+ rootError.value = null
+ }
+
+ const children = (path: string): FileNode[] => {
+ const dir = normalizeFileTreePath(path)
+ const ids = dirs.value[dir]?.children
+ if (!ids) return []
+ const out: FileNode[] = []
+ for (const id of ids) {
+ const node = nodes.value[id]
+ if (node) out.push(node)
+ }
+ return out
+ }
+
+ const rows = computed(() => {
+ if (!workspace.value) return []
+ return flattenLiveFileTreeModel(
+ (path) => children(path),
+ (path) => dirState(path)?.expanded ?? false,
+ )
+ })
+
+ const ready = computed(() => Boolean(workspace.value && !rootLoading.value && !rootError.value && (dirs.value['']?.loaded ?? false)))
+
+ return {
+ workspace,
+ rootLoading,
+ rootError,
+ rows,
+ ready,
+ dirState,
+ children,
+ openWorkspace,
+ closeWorkspace,
+ attachFiles,
+ listDir,
+ expandDir,
+ collapseDir,
+ toggleDir,
+ refreshDir,
+ refreshAll,
+ _testing: { scopeSeq: () => scopeSeq, inflight: () => inflight.size },
+ }
+})
diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue
index 0dcab91b1..21e00bc7b 100644
--- a/opensquilla-webui/src/views/ChatView.vue
+++ b/opensquilla-webui/src/views/ChatView.vue
@@ -815,6 +815,10 @@ import { useArtifactPromptAnnotationsStore } from '@/stores/artifactPromptAnnota
import { useWorkbenchDocumentContextStore } from '@/stores/workbenchDocumentContext'
import { useWorkbenchResourcesStore } from '@/stores/workbenchResources'
import { useWorkbenchStore } from '@/workbench/store'
+import {
+ WORKSPACE_FILE_ATTACH_EVENT,
+ type WorkspaceFileAttachDetail,
+} from '@/workbench/workspaceFileAttachEvent'
import { usePlatform } from '@/platform'
import { createRpcArtifactPromptAnnotationProvider } from '@/workbench/artifactPromptAnnotationProvider'
import {
@@ -875,6 +879,7 @@ import { SESSION_ROUTING_KEY, type SessionRouting } from '@/modules/sessionRouti
import { TURN_COMMANDS_KEY, type TurnCommands } from '@/modules/turnCommands'
import { APPROVAL_CENTER_KEY, type ApprovalCenter } from '@/modules/approvalCenter'
import { GOAL_CENTER_KEY, type GoalCenter } from '@/modules/goalCenter'
+import { WORKSPACE_FILES_KEY } from '@/modules/workspaceFiles'
import { useChatHistory } from '@/composables/chat/useChatHistory'
import { useChatMarkdownExport } from '@/composables/chat/useChatMarkdownExport'
import { useChatMessageActions } from '@/composables/chat/useChatMessageActions'
@@ -1198,6 +1203,9 @@ const approvalCenter: ApprovalCenter = injectedApprovalCenter
const injectedGoalCenter = inject(GOAL_CENTER_KEY)
if (!injectedGoalCenter) throw new Error('GoalCenter was not provided')
const goalCenter: GoalCenter = injectedGoalCenter
+// Read-only workspace file access is optional here: only the file-attach
+// flow needs it, and drafts without a workspace never touch it.
+const workspaceFiles = inject(WORKSPACE_FILES_KEY, null)
async function resolveCreatedSessionAvailability(sessionKey: string): Promise {
try {
@@ -6557,6 +6565,52 @@ function bindBottomIntersectionObserver() {
bottomIntersectionObserver.observe(sentinel)
}
+function onWorkspaceFileAttach(event: Event) {
+ const detail = (event as CustomEvent).detail
+ if (!detail?.workspaceId || !detail.path) return
+ void attachWorkspaceFileToComposer(detail)
+}
+
+async function attachWorkspaceFileToComposer(detail: WorkspaceFileAttachDetail) {
+ // Bounded content reads can truncate; the attachment pipeline must never
+ // silently stage a partial file.
+ const contentCap = 1 * 1024 * 1024
+ if (typeof detail.size === 'number' && detail.size > contentCap) {
+ pushToast(t('fileTree.attachTooLarge', { name: detail.name }), { tone: 'warn' })
+ return
+ }
+ // An inline payload (editor selection snippet) skips the content API —
+ // fetching would return the whole file, not the selected slice.
+ if (typeof detail.content === 'string') {
+ const snippet = new Blob([detail.content], { type: 'text/plain' })
+ if (snippet.size > contentCap) {
+ pushToast(t('fileTree.attachTooLarge', { name: detail.name }), { tone: 'warn' })
+ return
+ }
+ const file = new File([snippet], detail.name, { type: 'text/plain' })
+ await addAttachments([file])
+ pushToast(t('fileTree.attached', { name: detail.name }), { tone: 'info' })
+ return
+ }
+ if (!workspaceFiles) {
+ pushToast(t('fileTree.attachFailed', { name: detail.name }), { tone: 'warn' })
+ return
+ }
+ try {
+ const body = await workspaceFiles.readFile(detail.workspaceId, detail.path)
+ if (body.binary || body.truncated || body.content === null) {
+ pushToast(t('fileTree.attachUnsupported', { name: detail.name }), { tone: 'warn' })
+ return
+ }
+ const blob = new Blob([body.content], { type: 'text/plain' })
+ const file = new File([blob], detail.name, { type: 'text/plain' })
+ await addAttachments([file])
+ pushToast(t('fileTree.attached', { name: detail.name }), { tone: 'info' })
+ } catch {
+ pushToast(t('fileTree.attachFailed', { name: detail.name }), { tone: 'warn' })
+ }
+}
+
onMounted(async () => {
chatViewActive = true
chatViewDisposed = false
@@ -6564,6 +6618,7 @@ onMounted(async () => {
// source-less pointer marker from leaking into a later navigation gesture.
window.addEventListener('pointerup', onThreadPointerEnd)
window.addEventListener('pointercancel', onThreadPointerEnd)
+ window.addEventListener(WORKSPACE_FILE_ATTACH_EVENT, onWorkspaceFileAttach)
bindBottomIntersectionObserver()
const initialRouteFullPath = route.fullPath
const initialHistoryState = window.history.state as Record | null
@@ -6773,6 +6828,7 @@ watch(
onUnmounted(() => {
window.removeEventListener('pointerup', onThreadPointerEnd)
window.removeEventListener('pointercancel', onThreadPointerEnd)
+ window.removeEventListener(WORKSPACE_FILE_ATTACH_EVENT, onWorkspaceFileAttach)
chatRouteHeaderRegistration.release()
chatViewActive = false
appStore.setChatLivePhase('idle')
diff --git a/opensquilla-webui/src/workbench/store.test.ts b/opensquilla-webui/src/workbench/store.test.ts
index a14877522..3b59f5c35 100644
--- a/opensquilla-webui/src/workbench/store.test.ts
+++ b/opensquilla-webui/src/workbench/store.test.ts
@@ -107,6 +107,39 @@ describe('workbench store', () => {
expect(store.activeItemId).toBe('preview-new')
})
+ it('evicts workspace file panels at the same bounded limit', () => {
+ // Every keep-alive file panel holds a live Monaco editor, so the bound
+ // that caps artifact previews must cap file tabs too.
+ const store = useWorkbenchStore()
+ const disposed: string[] = []
+ store.onLifecycle(event => {
+ if (event.type === 'dispose') {
+ disposed.push(`${event.item.id}:${event.reason}`)
+ }
+ })
+ const fileItem = (id: string): WorkbenchItem => ({
+ id: `ws-file:ws-1:${id}`,
+ kind: 'file',
+ title: id,
+ scope: { type: 'workspace', id: 'ws-1' },
+ hostKind: 'dom',
+ retention: 'keep-alive',
+ payload: { workspaceId: 'ws-1', path: id },
+ })
+
+ for (let index = 0; index < WORKBENCH_PREVIEW_ITEM_LIMIT; index += 1) {
+ store.openItem(fileItem(`f${index}.md`))
+ }
+ store.activateItem('ws-file:ws-1:f0.md')
+ store.openItem(fileItem('f-new.md'))
+
+ expect(store.items).toHaveLength(WORKBENCH_PREVIEW_ITEM_LIMIT)
+ expect(store.items.some(candidate => candidate.id === 'ws-file:ws-1:f0.md')).toBe(true)
+ expect(store.items.some(candidate => candidate.id === 'ws-file:ws-1:f1.md')).toBe(false)
+ expect(disposed).toContain('ws-file:ws-1:f1.md:evicted')
+ expect(store.activeItemId).toBe('ws-file:ws-1:f-new.md')
+ })
+
it('refuses a ninth native surface without evicting a hidden item', () => {
const store = useWorkbenchStore()
const nativeItem = (id: string): WorkbenchItem => ({
diff --git a/opensquilla-webui/src/workbench/store.ts b/opensquilla-webui/src/workbench/store.ts
index 2a72ab0de..b2885c997 100644
--- a/opensquilla-webui/src/workbench/store.ts
+++ b/opensquilla-webui/src/workbench/store.ts
@@ -145,27 +145,28 @@ export const useWorkbenchStore = defineStore('workbench', () => {
}
expanded.value = true
activateItem(item.id)
- evictLeastRecentArtifactPreviews(item.id)
+ evictLeastRecentPreviews('artifact-preview', item.id)
+ evictLeastRecentPreviews('file', item.id)
return true
}
/**
- * Preview tabs are intentionally bounded. Eviction follows the same
- * activation order used when closing tabs, so a newly opened document and
- * recently inspected documents survive while stale Blob-backed previews are
- * disposed deterministically.
+ * Preview tabs are intentionally bounded — every keep-alive panel holds a
+ * live component (for file panels, a whole Monaco editor), so unbounded
+ * opens would accumulate memory. Eviction follows the same activation
+ * order used when closing tabs: the newly opened document and recently
+ * inspected tabs survive while the stale ones are disposed
+ * deterministically.
*/
- function evictLeastRecentArtifactPreviews(protectedId: string) {
- let previewCount = items.value.filter(
- candidate => candidate.kind === 'artifact-preview',
- ).length
+ function evictLeastRecentPreviews(kind: WorkbenchItem['kind'], protectedId: string) {
+ let previewCount = items.value.filter(candidate => candidate.kind === kind).length
while (previewCount > WORKBENCH_PREVIEW_ITEM_LIMIT) {
const staleId = activationOrder.find(id => {
if (id === protectedId) return false
return items.value.some(
candidate =>
candidate.id === id
- && candidate.kind === 'artifact-preview'
+ && candidate.kind === kind
&& candidate.hostKind !== 'native-webcontents',
)
})
diff --git a/opensquilla-webui/src/workbench/workspaceFileAttachEvent.ts b/opensquilla-webui/src/workbench/workspaceFileAttachEvent.ts
new file mode 100644
index 000000000..5406b7926
--- /dev/null
+++ b/opensquilla-webui/src/workbench/workspaceFileAttachEvent.ts
@@ -0,0 +1,23 @@
+export const WORKSPACE_FILE_ATTACH_EVENT = 'opensquilla:workspace-file-attach'
+
+export interface WorkspaceFileAttachDetail {
+ workspaceId: string
+ workspacePath: string
+ path: string
+ name: string
+ /** Directory-listing size in bytes; used to refuse files that would be
+ * truncated by the bounded content API before they become attachments. */
+ size?: number
+ /** Pre-fetched text (e.g. an editor selection snippet). When present the
+ * receiver stages this content directly instead of calling the content
+ * API, which would return the whole file rather than the selection. */
+ content?: string
+}
+
+export function requestWorkspaceFileAttach(detail: WorkspaceFileAttachDetail) {
+ window.dispatchEvent(
+ new CustomEvent(WORKSPACE_FILE_ATTACH_EVENT, {
+ detail,
+ }),
+ )
+}
diff --git a/opensquilla-webui/src/workbench/workspaceFileItems.ts b/opensquilla-webui/src/workbench/workspaceFileItems.ts
new file mode 100644
index 000000000..3076f58ef
--- /dev/null
+++ b/opensquilla-webui/src/workbench/workspaceFileItems.ts
@@ -0,0 +1,56 @@
+import type { WorkbenchItem } from './types'
+
+export interface WorkspaceFileRef {
+ workspaceId: string
+ workspaceName: string
+ workspacePath: string
+ path: string
+ /** Monotonic per-open counter. Every createWorkspaceFileWorkbenchItem call
+ * produces a distinct payload under the same stable id, so re-opening a
+ * file that is already open flows a changed prop into the panel and
+ * retriggers its reload watch instead of being a silent no-op. */
+ openNonce?: number
+}
+
+let openNonceCounter = 0
+
+export function workspaceFileWorkbenchItemId(
+ workspaceId: string,
+ path: string,
+): string {
+ return `ws-file:${workspaceId}:${path}`
+}
+
+export function createWorkspaceFileWorkbenchItem(
+ ref: WorkspaceFileRef,
+): WorkbenchItem {
+ return {
+ id: workspaceFileWorkbenchItemId(ref.workspaceId, ref.path),
+ kind: 'file',
+ title: ref.path.split('/').pop() || ref.path,
+ scope: { type: 'workspace', id: ref.workspaceId },
+ hostKind: 'dom',
+ retention: 'keep-alive',
+ payload: {
+ workspaceId: ref.workspaceId,
+ workspaceName: ref.workspaceName,
+ workspacePath: ref.workspacePath,
+ path: ref.path,
+ openNonce: ++openNonceCounter,
+ },
+ }
+}
+
+export function workspaceFileFromWorkbenchItem(
+ item: WorkbenchItem,
+): WorkspaceFileRef | null {
+ if (item.kind !== 'file') return null
+ const payload = item.payload as Record
+ const workspaceId = typeof payload.workspaceId === 'string' ? payload.workspaceId : ''
+ const workspaceName = typeof payload.workspaceName === 'string' ? payload.workspaceName : ''
+ const workspacePath = typeof payload.workspacePath === 'string' ? payload.workspacePath : ''
+ const path = typeof payload.path === 'string' ? payload.path : ''
+ const openNonce = typeof payload.openNonce === 'number' ? payload.openNonce : undefined
+ if (!workspaceId || !path) return null
+ return { workspaceId, workspaceName, workspacePath, path, openNonce }
+}
diff --git a/src/opensquilla/gateway/app.py b/src/opensquilla/gateway/app.py
index 57facd002..12789403d 100644
--- a/src/opensquilla/gateway/app.py
+++ b/src/opensquilla/gateway/app.py
@@ -922,6 +922,15 @@ async def ws_endpoint(ws: WebSocket) -> None:
)
set_upload_store(_upload_store)
register_upload_routes(app, config=config, store=_upload_store)
+ from opensquilla.gateway.files_api import ( # noqa: PLC0415
+ register_workspace_files_routes,
+ )
+
+ register_workspace_files_routes(
+ app,
+ config=config,
+ session_manager=session_manager,
+ )
from opensquilla.gateway.artifacts import register_artifact_routes # noqa: PLC0415
from opensquilla.gateway.attachments import register_attachment_routes # noqa: PLC0415
from opensquilla.gateway.audio_transcription import ( # noqa: PLC0415
diff --git a/src/opensquilla/gateway/files_api.py b/src/opensquilla/gateway/files_api.py
new file mode 100644
index 000000000..feebdca93
--- /dev/null
+++ b/src/opensquilla/gateway/files_api.py
@@ -0,0 +1,438 @@
+"""Workspace file browsing endpoints for the Web UI file tree.
+
+``GET /api/v1/files`` lists one directory of a *trusted project workspace*
+(non-recursive, matching the lazy-loading file tree on the client), and
+``GET /api/v1/files/content`` reads a bounded text slice for preview.
+
+Security model:
+- Only workspaces registered in the project-workspace store and already
+ trusted (``trusted_at`` set) are addressable, via
+ :func:`opensquilla.project_workspaces.resolve_validated_project_workspace`.
+ Arbitrary directories cannot be listed or read.
+- Relative paths are normalized; any path that resolves outside the
+ workspace root (``..`` segments, absolute paths, symlinks escaping the
+ root) is rejected before any I/O.
+- Dot entries (``.git``, ``.env``, ...) are excluded from listings so
+ secrets and VCS internals never surface in the tree or previews.
+- Root ``.gitignore`` rules are honored (last-match-wins, directory-suffix
+ and negation semantics — the same compact ruleset the OpenTUI completion
+ walker uses, kept self-contained here to avoid a gateway→tui import).
+- All blocking I/O runs in ``asyncio.to_thread``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import fnmatch
+import logging
+import os
+import unicodedata
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+from starlette.routing import Route
+
+from opensquilla.gateway.config import GatewayConfig
+from opensquilla.gateway.origin_guard import forbidden_origin_response, request_origin_allowed
+from opensquilla.project_workspaces import (
+ ProjectWorkspaceStateError,
+ resolve_validated_project_workspace,
+)
+
+log = logging.getLogger(__name__)
+
+DEFAULT_MAX_CONTENT_BYTES = 1 * 1024 * 1024
+MAX_CONTENT_BYTES_CAP = 4 * 1024 * 1024
+_MAX_LIST_ENTRIES = 20_000
+_BINARY_PROBE_BYTES = 8192
+
+
+class WorkspacePathError(ValueError):
+ """A relative path argument cannot be resolved inside the workspace."""
+
+
+@dataclass(frozen=True)
+class _WorkspaceRoot:
+ workspace_id: str
+ name: str
+ root: Path
+
+
+# ---------------------------------------------------------------------------
+# Path normalization / containment
+# ---------------------------------------------------------------------------
+
+
+def normalize_workspace_rel_path(raw: str | None) -> str:
+ """Normalize a client-supplied relative path to POSIX form.
+
+ Empty/None is the workspace root. Rejects absolute paths, ``..`` and
+ empty segments. Windows drive letters and backslashes are neutralized
+ (the containment check in :func:`_resolve_inside_workspace` is the
+ authoritative guard).
+ """
+ if raw is None:
+ return ""
+ value = str(raw).replace("\\", "/").strip()
+ if not value or value in {".", "./"}:
+ return ""
+ if value.startswith("/"):
+ raise WorkspacePathError("absolute path not allowed")
+ parts: list[str] = []
+ for part in value.split("/"):
+ if part in {"", "."}:
+ continue
+ if part == "..":
+ # Internal ``..`` segments collapse; escaping above the workspace
+ # root is rejected (and re-checked at resolve time).
+ if not parts:
+ raise WorkspacePathError("path traversal not allowed")
+ parts.pop()
+ continue
+ parts.append(part)
+ return "/".join(parts)
+
+
+def _resolve_inside_workspace(root: Path, rel: str) -> Path:
+ """Resolve ``root/rel`` and prove the result stays inside ``root``."""
+ target = (root / rel).resolve() if rel else root
+ if target != root and root not in target.parents:
+ raise WorkspacePathError("path escapes workspace")
+ return target
+
+
+# ---------------------------------------------------------------------------
+# .gitignore filtering (compact, self-contained — see module docstring)
+# ---------------------------------------------------------------------------
+
+
+def _load_gitignore_patterns(root: Path) -> list[tuple[str, bool]]:
+ gitignore = root / ".gitignore"
+ try:
+ lines = gitignore.read_text(encoding="utf-8", errors="ignore").splitlines()
+ except OSError:
+ return []
+ rules: list[tuple[str, bool]] = []
+ for raw_line in lines:
+ line = raw_line.strip()
+ if not line or line.startswith("#"):
+ continue
+ negated = line.startswith("!")
+ if negated:
+ line = line[1:]
+ if not line:
+ continue
+ rules.append((line.lstrip("/"), negated))
+ return rules
+
+
+def _pattern_matches(rel: str, parts: list[str], pattern: str) -> bool:
+ normalized = pattern.strip("/")
+ if not normalized:
+ return False
+ if pattern.endswith("/") and (rel == normalized or rel.startswith(normalized + "/")):
+ return True
+ if "/" in normalized:
+ return fnmatch.fnmatch(rel, normalized) or rel.startswith(normalized + "/")
+ if fnmatch.fnmatch(Path(rel).name, normalized):
+ return True
+ return any(fnmatch.fnmatch(part, normalized) for part in parts)
+
+
+def _is_ignored(rel_posix: str, rules: list[tuple[str, bool]]) -> bool:
+ # Git semantics: the LAST matching rule wins, so a later "!keep.log"
+ # re-includes a file excluded by an earlier "*.log".
+ rel = rel_posix.strip("/")
+ parts = rel.split("/") if rel else []
+ ignored = False
+ for pattern, negated in rules:
+ if _pattern_matches(rel, parts, pattern):
+ ignored = not negated
+ return ignored
+
+
+# Well-known dependency/build directories excluded even when a .gitignore
+# rule or negation would re-include them (mirrors the OpenTUI completion
+# walker's _SKIP_DIRS).
+_ALWAYS_SKIP_DIRS = frozenset({"node_modules", ".venv", "__pycache__"})
+
+
+def _entry_visible(name: str, rel: str, rules: list[tuple[str, bool]]) -> bool:
+ # Dot entries are always excluded: VCS internals and dotfile secrets
+ # (.env, credentials) must never surface in the tree or previews.
+ if name.startswith("."):
+ return False
+ if name in _ALWAYS_SKIP_DIRS:
+ return False
+ return not _is_ignored(rel, rules)
+
+
+# ---------------------------------------------------------------------------
+# Blocking filesystem work (run in a worker thread)
+# ---------------------------------------------------------------------------
+
+
+def _list_dir_blocking(root: Path, rel: str) -> dict[str, Any]:
+ target = _resolve_inside_workspace(root, rel)
+ if not target.is_dir():
+ raise FileNotFoundError(rel)
+ rules = _load_gitignore_patterns(root)
+ entries: list[dict[str, Any]] = []
+ with os.scandir(target) as it:
+ for info in it:
+ try:
+ name = info.name
+ except OSError:
+ continue
+ rel_child = f"{rel}/{name}" if rel else name
+ if not _entry_visible(name, rel_child, rules):
+ continue
+ is_dir = info.is_dir(follow_symlinks=False)
+ entry: dict[str, Any] = {
+ "name": name,
+ "path": rel_child,
+ "type": "directory" if is_dir else "file",
+ }
+ if not is_dir:
+ try:
+ stat = info.stat(follow_symlinks=False)
+ entry["size"] = stat.st_size
+ entry["mtime"] = int(stat.st_mtime * 1000)
+ except OSError:
+ pass
+ entries.append(entry)
+ if len(entries) > _MAX_LIST_ENTRIES:
+ raise OSError(f"directory has more than {_MAX_LIST_ENTRIES} entries")
+ entries.sort(key=lambda e: (e["type"] != "directory", e["name"].lower()))
+ return {"path": rel, "entries": entries}
+
+
+def _read_content_blocking(root: Path, rel: str, max_bytes: int) -> dict[str, Any]:
+ target = _resolve_inside_workspace(root, rel)
+ if not target.is_file():
+ raise FileNotFoundError(rel)
+ rules = _load_gitignore_patterns(root)
+ if not _entry_visible(target.name, rel, rules):
+ raise WorkspacePathError("path not visible")
+ size = target.stat().st_size
+ with target.open("rb") as handle:
+ raw = handle.read(max_bytes + 1)
+ truncated = len(raw) > max_bytes
+ if truncated:
+ raw = raw[:max_bytes]
+ if b"\x00" in raw[:_BINARY_PROBE_BYTES]:
+ return {
+ "path": rel,
+ "size": size,
+ "binary": True,
+ "truncated": False,
+ "content": None,
+ }
+ return {
+ "path": rel,
+ "size": size,
+ "binary": False,
+ "truncated": truncated,
+ "content": raw.decode("utf-8", errors="replace"),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Auth / workspace resolution (request side)
+# ---------------------------------------------------------------------------
+
+
+def _authorization_token_matches(config: GatewayConfig, request: Request) -> bool:
+ """Header-only Bearer token check (same posture as the upload endpoint)."""
+ header = request.headers.get("authorization", "")
+ if not header.lower().startswith("bearer "):
+ return False
+ token = header[7:].strip()
+ if token == config.auth.token:
+ return True
+ from opensquilla.gateway.desktop_ownership import (
+ active_desktop_gateway_auth_token_matches,
+ )
+
+ return active_desktop_gateway_auth_token_matches(token)
+
+
+async def _resolve_root(
+ request: Request,
+ config: GatewayConfig,
+ session_manager: Any,
+) -> _WorkspaceRoot | JSONResponse:
+ if not request_origin_allowed(request, config):
+ return forbidden_origin_response()
+ if config.auth.mode == "token":
+ if config.auth.token and not _authorization_token_matches(config, request):
+ return JSONResponse(
+ {"error": "Authorization header (Bearer …) required.", "code": "UNAUTHORIZED"},
+ status_code=401,
+ )
+ from opensquilla.gateway.session_services import get_session_storage
+
+ storage = get_session_storage(session_manager)
+ if storage is None:
+ return JSONResponse(
+ {"error": "workspace store unavailable", "code": "WORKSPACE_STORE_UNAVAILABLE"},
+ status_code=503,
+ )
+ workspace_id = str(request.query_params.get("workspace") or "").strip()
+ if not workspace_id:
+ return JSONResponse(
+ {"error": "missing 'workspace' query parameter", "code": "BAD_REQUEST"},
+ status_code=400,
+ )
+ try:
+ validated = await resolve_validated_project_workspace(storage, workspace_id)
+ except ProjectWorkspaceStateError as exc:
+ if exc.reason in {"not_found", "removed"}:
+ return JSONResponse(
+ {"error": f"workspace {workspace_id} not found", "code": "NOT_FOUND"},
+ status_code=404,
+ )
+ return JSONResponse(
+ {
+ "error": f"workspace {workspace_id} unavailable",
+ "code": "WORKSPACE_UNAVAILABLE",
+ "reason": exc.reason,
+ },
+ status_code=409,
+ )
+ root = Path(validated.canonical_path)
+ return _WorkspaceRoot(
+ workspace_id=validated.workspace.workspace_id,
+ name=validated.workspace.display_name,
+ root=root,
+ )
+
+
+def _workspace_header(root: _WorkspaceRoot) -> dict[str, Any]:
+ return {
+ "workspace": {
+ "id": root.workspace_id,
+ "name": root.name,
+ "path": unicodedata.normalize("NFC", str(root.root)),
+ }
+ }
+
+
+# ---------------------------------------------------------------------------
+# Route registration
+# ---------------------------------------------------------------------------
+
+
+def register_workspace_files_routes(
+ app: Starlette,
+ *,
+ config: GatewayConfig,
+ session_manager: Any = None,
+) -> None:
+ """Register GET /api/v1/files and GET /api/v1/files/content."""
+
+ async def list_handler(request: Request) -> JSONResponse:
+ resolved = await _resolve_root(request, config, session_manager)
+ if isinstance(resolved, JSONResponse):
+ return resolved
+ try:
+ rel = normalize_workspace_rel_path(request.query_params.get("path"))
+ except WorkspacePathError:
+ return JSONResponse(
+ {"error": "invalid path", "code": "BAD_REQUEST"}, status_code=400
+ )
+ try:
+ result = await asyncio.to_thread(_list_dir_blocking, resolved.root, rel)
+ except FileNotFoundError:
+ return JSONResponse(
+ {"error": "path not found", "code": "NOT_FOUND"}, status_code=404
+ )
+ except MemoryError:
+ # A single failed request must never take the gateway process down.
+ log.warning("workspace_files.list_memory_error path=%s", rel)
+ return JSONResponse(
+ {"error": "unable to list directory", "code": "LIST_FAILED"},
+ status_code=500,
+ )
+ except OSError as exc:
+ message = str(exc)
+ if "escapes workspace" in message or "traversal" in message:
+ return JSONResponse(
+ {"error": "invalid path", "code": "BAD_REQUEST"}, status_code=400
+ )
+ log.warning("workspace_files.list_failed", path=rel, error=message)
+ return JSONResponse(
+ {"error": "unable to list directory", "code": "LIST_FAILED"},
+ status_code=500,
+ )
+ payload = _workspace_header(resolved)
+ payload.update(result)
+ return JSONResponse(payload)
+
+ async def content_handler(request: Request) -> JSONResponse:
+ resolved = await _resolve_root(request, config, session_manager)
+ if isinstance(resolved, JSONResponse):
+ return resolved
+ try:
+ rel = normalize_workspace_rel_path(request.query_params.get("path"))
+ except WorkspacePathError:
+ return JSONResponse(
+ {"error": "invalid path", "code": "BAD_REQUEST"}, status_code=400
+ )
+ if not rel:
+ return JSONResponse(
+ {"error": "a file path is required", "code": "BAD_REQUEST"}, status_code=400
+ )
+ raw_max = request.query_params.get("max_bytes")
+ max_bytes = DEFAULT_MAX_CONTENT_BYTES
+ if raw_max is not None and raw_max.strip().isdigit():
+ max_bytes = min(int(raw_max), MAX_CONTENT_BYTES_CAP)
+ if max_bytes <= 0:
+ max_bytes = DEFAULT_MAX_CONTENT_BYTES
+ try:
+ result = await asyncio.to_thread(
+ _read_content_blocking, resolved.root, rel, max_bytes
+ )
+ except FileNotFoundError:
+ return JSONResponse(
+ {"error": "path not found", "code": "NOT_FOUND"}, status_code=404
+ )
+ except WorkspacePathError:
+ return JSONResponse(
+ {"error": "invalid path", "code": "BAD_REQUEST"}, status_code=400
+ )
+ except MemoryError:
+ # Under host memory pressure even the bounded 1MB read can fail;
+ # answer 500 instead of letting the exception kill the process.
+ log.warning("workspace_files.read_memory_error path=%s", rel)
+ return JSONResponse(
+ {"error": "unable to read file", "code": "READ_FAILED"}, status_code=500
+ )
+ except OSError as exc:
+ log.warning("workspace_files.read_failed", path=rel, error=str(exc))
+ return JSONResponse(
+ {"error": "unable to read file", "code": "READ_FAILED"}, status_code=500
+ )
+ payload = _workspace_header(resolved)
+ payload.update(result)
+ return JSONResponse(payload)
+
+ app.router.routes.append(
+ Route("/api/v1/files", list_handler, methods=["GET"])
+ )
+ app.router.routes.append(
+ Route("/api/v1/files/content", content_handler, methods=["GET"])
+ )
+
+
+__all__ = [
+ "DEFAULT_MAX_CONTENT_BYTES",
+ "MAX_CONTENT_BYTES_CAP",
+ "WorkspacePathError",
+ "normalize_workspace_rel_path",
+ "register_workspace_files_routes",
+]
diff --git a/tests/test_gateway/test_workspace_files_api.py b/tests/test_gateway/test_workspace_files_api.py
new file mode 100644
index 000000000..d51f3cda4
--- /dev/null
+++ b/tests/test_gateway/test_workspace_files_api.py
@@ -0,0 +1,365 @@
+"""Tests for the workspace file browsing endpoints (GET /api/v1/files*).
+
+Covers: path normalization (traversal, absolute, dot-segments), workspace
+resolution errors (missing/untrusted/removed), dot-entry and .gitignore
+filtering, symlink escape rejection, content read (truncation, binary,
+max_bytes cap), and token auth.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Unit tests: path normalization (no app needed)
+# ---------------------------------------------------------------------------
+from opensquilla.gateway.files_api import ( # noqa: E402
+ WorkspacePathError,
+ normalize_workspace_rel_path,
+)
+
+
+def test_normalize_empty_is_root() -> None:
+ assert normalize_workspace_rel_path(None) == ""
+ assert normalize_workspace_rel_path("") == ""
+ assert normalize_workspace_rel_path(".") == ""
+ assert normalize_workspace_rel_path("./") == ""
+
+
+def test_normalize_collapses_dots_and_windows_separators() -> None:
+ assert normalize_workspace_rel_path("src/./lib/../lib/a.ts") == "src/lib/a.ts"
+ assert normalize_workspace_rel_path("src\\lib\\a.ts") == "src/lib/a.ts"
+ assert normalize_workspace_rel_path("src//lib//a.ts") == "src/lib/a.ts"
+
+
+def test_normalize_internal_dotdot_collapses() -> None:
+ # Internal .. segments are legal and collapse; only escapes are rejected.
+ assert normalize_workspace_rel_path("a/../b") == "b"
+ assert normalize_workspace_rel_path("src/../lib/a.ts") == "lib/a.ts"
+
+
+def test_normalize_rejects_traversal_and_absolute() -> None:
+ with pytest.raises(WorkspacePathError):
+ normalize_workspace_rel_path("..")
+ with pytest.raises(WorkspacePathError):
+ normalize_workspace_rel_path("/etc/passwd")
+ with pytest.raises(WorkspacePathError):
+ normalize_workspace_rel_path("src/../../x")
+
+
+# ---------------------------------------------------------------------------
+# HTTP tests
+# ---------------------------------------------------------------------------
+
+
+class _FakeWorkspaceStorage:
+ """Minimal stand-in for session storage's project-workspace access."""
+
+ def __init__(self, workspaces: dict[str, Any]) -> None:
+ self._workspaces = workspaces
+
+ async def get_project_workspace(self, workspace_id: str) -> Any:
+ return self._workspaces.get(workspace_id)
+
+
+class _FakeSessionManager:
+ """Wraps the storage the way a real manager exposes it (``.storage``)."""
+
+ def __init__(self, storage: _FakeWorkspaceStorage) -> None:
+ self.storage = storage
+
+
+class _FakeWorkspace:
+ def __init__(self, workspace_id: str, path: str, path_key: str) -> None:
+ self.workspace_id = workspace_id
+ self.path = path
+ self.path_key = path_key
+ self.display_name = Path(path).name
+ self.removed_at = None
+ self.trusted_at = 1
+
+ removed_at: int | None
+ trusted_at: int | None
+
+
+def _seed_workspace(tmp_path: Path) -> tuple[str, Path, str]:
+ ws = tmp_path / "proj"
+ ws.mkdir()
+ (ws / "src").mkdir()
+ (ws / "src" / "lib").mkdir()
+ (ws / "src" / "lib" / "b.ts").write_text("const b = 1\n", encoding="utf-8")
+ (ws / "src" / "z.ts").write_text("const z = 1\n", encoding="utf-8")
+ (ws / "README.md").write_text("# proj\n", encoding="utf-8")
+ (ws / ".env").write_text("SECRET=1\n", encoding="utf-8")
+ (ws / "notes.log").write_text("log line\n", encoding="utf-8")
+ (ws / ".gitignore").write_text("*.log\n!keep.log\n", encoding="utf-8")
+ (ws / "keep.log").write_text("kept\n", encoding="utf-8")
+ (ws / "node_modules").mkdir()
+ (ws / "node_modules" / "pkg.js").write_text("x\n", encoding="utf-8")
+ # Hidden nested file that only shows if dot dirs were listed.
+ (ws / ".git").mkdir()
+ (ws / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
+ ws_id = "ws-test-1"
+ # Must match what resolve_validated_project_workspace recomputes,
+ # otherwise the canonical_changed check rejects the workspace.
+ from opensquilla.project_workspaces import project_path_key
+
+ path_key = project_path_key(ws, strict=True)
+ return ws_id, ws, path_key
+
+
+def _app_client(tmp_path: Path, config: Any | None = None, storage: Any | None = None):
+ pytest.importorskip("starlette.testclient")
+ from starlette.applications import Starlette
+ from starlette.testclient import TestClient
+
+ from opensquilla.gateway.config import GatewayConfig
+ from opensquilla.gateway.files_api import register_workspace_files_routes
+
+ ws_id, ws, path_key = _seed_workspace(tmp_path)
+ storage = storage or _FakeWorkspaceStorage(
+ {ws_id: _FakeWorkspace(ws_id, str(ws), path_key)}
+ )
+ app = Starlette(debug=False)
+ register_workspace_files_routes(
+ app, config=config or GatewayConfig(), session_manager=_FakeSessionManager(storage)
+ )
+ return TestClient(app), ws_id
+
+
+def test_list_root_excludes_dots_and_honors_gitignore(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(f"/api/v1/files?workspace={ws_id}")
+ assert response.status_code == 200
+ body = response.json()
+ names = [e["name"] for e in body["entries"]]
+ # Directories sort before files; dot entries never appear.
+ assert ".env" not in names
+ assert ".git" not in names
+ # node_modules is a dot entry: excluded. notes.log ignored; keep.log re-included.
+ assert "node_modules" not in names
+ assert "notes.log" not in names
+ assert "keep.log" in names
+ dirs = [e["name"] for e in body["entries"] if e["type"] == "directory"]
+ files = [e["name"] for e in body["entries"] if e["type"] == "file"]
+ assert dirs == ["src"]
+ # Case-insensitive name sort: keep.log < README.md.
+ assert files == ["keep.log", "README.md"]
+ assert body["workspace"]["id"] == ws_id
+
+
+def test_list_nested_dir(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(f"/api/v1/files?workspace={ws_id}&path=src/lib")
+ assert response.status_code == 200
+ names = [e["name"] for e in response.json()["entries"]]
+ assert names == ["b.ts"]
+
+
+def test_list_rejects_traversal(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ for bad in ("..", "src/../../..", "/etc"):
+ response = client.get(f"/api/v1/files?workspace={ws_id}&path={bad}")
+ assert response.status_code == 400, (bad, response.status_code)
+
+
+def test_list_missing_workspace_404(tmp_path: Path) -> None:
+ client, _ = _app_client(tmp_path)
+ response = client.get("/api/v1/files?workspace=nope")
+ assert response.status_code == 404
+ assert response.json()["code"] == "NOT_FOUND"
+
+
+def test_list_missing_path_404(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(f"/api/v1/files?workspace={ws_id}&path=does-not-exist")
+ assert response.status_code == 404
+
+
+def test_list_requires_workspace_param(tmp_path: Path) -> None:
+ client, _ = _app_client(tmp_path)
+ response = client.get("/api/v1/files")
+ assert response.status_code == 400
+
+
+def test_content_reads_text(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(
+ f"/api/v1/files/content?workspace={ws_id}&path=src/lib/b.ts"
+ )
+ assert response.status_code == 200
+ body = response.json()
+ # Read back exactly what the filesystem holds (git autocrlf may have
+ # rewritten the seeded newline to \r\n on Windows; read_bytes keeps
+ # the raw line endings, unlike read_text's universal-newline rewrite).
+ expected = (tmp_path / "proj" / "src" / "lib" / "b.ts").read_bytes().decode(
+ "utf-8"
+ )
+ assert body["content"] == expected
+ assert body["content"].startswith("const b = 1")
+ assert body["binary"] is False
+ assert body["truncated"] is False
+
+
+def test_content_truncates_at_max_bytes(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(
+ f"/api/v1/files/content?workspace={ws_id}&path=README.md&max_bytes=3"
+ )
+ assert response.status_code == 200
+ body = response.json()
+ assert body["content"] == "# p"
+ assert body["truncated"] is True
+ assert body["size"] == (tmp_path / "proj" / "README.md").stat().st_size
+
+
+def test_content_rejects_oversized_max_bytes(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(
+ f"/api/v1/files/content?workspace={ws_id}&path=README.md&max_bytes=99999999"
+ )
+ assert response.status_code == 200
+ assert response.json()["truncated"] is False
+
+
+def test_content_rejects_binary(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ (tmp_path / "proj" / "blob.bin").write_bytes(b"\x00\x01\x02PNG")
+ response = client.get(
+ f"/api/v1/files/content?workspace={ws_id}&path=blob.bin"
+ )
+ assert response.status_code == 200
+ body = response.json()
+ assert body["binary"] is True
+ assert body["content"] is None
+
+
+def test_content_rejects_dotfile(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(
+ f"/api/v1/files/content?workspace={ws_id}&path=.env"
+ )
+ assert response.status_code == 400
+
+
+def test_content_rejects_directory(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ response = client.get(f"/api/v1/files/content?workspace={ws_id}&path=src")
+ assert response.status_code == 404
+
+
+def test_symlink_escape_rejected(tmp_path: Path) -> None:
+ client, ws_id = _app_client(tmp_path)
+ secret = tmp_path / "secret.txt"
+ secret.write_text("top secret\n", encoding="utf-8")
+ link = tmp_path / "proj" / "sneaky"
+ os.symlink(secret, link)
+ # Reading through the escaping symlink is rejected (400, not 200 with
+ # the secret bytes).
+ response = client.get(f"/api/v1/files/content?workspace={ws_id}&path=sneaky")
+ assert response.status_code == 400
+ # The entry itself may appear in the listing (a symlink is just an
+ # entry); what matters is that its target is never served.
+ listing = client.get(f"/api/v1/files?workspace={ws_id}").json()
+ body = response.json()
+ assert "top secret" not in str(body.get("content", ""))
+ assert isinstance(listing.get("entries"), list)
+
+
+def test_untrusted_workspace_409(tmp_path: Path) -> None:
+ pytest.importorskip("starlette.testclient")
+ from starlette.applications import Starlette
+ from starlette.testclient import TestClient
+
+ from opensquilla.gateway.config import GatewayConfig
+ from opensquilla.gateway.files_api import register_workspace_files_routes
+
+ ws_id, ws, path_key = _seed_workspace(tmp_path)
+ untrusted = _FakeWorkspace(ws_id, str(ws), path_key)
+ untrusted.trusted_at = None
+ app = Starlette(debug=False)
+ register_workspace_files_routes(
+ app,
+ config=GatewayConfig(),
+ session_manager=_FakeSessionManager(
+ _FakeWorkspaceStorage({ws_id: untrusted})
+ ),
+ )
+ with TestClient(app) as client:
+ response = client.get(f"/api/v1/files?workspace={ws_id}")
+ assert response.status_code == 409
+ assert response.json()["code"] == "WORKSPACE_UNAVAILABLE"
+
+
+def test_token_auth_enforced(tmp_path: Path) -> None:
+ pytest.importorskip("starlette.testclient")
+ from starlette.applications import Starlette
+ from starlette.testclient import TestClient
+
+ from opensquilla.gateway.config import GatewayConfig
+ from opensquilla.gateway.files_api import register_workspace_files_routes
+
+ ws_id, ws, path_key = _seed_workspace(tmp_path)
+ config = GatewayConfig()
+ config.auth.mode = "token"
+ config.auth.token = "sekret"
+ app = Starlette(debug=False)
+ register_workspace_files_routes(
+ app,
+ config=config,
+ session_manager=_FakeSessionManager(
+ _FakeWorkspaceStorage({ws_id: _FakeWorkspace(ws_id, str(ws), path_key)})
+ ),
+ )
+ with TestClient(app) as client:
+ anon = client.get(f"/api/v1/files?workspace={ws_id}")
+ wrong = client.get(
+ f"/api/v1/files?workspace={ws_id}",
+ headers={"Authorization": "Bearer wrong"},
+ )
+ right = client.get(
+ f"/api/v1/files?workspace={ws_id}",
+ headers={"Authorization": "Bearer sekret"},
+ )
+ assert anon.status_code == 401
+ assert wrong.status_code == 401
+ assert right.status_code == 200
+
+
+def test_content_memory_error_returns_500_not_crash(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A MemoryError inside the read must map to a 500, not kill the server."""
+ client, ws_id = _app_client(tmp_path)
+
+ def _boom(root: Any, rel: str, max_bytes: int) -> Any:
+ raise MemoryError
+
+ monkeypatch.setattr(
+ "opensquilla.gateway.files_api._read_content_blocking", _boom
+ )
+ response = client.get(
+ f"/api/v1/files/content?workspace={ws_id}&path=README.md"
+ )
+ assert response.status_code == 500
+ assert response.json()["code"] == "READ_FAILED"
+
+
+def test_list_memory_error_returns_500_not_crash(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ client, ws_id = _app_client(tmp_path)
+
+ def _boom(root: Any, rel: str) -> Any:
+ raise MemoryError
+
+ monkeypatch.setattr(
+ "opensquilla.gateway.files_api._list_dir_blocking", _boom
+ )
+ response = client.get(f"/api/v1/files?workspace={ws_id}")
+ assert response.status_code == 500
+ assert response.json()["code"] == "LIST_FAILED"