mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 07:34:58 +00:00
Merge pull request #2128 from kaaloo/letta/surfsource-pdf-preview-591ee6ed
Some checks are pending
Build and Push Docker Images / image_version (push) Waiting to run
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-24.04-arm, linux/arm64, arm64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-latest, linux/amd64, amd64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (sandbox, surfsense-sandbox, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Some checks are pending
Build and Push Docker Images / image_version (push) Waiting to run
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-24.04-arm, linux/arm64, arm64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-latest, linux/amd64, amd64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (sandbox, surfsense-sandbox, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
feat(desktop): preview PDF sources in-app
This commit is contained in:
commit
cddfd05f94
29 changed files with 858 additions and 15 deletions
|
|
@ -78,7 +78,7 @@ No filename is stored in a row: the document's directory is the record. The orig
|
|||
|
||||
`GET .../original` serves the stored bytes with the document's title as the filename and the stored MIME type, always as an attachment: an HTML or SVG file rendered inline would run its script against the app's origin. It answers `404` when there is no file behind the document, as for a note.
|
||||
|
||||
The desktop app opens originals natively instead. Through the preload bridge, Electron applies the same rule to the directory under the data directory for the given ids, since the main process never calls the backend, and hands it to the operating system, or reveals it in the file manager ([`document-files.ts`](../../surfsense_local/electron/src/main/document-files.ts)).
|
||||
The desktop app previews an original in its left rail when its MIME type has a source viewer, starting with PDF. Other file types still open natively: through the preload bridge, Electron applies the same rule to the directory under the data directory for the given ids, since the main process never calls the backend, and hands it to the operating system, or reveals it in the file manager ([`document-files.ts`](../../surfsense_local/electron/src/main/document-files.ts)). The `DocumentRead` row carries `mime_type` from stored metadata only for `FILE`; notes always report `null`, even if their user-provided metadata contains a `mime_type`. The preview fetches the existing `/original` route, which remains an attachment so directly navigating to HTML or SVG cannot execute it on the app's origin.
|
||||
|
||||
## Ingest pipeline
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ The rules that keep the processes out of each other's way:
|
|||
|
||||
- A Vite and React SPA with Tailwind and shadcn/ui components in `components/ui/`, built on Base UI in shadcn's `base-nova` style; assistant-ui drives the conversation, and still depends on Radix. Packaged, Electron loads `frontend/dist/index.html` from disk, which is why Vite builds with relative asset paths. In dev it loads the Vite server on port 5173.
|
||||
- The API's address comes from the preload: Electron passes it as a command-line argument and [`preload/index.ts`](../../surfsense_local/electron/src/preload/index.ts) exposes it as `window.surfsense.apiUrl`. In a bare browser there is no preload, requests stay root-relative, and the Vite dev server forwards `/health`, `/llm`, `/workspaces`, `/chat` and `/artifacts` to `127.0.0.1:8000`.
|
||||
- The preload bridge is the renderer's only other channel: opening or revealing an original file, opening an external link, the platform name, updates, theme, the title bar, this session's log and unexpected sidecar-exit notices. The renderer never sees Node or the sidecars directly.
|
||||
- The preload bridge is the renderer's only other channel: opening or revealing an original file natively, opening an external link, the platform name, updates, theme, the title bar, this session's log and unexpected sidecar-exit notices. The renderer never sees Node or the sidecars directly. A source with a registered viewer (PDF first) is fetched from the API and previewed in the left rail; other file types keep the native open action ([`documents.md`](documents.md)).
|
||||
- API calls go through `request()` in [`lib/api.ts`](../../surfsense_local/frontend/src/lib/api.ts), except the Studio viewers, which fetch an artifact's file bytes directly; an `<img>`, `<audio>` or download link takes its absolute address from `apiUrl()`. `request()` prefixes the address, turns an error body into an `ApiError` with its `code`, and when a user action is refused with `403 egress_disabled` it asks for consent and retries once.
|
||||
- TanStack Query holds most server state: threads and messages, the citation panel, the model catalog and settings, an open artifact. The sources list and the Studio artifact list are component state refreshed by the poll above.
|
||||
- There is no router. [`app/app-bootstrap.tsx`](../../surfsense_local/frontend/src/app/app-bootstrap.tsx) asks `GET /llm/onboarding`, then renders either onboarding ([`local-models/selection.md`](local-models/selection.md)) or the lazily loaded dashboard: the workspace rail, threads and sources on the left, the conversation in the middle, and resource usage, Studio, artifacts and the citation panel on the right.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
status: proposed
|
||||
status: in-progress
|
||||
tracking: https://github.com/MODSetter/SurfSense/issues/2077
|
||||
code:
|
||||
- surfsense_local/backend/modules/documents/schemas.py
|
||||
|
|
|
|||
|
|
@ -85,15 +85,16 @@ function readCatalog(locale) {
|
|||
const catalogs = Object.fromEntries(LOCALES.map((l) => [l, readCatalog(l)]))
|
||||
|
||||
const features = new Set([
|
||||
...readdirSync(FEATURES).filter((name) =>
|
||||
statSync(join(FEATURES, name)).isDirectory()
|
||||
),
|
||||
...readdirSync(FEATURES)
|
||||
.filter((name) => statSync(join(FEATURES, name)).isDirectory())
|
||||
.map((name) => name.replaceAll("-", "_")),
|
||||
...EXTRA_PREFIXES,
|
||||
])
|
||||
|
||||
// A deleted feature folder leaves its ids behind with a prefix nothing owns.
|
||||
// Match the whole folder prefix: feature names may contain underscores.
|
||||
for (const key of Object.keys(catalogs.en)) {
|
||||
if (!features.has(key.split("_")[0])) {
|
||||
if (![...features].some((feature) => key.startsWith(`${feature}_`))) {
|
||||
fail("translations/en.json", `${key} does not start with a feature folder or app`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,3 +67,10 @@ class Document(Base):
|
|||
artifact: Mapped["Artifact | None"] = relationship(
|
||||
back_populates="document", cascade="all, delete-orphan", passive_deletes=True
|
||||
)
|
||||
|
||||
@property
|
||||
def mime_type(self) -> str | None:
|
||||
if self.document_type is not DocumentType.FILE:
|
||||
return None
|
||||
mime_type = (self.document_metadata or {}).get("mime_type")
|
||||
return mime_type if isinstance(mime_type, str) else None
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class DocumentRead(BaseModel):
|
|||
id: int
|
||||
title: str
|
||||
document_type: DocumentType
|
||||
mime_type: str | None
|
||||
status: DocumentStatus
|
||||
error_message: str | None
|
||||
created_at: datetime
|
||||
|
|
|
|||
|
|
@ -115,6 +115,27 @@ async def test_a_note_written_without_document_metadata_has_none(
|
|||
assert read.json()["document_metadata"] is None
|
||||
|
||||
|
||||
async def test_document_rows_expose_file_mime_type_but_not_note_metadata(
|
||||
client: AsyncClient, workspace_id: int
|
||||
) -> None:
|
||||
"""Only a FILE's server-validated MIME type may select its viewer."""
|
||||
uploaded = await client.post(
|
||||
f"/workspaces/{workspace_id}/documents/upload",
|
||||
files={"files": ("report.pdf", b"%PDF-1.7", "application/pdf")},
|
||||
)
|
||||
note = await client.post(
|
||||
f"/workspaces/{workspace_id}/documents",
|
||||
json={
|
||||
"title": "Note",
|
||||
"content": "text",
|
||||
"document_metadata": {"mime_type": "application/pdf"},
|
||||
},
|
||||
)
|
||||
|
||||
assert uploaded.json()["created"][0]["mime_type"] == "application/pdf"
|
||||
assert note.json()["mime_type"] is None
|
||||
|
||||
|
||||
async def test_editing_a_note_keeps_its_document_metadata(
|
||||
client: AsyncClient, workspace_id: int
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
readSourcePreview,
|
||||
SOURCE_PREVIEW_KEY,
|
||||
writeSourcePreview,
|
||||
} from "./chrome-prefs"
|
||||
|
||||
afterEach(() => localStorage.clear())
|
||||
|
||||
describe("source preview preferences", () => {
|
||||
it("remembers a source per workspace and clears only the closed workspace", () => {
|
||||
writeSourcePreview(1, 12)
|
||||
writeSourcePreview(2, 24)
|
||||
|
||||
expect(readSourcePreview(1)).toBe(12)
|
||||
expect(readSourcePreview(2)).toBe(24)
|
||||
writeSourcePreview(1, null)
|
||||
expect(readSourcePreview(1)).toBeNull()
|
||||
expect(readSourcePreview(2)).toBe(24)
|
||||
})
|
||||
|
||||
it("ignores invalid persisted values", () => {
|
||||
localStorage.setItem(SOURCE_PREVIEW_KEY, '{"1":"12","2":-1}')
|
||||
|
||||
expect(readSourcePreview(1)).toBeNull()
|
||||
expect(readSourcePreview(2)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,42 @@
|
|||
export const RIGHT_PANEL_KEY = "surfsense:right-panel:v1"
|
||||
export const SOURCE_PREVIEW_KEY = "surfsense:source-preview:v1"
|
||||
|
||||
function readSourcePreviews(): Record<string, number> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
localStorage.getItem(SOURCE_PREVIEW_KEY) ?? "{}"
|
||||
)
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([, documentId]) =>
|
||||
Number.isInteger(documentId) && (documentId as number) > 0
|
||||
)
|
||||
) as Record<string, number>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function readSourcePreview(workspaceId: number): number | null {
|
||||
return readSourcePreviews()[String(workspaceId)] ?? null
|
||||
}
|
||||
|
||||
export function writeSourcePreview(
|
||||
workspaceId: number,
|
||||
documentId: number | null
|
||||
) {
|
||||
const previews = readSourcePreviews()
|
||||
if (documentId === null) delete previews[String(workspaceId)]
|
||||
else previews[String(workspaceId)] = documentId
|
||||
try {
|
||||
localStorage.setItem(SOURCE_PREVIEW_KEY, JSON.stringify(previews))
|
||||
} catch {
|
||||
// Private browsing and full disks throw.
|
||||
}
|
||||
}
|
||||
|
||||
export function readRightPanelOpen() {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { DETAIL_RAIL_WIDTH, MAIN_RAIL_WIDTH } from "@/components/ui/slide-rail"
|
|||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { render } from "@/test-utils"
|
||||
|
||||
import { RIGHT_PANEL_KEY } from "./chrome-prefs"
|
||||
import { readSourcePreview, RIGHT_PANEL_KEY } from "./chrome-prefs"
|
||||
import { DashboardPage } from "./dashboard-page"
|
||||
|
||||
const workspace = {
|
||||
|
|
@ -708,7 +708,8 @@ describe("dashboard chat", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: "View cited chunk 30" }))
|
||||
expect(await screen.findByText("indexed passage")).toBeTruthy()
|
||||
expect(screen.getByText("Cited chunk")).toBeTruthy()
|
||||
const rail = document.querySelector("[data-slot=slide-rail]")
|
||||
const rail = document.querySelector("#workspace-right-panel")?.parentElement
|
||||
?.parentElement
|
||||
expect(rail).toBeInstanceOf(HTMLElement)
|
||||
expect((rail as HTMLElement).style.width).toBe(`${DETAIL_RAIL_WIDTH}px`)
|
||||
await user.click(screen.getByRole("button", { name: "Open file" }))
|
||||
|
|
@ -806,6 +807,79 @@ describe("dashboard chat", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("previews a PDF in a left rail and restores the right panel preference", async () => {
|
||||
const pdf = {
|
||||
id: 42,
|
||||
title: "report.pdf",
|
||||
document_type: "FILE",
|
||||
mime_type: "application/pdf",
|
||||
status: "pending",
|
||||
error_message: null,
|
||||
created_at: "2026-09-05T00:00:00Z",
|
||||
updated_at: "2026-09-05T00:00:00Z",
|
||||
}
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path = String(input)
|
||||
if (path === "/llm/providers") {
|
||||
return Response.json([
|
||||
{ name: "llamacpp", healthy: true, can_download: true },
|
||||
])
|
||||
}
|
||||
if (
|
||||
path === "/workspaces/1/documents?document_type=FILE&document_type=NOTE"
|
||||
) {
|
||||
return Response.json([pdf])
|
||||
}
|
||||
if (path === "/workspaces/1/chat/threads") return Response.json([])
|
||||
if (path === "/workspaces/1/documents/42/original") {
|
||||
return new Response(new Uint8Array([37, 80, 68, 70, 45, 49, 46, 55]))
|
||||
}
|
||||
return Response.json({ detail: "not found" }, { status: 404 })
|
||||
})
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
localStorage.setItem(RIGHT_PANEL_KEY, "open")
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DashboardPage
|
||||
initialProviderAvailable={true}
|
||||
selection={{
|
||||
model_type: "text_gen",
|
||||
provider: "llamacpp",
|
||||
connection_id: null,
|
||||
name: "llama3.2:1b",
|
||||
updated_at: "2026-09-05T00:00:00Z",
|
||||
}}
|
||||
initialWorkspaces={[workspace]}
|
||||
onModelSelected={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "report.pdf" }))
|
||||
expect(
|
||||
await screen.findByRole("complementary", { name: "Source preview" })
|
||||
).toBeTruthy()
|
||||
const rightRail = document.querySelector("#workspace-right-panel")
|
||||
?.parentElement?.parentElement as HTMLElement
|
||||
expect(rightRail.style.width).toBe("0px")
|
||||
expect(readSourcePreview(workspace.id)).toBe(42)
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/workspaces/1/documents/42/original",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Close source preview" })
|
||||
)
|
||||
expect(readSourcePreview(workspace.id)).toBeNull()
|
||||
expect(rightRail.style.width).toBe(`${MAIN_RAIL_WIDTH}px`)
|
||||
expect(localStorage.getItem(RIGHT_PANEL_KEY)).toBe("open")
|
||||
})
|
||||
|
||||
it("surfaces a message request failure inside the conversation", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
|
@ -1321,7 +1395,8 @@ describe("dashboard chat", () => {
|
|||
await user.click(weeklySummary)
|
||||
expect(await screen.findByText("Saturn is a gas giant.")).toBeTruthy()
|
||||
expect(screen.getByRole("complementary", { name: "Artifact" })).toBeTruthy()
|
||||
const rail = document.querySelector("[data-slot=slide-rail]")
|
||||
const rail = document.querySelector("#workspace-right-panel")?.parentElement
|
||||
?.parentElement
|
||||
expect((rail as HTMLElement).style.width).toBe(`${DETAIL_RAIL_WIDTH}px`)
|
||||
await user.click(screen.getByRole("button", { name: "Close artifact" }))
|
||||
expect((rail as HTMLElement).style.width).toBe(`${MAIN_RAIL_WIDTH}px`)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ import {
|
|||
SourcesPanel,
|
||||
} from "@/features/sources/sources-panel"
|
||||
import { useSources } from "@/features/sources/use-sources"
|
||||
import { getFileViewer } from "@/features/file-viewers/registry"
|
||||
import { SourcePreviewPanel } from "@/features/source-preview/source-preview-panel"
|
||||
import { ArtifactList } from "@/features/studio/artifact-list"
|
||||
import { ArtifactPanel } from "@/features/studio/artifact-panel"
|
||||
import { StudioPanel } from "@/features/studio/studio-panel"
|
||||
|
|
@ -57,7 +59,12 @@ import type { Workspace } from "@/features/workspaces/api"
|
|||
import { useWorkspaces } from "@/features/workspaces/use-workspaces"
|
||||
import { intl } from "@/i18n/intl"
|
||||
import { WorkspaceRail } from "@/features/workspaces/workspace-rail"
|
||||
import { readRightPanelOpen, writeRightPanelOpen } from "./chrome-prefs"
|
||||
import {
|
||||
readRightPanelOpen,
|
||||
readSourcePreview,
|
||||
writeRightPanelOpen,
|
||||
writeSourcePreview,
|
||||
} from "./chrome-prefs"
|
||||
import { LeftSidebar } from "./left-sidebar"
|
||||
import { RightPanel } from "./right-panel"
|
||||
import { SidebarFooter } from "./sidebar-footer"
|
||||
|
|
@ -101,6 +108,9 @@ function WorkspaceDashboard({
|
|||
}) {
|
||||
const [inspect, setInspect] = useState<Inspect>(null)
|
||||
const [rightPanelOpen, setRightPanelOpen] = useState(readRightPanelOpen)
|
||||
const [sourcePreviewId, setSourcePreviewId] = useState<number | null>(() =>
|
||||
readSourcePreview(workspace.id)
|
||||
)
|
||||
const sources = useSources(workspace.id)
|
||||
// Which formats Studio offers is the server's answer to what is selected,
|
||||
// so it has to be asked again when that changes. The chat model is named
|
||||
|
|
@ -117,11 +127,37 @@ function WorkspaceDashboard({
|
|||
readsImages: selection?.reads_images === true,
|
||||
onModelRequired,
|
||||
})
|
||||
const sourcePreview = sources.documents.find(
|
||||
(document) => document.id === sourcePreviewId
|
||||
)
|
||||
const sourcePreviewOpen =
|
||||
sourcePreview?.document_type === "FILE" &&
|
||||
getFileViewer(sourcePreview.mime_type) !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (sourcePreviewId === null || sources.isLoading) return
|
||||
if (
|
||||
!sourcePreview ||
|
||||
sourcePreview.document_type !== "FILE" ||
|
||||
!getFileViewer(sourcePreview.mime_type)
|
||||
) {
|
||||
writeSourcePreview(workspace.id, null)
|
||||
}
|
||||
}, [sourcePreview, sourcePreviewId, sources.isLoading, workspace.id])
|
||||
|
||||
const composerHold =
|
||||
modelIssue && needsConsent ? consentPlaceholder(modelIssue) : undefined
|
||||
|
||||
const closeInspect = () => setInspect(null)
|
||||
const closeSourcePreview = () => {
|
||||
setSourcePreviewId(null)
|
||||
writeSourcePreview(workspace.id, null)
|
||||
}
|
||||
const toggleSourcePreview = (documentId: number) => {
|
||||
const next = sourcePreviewId === documentId ? null : documentId
|
||||
setSourcePreviewId(next)
|
||||
writeSourcePreview(workspace.id, next)
|
||||
}
|
||||
const toggleRightPanel = () => {
|
||||
setRightPanelOpen((open) => {
|
||||
const next = !open
|
||||
|
|
@ -267,6 +303,7 @@ function WorkspaceDashboard({
|
|||
: (files) => void sources.upload(files)
|
||||
}
|
||||
onOpen={(id) => void sources.openOriginal(id)}
|
||||
onPreview={toggleSourcePreview}
|
||||
onReveal={(id) => void sources.revealOriginal(id)}
|
||||
onRetry={(id) => void sources.retry(id)}
|
||||
onCancel={(id) => void sources.cancel(id)}
|
||||
|
|
@ -280,6 +317,21 @@ function WorkspaceDashboard({
|
|||
footer={<SidebarFooter onOpenLicense={onOpenLicense} />}
|
||||
/>
|
||||
</div>
|
||||
<SlideRail
|
||||
open={sourcePreviewOpen}
|
||||
side="start"
|
||||
width={MAIN_RAIL_WIDTH}
|
||||
>
|
||||
{sourcePreview ? (
|
||||
<SourcePreviewPanel
|
||||
workspaceId={workspace.id}
|
||||
document={sourcePreview}
|
||||
onOpen={() => void sources.openOriginal(sourcePreview.id)}
|
||||
onReveal={() => void sources.revealOriginal(sourcePreview.id)}
|
||||
onClose={closeSourcePreview}
|
||||
/>
|
||||
) : null}
|
||||
</SlideRail>
|
||||
<div className="flex min-h-0 min-w-[520px] flex-1 flex-col">
|
||||
<ThreadPanel
|
||||
runtime={chat.runtime}
|
||||
|
|
@ -318,7 +370,7 @@ function WorkspaceDashboard({
|
|||
/>
|
||||
</div>
|
||||
<SlideRail
|
||||
open={rightPanelOpen}
|
||||
open={rightPanelOpen && !sourcePreviewOpen}
|
||||
side="end"
|
||||
width={inspect ? DETAIL_RAIL_WIDTH : MAIN_RAIL_WIDTH}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist"
|
||||
import type { PDFViewer as PDFViewerCore } from "pdfjs-dist/web/pdf_viewer.mjs"
|
||||
import "pdfjs-dist/web/pdf_viewer.css"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ZoomInIcon, ZoomOutIcon } from "@/components/ui/icons"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { intl } from "@/i18n/intl"
|
||||
import type { FileViewerProps } from "./registry"
|
||||
|
||||
// Ported from Studio's PDF viewer; source files use the original-document URL.
|
||||
//
|
||||
// Loaded lazily: pdfjs-dist's core module crashes in jsdom on import (a
|
||||
// DOMMatrix check), and pdf_viewer.mjs reads the core API off
|
||||
// `globalThis.pdfjsLib` instead of importing it — so this must run first.
|
||||
// Always load pdf_viewer.mjs via loadPdfViewerModule(), never a bare import.
|
||||
let pdfjsLibPromise: Promise<typeof import("pdfjs-dist")> | null = null
|
||||
function loadPdfjsLib() {
|
||||
pdfjsLibPromise ??= import("pdfjs-dist").then((pdfjsLib) => {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url
|
||||
).toString()
|
||||
;(
|
||||
globalThis as typeof globalThis & { pdfjsLib: typeof pdfjsLib }
|
||||
).pdfjsLib = pdfjsLib
|
||||
return pdfjsLib
|
||||
})
|
||||
return pdfjsLibPromise
|
||||
}
|
||||
|
||||
function loadPdfViewerModule() {
|
||||
return loadPdfjsLib().then(() => import("pdfjs-dist/web/pdf_viewer.mjs"))
|
||||
}
|
||||
|
||||
type EmbeddedPdfViewer = Omit<PDFViewerCore, "setDocument"> & {
|
||||
setDocument(pdfDocument: PDFDocumentProxy | null): void
|
||||
}
|
||||
|
||||
interface PageRenderedEvent {
|
||||
isDetailView: boolean
|
||||
}
|
||||
|
||||
export function PdfViewer({ url, actionsContainer }: FileViewerProps) {
|
||||
const [numPages, setNumPages] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewerElementRef = useRef<HTMLDivElement>(null)
|
||||
const pdfViewerRef = useRef<EmbeddedPdfViewer | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void retryKey
|
||||
const container = containerRef.current
|
||||
const viewerElement = viewerElementRef.current
|
||||
if (!container || !viewerElement) return
|
||||
|
||||
const controller = new AbortController()
|
||||
let disposed = false
|
||||
let loadingTask: PDFDocumentLoadingTask | null = null
|
||||
let pdfDocument: PDFDocumentProxy | null = null
|
||||
let pdfViewer: EmbeddedPdfViewer | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let resizeFrame: number | null = null
|
||||
let eventBus: InstanceType<
|
||||
(typeof import("pdfjs-dist/web/pdf_viewer.mjs"))["EventBus"]
|
||||
> | null = null
|
||||
let handlePagesInit: (() => void) | null = null
|
||||
let handlePageRendered: ((event: PageRenderedEvent) => void) | null = null
|
||||
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
setNumPages(0)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// loadPdfViewerModule() then fetch — not Promise.all — so
|
||||
// pdf_viewer.mjs never starts before globalThis.pdfjsLib is set.
|
||||
const viewerModulePromise = loadPdfViewerModule()
|
||||
const responsePromise = fetch(url, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
const [pdfjsLib, viewerModule, response] = await Promise.all([
|
||||
loadPdfjsLib(),
|
||||
viewerModulePromise,
|
||||
responsePromise,
|
||||
])
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "file_viewers_pdf_load_error",
|
||||
defaultMessage:
|
||||
"Server returned {status} while retrieving the PDF",
|
||||
},
|
||||
{
|
||||
status: String(response.status),
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.arrayBuffer()
|
||||
if (disposed) return
|
||||
|
||||
eventBus = new viewerModule.EventBus()
|
||||
const linkService = new viewerModule.PDFLinkService({ eventBus })
|
||||
pdfViewer = new viewerModule.PDFViewer({
|
||||
container,
|
||||
viewer: viewerElement,
|
||||
eventBus,
|
||||
linkService,
|
||||
textLayerMode: 0,
|
||||
annotationMode: 0,
|
||||
}) as EmbeddedPdfViewer
|
||||
pdfViewerRef.current = pdfViewer
|
||||
linkService.setViewer(pdfViewer)
|
||||
|
||||
handlePagesInit = () => {
|
||||
if (disposed || !pdfViewer || !pdfDocument) return
|
||||
pdfViewer.currentScaleValue = "page-width"
|
||||
container.scrollTop = 0
|
||||
setNumPages(pdfDocument.numPages)
|
||||
}
|
||||
handlePageRendered = ({ isDetailView }) => {
|
||||
if (!disposed && !isDetailView) setLoading(false)
|
||||
}
|
||||
eventBus.on("pagesinit", handlePagesInit)
|
||||
eventBus.on("pagerendered", handlePageRendered)
|
||||
|
||||
loadingTask = pdfjsLib.getDocument({ data })
|
||||
pdfDocument = await loadingTask.promise
|
||||
if (disposed) {
|
||||
// PDF.js 6 dropped PDFDocumentProxy.destroy(); the loading task
|
||||
// owns the document and its worker.
|
||||
await loadingTask.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
linkService.setDocument(pdfDocument)
|
||||
pdfViewer.setDocument(pdfDocument)
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (pdfViewer?.currentScaleValue !== "page-width") return
|
||||
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame)
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = null
|
||||
if (pdfViewer?.currentScaleValue === "page-width") {
|
||||
pdfViewer.currentScaleValue = "page-width"
|
||||
}
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
} catch (error: unknown) {
|
||||
if (disposed) return
|
||||
setLoadError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: intl.formatMessage({
|
||||
id: "file_viewers_pdf_unknown_error",
|
||||
defaultMessage: "Failed to load PDF",
|
||||
})
|
||||
)
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
controller.abort()
|
||||
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame)
|
||||
resizeObserver?.disconnect()
|
||||
if (eventBus && handlePagesInit)
|
||||
eventBus.off("pagesinit", handlePagesInit)
|
||||
if (eventBus && handlePageRendered)
|
||||
eventBus.off("pagerendered", handlePageRendered)
|
||||
pdfViewer?.setDocument(null)
|
||||
pdfViewerRef.current = null
|
||||
void loadingTask?.destroy()
|
||||
}
|
||||
}, [url, retryKey])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
// ctrlKey is also how browsers report a trackpad pinch gesture (not
|
||||
// just an actual held-down Ctrl key), so this covers both at once —
|
||||
// PDFViewer has no built-in handling for either, unlike the reference
|
||||
// PDF.js viewer app, which this embeddable component doesn't include.
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey) return
|
||||
event.preventDefault()
|
||||
pdfViewerRef.current?.updateScale({
|
||||
steps: event.deltaY < 0 ? 1 : -1,
|
||||
origin: [event.clientX, event.clientY],
|
||||
drawingDelay: 500,
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener("wheel", handleWheel, { passive: false })
|
||||
return () => container.removeEventListener("wheel", handleWheel)
|
||||
}, [])
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
pdfViewerRef.current?.increaseScale({ drawingDelay: 500 })
|
||||
}, [])
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
pdfViewerRef.current?.decreaseScale({ drawingDelay: 500 })
|
||||
}, [])
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
{intl.formatMessage({
|
||||
id: "file_viewers_pdf_error_title",
|
||||
defaultMessage: "Failed to load PDF",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{loadError}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setRetryKey((key) => key + 1)}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "file_viewers_pdf_retry_button",
|
||||
defaultMessage: "Try again",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const zoomControls = (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={intl.formatMessage({
|
||||
id: "file_viewers_pdf_zoom_out_aria",
|
||||
defaultMessage: "Zoom out",
|
||||
})}
|
||||
onClick={zoomOut}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={intl.formatMessage({
|
||||
id: "file_viewers_pdf_zoom_in_aria",
|
||||
defaultMessage: "Zoom in",
|
||||
})}
|
||||
onClick={zoomIn}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white text-neutral-950">
|
||||
{numPages > 0 && actionsContainer
|
||||
? createPortal(zoomControls, actionsContainer)
|
||||
: null}
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={containerRef} className="absolute inset-0 overflow-auto">
|
||||
<div ref={viewerElementRef} className="pdfViewer" />
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getFileViewer } from "./registry"
|
||||
|
||||
describe("source file viewers", () => {
|
||||
it("provides the PDF viewer and leaves unsupported MIME types native", () => {
|
||||
expect(getFileViewer("application/pdf")).not.toBeNull()
|
||||
expect(getFileViewer("text/plain")).toBeNull()
|
||||
expect(getFileViewer(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import type { ComponentType } from "react"
|
||||
|
||||
import { PdfViewer } from "./pdf-viewer"
|
||||
|
||||
export type FileViewerProps = {
|
||||
url: string
|
||||
mimeType: string
|
||||
sizeBytes: number | null
|
||||
title: string
|
||||
actionsContainer: HTMLElement | null
|
||||
}
|
||||
|
||||
const FILE_VIEWERS: Record<string, ComponentType<FileViewerProps>> = {
|
||||
"application/pdf": PdfViewer,
|
||||
}
|
||||
|
||||
export function getFileViewer(
|
||||
mimeType: string | null
|
||||
): ComponentType<FileViewerProps> | null {
|
||||
return mimeType ? (FILE_VIEWERS[mimeType] ?? null) : null
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DetailPanel } from "@/components/ui/detail-panel"
|
||||
import { FolderOpenIcon, ViewIcon } from "@/components/ui/icons"
|
||||
import type { WorkspaceDocument } from "@/features/sources/api"
|
||||
import { originalDocumentUrl } from "@/features/sources/api"
|
||||
import { intl } from "@/i18n/intl"
|
||||
import { getFileViewer } from "@/features/file-viewers/registry"
|
||||
|
||||
export function SourcePreviewPanel({
|
||||
workspaceId,
|
||||
document,
|
||||
onOpen,
|
||||
onReveal,
|
||||
onClose,
|
||||
}: {
|
||||
workspaceId: number
|
||||
document: WorkspaceDocument
|
||||
onOpen: () => void
|
||||
onReveal: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [actionsContainer, setActionsContainer] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
const Viewer = getFileViewer(document.mime_type)
|
||||
const mimeType = document.mime_type
|
||||
if (!Viewer || !mimeType) return null
|
||||
|
||||
return (
|
||||
<DetailPanel
|
||||
title={document.title}
|
||||
titleClassName="select-none"
|
||||
ariaLabel={intl.formatMessage({
|
||||
id: "source_preview_panel_aria",
|
||||
defaultMessage: "Source preview",
|
||||
})}
|
||||
closeLabel={intl.formatMessage({
|
||||
id: "source_preview_panel_close_aria",
|
||||
defaultMessage: "Close source preview",
|
||||
})}
|
||||
onClose={onClose}
|
||||
flush
|
||||
actions={
|
||||
<>
|
||||
<div ref={setActionsContainer} className="flex items-center gap-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={intl.formatMessage({
|
||||
id: "source_preview_panel_open_aria",
|
||||
defaultMessage: "Open source",
|
||||
})}
|
||||
title={intl.formatMessage({
|
||||
id: "source_preview_panel_open_tooltip",
|
||||
defaultMessage: "Open source",
|
||||
})}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ViewIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={intl.formatMessage({
|
||||
id: "source_preview_panel_reveal_aria",
|
||||
defaultMessage: "Show source in folder",
|
||||
})}
|
||||
title={intl.formatMessage({
|
||||
id: "source_preview_panel_reveal_tooltip",
|
||||
defaultMessage: "Show in folder",
|
||||
})}
|
||||
onClick={onReveal}
|
||||
>
|
||||
<FolderOpenIcon />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="h-full">
|
||||
{/* getFileViewer returns a stable registry reference. */}
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<Viewer
|
||||
url={originalDocumentUrl(workspaceId, document.id)}
|
||||
mimeType={mimeType}
|
||||
sizeBytes={null}
|
||||
title={document.title}
|
||||
actionsContainer={actionsContainer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { requestJson, requestVoid } from "@/lib/api"
|
||||
import { apiUrl, requestJson, requestVoid } from "@/lib/api"
|
||||
|
||||
export type DocumentStatus =
|
||||
"pending" | "processing" | "ready" | "failed" | "cancelled"
|
||||
|
|
@ -7,6 +7,7 @@ export type WorkspaceDocument = {
|
|||
id: number
|
||||
title: string
|
||||
document_type: "FILE" | "NOTE"
|
||||
mime_type: string | null
|
||||
status: DocumentStatus
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
|
|
@ -94,6 +95,13 @@ export function listDocuments(
|
|||
)
|
||||
}
|
||||
|
||||
export function originalDocumentUrl(
|
||||
workspaceId: number,
|
||||
documentId: number
|
||||
): string {
|
||||
return apiUrl(`/workspaces/${workspaceId}/documents/${documentId}/original`)
|
||||
}
|
||||
|
||||
export function retryDocument(
|
||||
workspaceId: number,
|
||||
documentId: number,
|
||||
|
|
|
|||
|
|
@ -26,12 +26,20 @@ const pendingDocument = {
|
|||
id: 7,
|
||||
title: "guide.txt",
|
||||
document_type: "FILE" as const,
|
||||
mime_type: null,
|
||||
status: "pending" as const,
|
||||
error_message: null,
|
||||
created_at: "2026-09-05T00:00:00Z",
|
||||
updated_at: "2026-09-05T00:00:00Z",
|
||||
}
|
||||
|
||||
const pendingPdf = {
|
||||
...pendingDocument,
|
||||
id: 9,
|
||||
title: "report.pdf",
|
||||
mime_type: "application/pdf",
|
||||
}
|
||||
|
||||
function SourceHarness() {
|
||||
const sources = useSources(1)
|
||||
return (
|
||||
|
|
@ -90,6 +98,41 @@ afterEach(() => {
|
|||
})
|
||||
|
||||
describe("source upload", () => {
|
||||
it("previews a PDF source before ingestion finishes", async () => {
|
||||
const user = userEvent.setup()
|
||||
const onPreview = vi.fn()
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SourcesPanel
|
||||
documents={[pendingPdf]}
|
||||
selectedDocumentIds={[]}
|
||||
highlightedDocumentId={null}
|
||||
isLoading={false}
|
||||
isDeleting={false}
|
||||
error={null}
|
||||
onOpen={vi.fn()}
|
||||
onPreview={onPreview}
|
||||
onReveal={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDeleteSelected={vi.fn()}
|
||||
onSelectionChange={vi.fn()}
|
||||
onToggleAll={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "report.pdf" }))
|
||||
expect(onPreview).toHaveBeenCalledWith(9)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Actions for report.pdf" })
|
||||
)
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Preview" }))
|
||||
expect(onPreview).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("uses selection, processing, and retry controls in the icon slot", () => {
|
||||
const ready = {
|
||||
...pendingDocument,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
import { ScrollFade } from "@/components/ui/scroll-fade"
|
||||
import { SkeletonSlabs } from "@/components/ui/skeleton"
|
||||
import { SOURCE_FILE_ACCEPT, type WorkspaceDocument } from "./api"
|
||||
import { getFileViewer } from "@/features/file-viewers/registry"
|
||||
import { useFileDrop } from "./use-file-drop"
|
||||
import { useModifierHeld } from "@/hooks/use-modifier-held"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
|
@ -66,6 +67,7 @@ function SelectableSourceRow({
|
|||
highlighted,
|
||||
rowRef,
|
||||
onOpen,
|
||||
onPreview,
|
||||
onReveal,
|
||||
onRetry,
|
||||
onCancel,
|
||||
|
|
@ -78,6 +80,7 @@ function SelectableSourceRow({
|
|||
highlighted: boolean
|
||||
rowRef: (node: HTMLLIElement | null) => void
|
||||
onOpen: () => void
|
||||
onPreview: () => void
|
||||
onReveal: () => void
|
||||
onRetry: () => void
|
||||
onCancel: () => void
|
||||
|
|
@ -93,6 +96,8 @@ function SelectableSourceRow({
|
|||
document.status === "pending" || document.status === "processing"
|
||||
const processing = document.status === "processing"
|
||||
const openable = ready && document.document_type === "FILE"
|
||||
const previewable = getFileViewer(document.mime_type) !== null
|
||||
const titleActionable = previewable || openable
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const [rowHovered, setRowHovered] = useState(false)
|
||||
// A developer aid: holding Ctrl/Cmd while hovering anywhere on a failed
|
||||
|
|
@ -201,12 +206,14 @@ function SelectableSourceRow({
|
|||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!openable}
|
||||
disabled={!titleActionable}
|
||||
className={cn(
|
||||
"sidebar-row-title-fade min-w-0 flex-1 overflow-hidden rounded-sm text-left text-sm font-normal whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default",
|
||||
dropdownOpen && "sidebar-row-title-fade-actions"
|
||||
)}
|
||||
onClick={openable ? onOpen : undefined}
|
||||
onClick={
|
||||
titleActionable ? (previewable ? onPreview : onOpen) : undefined
|
||||
}
|
||||
>
|
||||
{document.title}
|
||||
</button>
|
||||
|
|
@ -239,6 +246,15 @@ function SelectableSourceRow({
|
|||
className="min-w-40"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
{previewable ? (
|
||||
<DropdownMenuItem onClick={onPreview}>
|
||||
<ViewIcon />
|
||||
{intl.formatMessage({
|
||||
id: "sources_row_menu_preview_label",
|
||||
defaultMessage: "Preview",
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{openable ? (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onOpen}>
|
||||
|
|
@ -388,6 +404,7 @@ export function SourcesPanel({
|
|||
error,
|
||||
addAction,
|
||||
onOpen,
|
||||
onPreview,
|
||||
onReveal,
|
||||
onRetry,
|
||||
onCancel,
|
||||
|
|
@ -405,6 +422,7 @@ export function SourcesPanel({
|
|||
error: string | null
|
||||
addAction?: ReactNode
|
||||
onOpen: (documentId: number) => void
|
||||
onPreview?: (documentId: number) => void
|
||||
onReveal: (documentId: number) => void
|
||||
onRetry: (documentId: number) => void
|
||||
onCancel: (documentId: number) => void
|
||||
|
|
@ -525,6 +543,7 @@ export function SourcesPanel({
|
|||
else sourceRows.current.delete(document.id)
|
||||
}}
|
||||
onOpen={() => onOpen(document.id)}
|
||||
onPreview={() => onPreview?.(document.id)}
|
||||
onReveal={() => onReveal(document.id)}
|
||||
onRetry={() => onRetry(document.id)}
|
||||
onCancel={() => onCancel(document.id)}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const readyDocument = {
|
|||
id: 4,
|
||||
title: "Saturn facts",
|
||||
document_type: "NOTE" as const,
|
||||
mime_type: null,
|
||||
status: "ready" as const,
|
||||
error_message: null,
|
||||
created_at: "2026-09-05T00:00:00Z",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "Starte SurfSense neu, um den Dienst wiederherzustellen.",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense funktioniert nicht mehr} documents {Die Dokumentverarbeitung wurde beendet} studio {Die Studio-Verarbeitung wurde beendet} chat {Der lokale Chat wurde beendet} images {Die Bilderzeugung wurde beendet} audio {Die Audioerzeugung wurde beendet} other {Ein Hintergrunddienst wurde beendet}}",
|
||||
"feedback_toast_report_button": "Problem melden",
|
||||
"file_viewers_pdf_error_title": "PDF konnte nicht geladen werden",
|
||||
"file_viewers_pdf_load_error": "Der Server gab beim Abrufen der PDF-Datei den Status {status} zurück",
|
||||
"file_viewers_pdf_retry_button": "Erneut versuchen",
|
||||
"file_viewers_pdf_unknown_error": "PDF konnte nicht geladen werden",
|
||||
"file_viewers_pdf_zoom_in_aria": "Vergrößern",
|
||||
"file_viewers_pdf_zoom_out_aria": "Verkleinern",
|
||||
"license_clock_notice_body": "Die Uhr dieses Computers geht hinter dem Zeitpunkt zurück, zu dem SurfSense zuletzt lief. Stelle sie richtig, um deine Lizenz zu nutzen.",
|
||||
"license_clock_notice_title": "Uhrzeit stimmt nicht",
|
||||
"license_error_bad_signature": "Diese Lizenzdatei wurde nicht von SurfSense ausgestellt oder wurde verändert.",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "Video",
|
||||
"settings_video_models_body": "Das Modell, das Videos erstellt. Führe eines auf diesem Computer aus oder nutze eines von einem Server.",
|
||||
"settings_video_models_title": "Modelle für Videoerzeugung",
|
||||
"source_preview_panel_aria": "Quellenvorschau",
|
||||
"source_preview_panel_close_aria": "Quellenvorschau schließen",
|
||||
"source_preview_panel_open_aria": "Quelle öffnen",
|
||||
"source_preview_panel_open_tooltip": "Quelle öffnen",
|
||||
"source_preview_panel_reveal_aria": "Quelle im Ordner anzeigen",
|
||||
"source_preview_panel_reveal_tooltip": "Im Ordner anzeigen",
|
||||
"sources_action_failed_title": "Quellenaktion fehlgeschlagen",
|
||||
"sources_add_button": "Hinzufügen",
|
||||
"sources_add_file_aria": "Quelldateien hochladen",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "Löschen",
|
||||
"sources_row_menu_deselect_label": "Abwählen",
|
||||
"sources_row_menu_open_label": "Öffnen",
|
||||
"sources_row_menu_preview_label": "Vorschau",
|
||||
"sources_row_menu_retry_label": "Erneut versuchen",
|
||||
"sources_row_menu_reveal_label": "Im Ordner anzeigen",
|
||||
"sources_row_menu_select_label": "Auswählen",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "Restart SurfSense to restore it.",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense stopped working} documents {Document processing stopped} studio {Studio processing stopped} chat {Local chat stopped} images {Image generation stopped} audio {Audio generation stopped} other {A background service stopped}}",
|
||||
"feedback_toast_report_button": "Report issue",
|
||||
"file_viewers_pdf_error_title": "Failed to load PDF",
|
||||
"file_viewers_pdf_load_error": "Server returned {status} while retrieving the PDF",
|
||||
"file_viewers_pdf_retry_button": "Try again",
|
||||
"file_viewers_pdf_unknown_error": "Failed to load PDF",
|
||||
"file_viewers_pdf_zoom_in_aria": "Zoom in",
|
||||
"file_viewers_pdf_zoom_out_aria": "Zoom out",
|
||||
"license_clock_notice_body": "This computer’s clock is behind the last time SurfSense ran. Set it right to use your license.",
|
||||
"license_clock_notice_title": "Clock is off",
|
||||
"license_error_bad_signature": "This license file was not issued by SurfSense, or it was altered.",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "Video",
|
||||
"settings_video_models_body": "The model that makes video. Run one on this computer, or use one from a server.",
|
||||
"settings_video_models_title": "Video generation models",
|
||||
"source_preview_panel_aria": "Source preview",
|
||||
"source_preview_panel_close_aria": "Close source preview",
|
||||
"source_preview_panel_open_aria": "Open source",
|
||||
"source_preview_panel_open_tooltip": "Open source",
|
||||
"source_preview_panel_reveal_aria": "Show source in folder",
|
||||
"source_preview_panel_reveal_tooltip": "Show in folder",
|
||||
"sources_action_failed_title": "Source action failed",
|
||||
"sources_add_button": "Add",
|
||||
"sources_add_file_aria": "Upload source files",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "Delete",
|
||||
"sources_row_menu_deselect_label": "Deselect",
|
||||
"sources_row_menu_open_label": "Open",
|
||||
"sources_row_menu_preview_label": "Preview",
|
||||
"sources_row_menu_retry_label": "Retry",
|
||||
"sources_row_menu_reveal_label": "Show in folder",
|
||||
"sources_row_menu_select_label": "Select",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "Reinicia SurfSense para restaurarlo.",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense dejó de funcionar} documents {El procesamiento de documentos se detuvo} studio {El procesamiento de Studio se detuvo} chat {El chat local se detuvo} images {La generación de imágenes se detuvo} audio {La generación de audio se detuvo} other {Un servicio en segundo plano se detuvo}}",
|
||||
"feedback_toast_report_button": "Informar problema",
|
||||
"file_viewers_pdf_error_title": "No se pudo cargar el PDF",
|
||||
"file_viewers_pdf_load_error": "El servidor devolvió {status} al recuperar el PDF",
|
||||
"file_viewers_pdf_retry_button": "Volver a intentar",
|
||||
"file_viewers_pdf_unknown_error": "No se pudo cargar el PDF",
|
||||
"file_viewers_pdf_zoom_in_aria": "Acercar",
|
||||
"file_viewers_pdf_zoom_out_aria": "Alejar",
|
||||
"license_clock_notice_body": "El reloj de este equipo está atrasado respecto a la última vez que se ejecutó SurfSense. Ajústalo bien para usar tu licencia.",
|
||||
"license_clock_notice_title": "Hora incorrecta",
|
||||
"license_error_bad_signature": "Este archivo de licencia no lo emitió SurfSense, o fue alterado.",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "Video",
|
||||
"settings_video_models_body": "El modelo que crea videos. Ejecuta uno en este equipo o usa uno de un servidor.",
|
||||
"settings_video_models_title": "Modelos de generación de video",
|
||||
"source_preview_panel_aria": "Vista previa de la fuente",
|
||||
"source_preview_panel_close_aria": "Cerrar vista previa de la fuente",
|
||||
"source_preview_panel_open_aria": "Abrir fuente",
|
||||
"source_preview_panel_open_tooltip": "Abrir fuente",
|
||||
"source_preview_panel_reveal_aria": "Mostrar fuente en la carpeta",
|
||||
"source_preview_panel_reveal_tooltip": "Mostrar en la carpeta",
|
||||
"sources_action_failed_title": "Falló la acción de la fuente",
|
||||
"sources_add_button": "Añadir",
|
||||
"sources_add_file_aria": "Subir archivos de fuentes",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "Eliminar",
|
||||
"sources_row_menu_deselect_label": "Deseleccionar",
|
||||
"sources_row_menu_open_label": "Abrir",
|
||||
"sources_row_menu_preview_label": "Vista previa",
|
||||
"sources_row_menu_retry_label": "Reintentar",
|
||||
"sources_row_menu_reveal_label": "Mostrar en la carpeta",
|
||||
"sources_row_menu_select_label": "Seleccionar",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "Redémarrez SurfSense pour rétablir ce service.",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense a cessé de fonctionner} documents {Le traitement des documents s’est arrêté} studio {Le traitement Studio s’est arrêté} chat {Le chat local s’est arrêté} images {La génération d’images s’est arrêtée} audio {La génération audio s’est arrêtée} other {Un service en arrière-plan s’est arrêté}}",
|
||||
"feedback_toast_report_button": "Signaler un problème",
|
||||
"file_viewers_pdf_error_title": "Impossible de charger le PDF",
|
||||
"file_viewers_pdf_load_error": "Le serveur a renvoyé le statut {status} lors de la récupération du PDF",
|
||||
"file_viewers_pdf_retry_button": "Réessayer",
|
||||
"file_viewers_pdf_unknown_error": "Impossible de charger le PDF",
|
||||
"file_viewers_pdf_zoom_in_aria": "Zoomer",
|
||||
"file_viewers_pdf_zoom_out_aria": "Dézoomer",
|
||||
"license_clock_notice_body": "L’horloge de cet ordinateur est en retard par rapport à la dernière exécution de SurfSense. Réglez-la correctement pour utiliser votre licence.",
|
||||
"license_clock_notice_title": "Horloge déréglée",
|
||||
"license_error_bad_signature": "Ce fichier de licence n’a pas été émis par SurfSense, ou il a été modifié.",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "Vidéo",
|
||||
"settings_video_models_body": "Le modèle qui crée les vidéos. Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.",
|
||||
"settings_video_models_title": "Modèles de génération de vidéos",
|
||||
"source_preview_panel_aria": "Aperçu de la source",
|
||||
"source_preview_panel_close_aria": "Fermer l’aperçu de la source",
|
||||
"source_preview_panel_open_aria": "Ouvrir la source",
|
||||
"source_preview_panel_open_tooltip": "Ouvrir la source",
|
||||
"source_preview_panel_reveal_aria": "Afficher la source dans le dossier",
|
||||
"source_preview_panel_reveal_tooltip": "Afficher dans le dossier",
|
||||
"sources_action_failed_title": "Échec de l’action sur la source",
|
||||
"sources_add_button": "Ajouter",
|
||||
"sources_add_file_aria": "Envoyer des fichiers source",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "Supprimer",
|
||||
"sources_row_menu_deselect_label": "Désélectionner",
|
||||
"sources_row_menu_open_label": "Ouvrir",
|
||||
"sources_row_menu_preview_label": "Aperçu",
|
||||
"sources_row_menu_retry_label": "Réessayer",
|
||||
"sources_row_menu_reveal_label": "Afficher dans le dossier",
|
||||
"sources_row_menu_select_label": "Sélectionner",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "इसे फिर से चालू करने के लिए SurfSense को रीस्टार्ट करें।",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense ने काम करना बंद कर दिया} documents {दस्तावेज़ प्रोसेसिंग रुक गई} studio {Studio प्रोसेसिंग रुक गई} chat {स्थानीय चैट रुक गई} images {इमेज जनरेशन रुक गया} audio {ऑडियो जनरेशन रुक गया} other {एक बैकग्राउंड सेवा रुक गई}}",
|
||||
"feedback_toast_report_button": "समस्या रिपोर्ट करें",
|
||||
"file_viewers_pdf_error_title": "PDF लोड नहीं हो सकी",
|
||||
"file_viewers_pdf_load_error": "PDF प्राप्त करते समय सर्वर ने {status} लौटाया",
|
||||
"file_viewers_pdf_retry_button": "फिर से कोशिश करें",
|
||||
"file_viewers_pdf_unknown_error": "PDF लोड नहीं हो सकी",
|
||||
"file_viewers_pdf_zoom_in_aria": "ज़ूम इन करें",
|
||||
"file_viewers_pdf_zoom_out_aria": "ज़ूम आउट करें",
|
||||
"license_clock_notice_body": "इस कंप्यूटर की घड़ी SurfSense के पिछली बार चलने के समय से पीछे है। अपने लाइसेंस का इस्तेमाल करने के लिए उसे ठीक करें।",
|
||||
"license_clock_notice_title": "घड़ी गलत है",
|
||||
"license_error_bad_signature": "यह लाइसेंस फ़ाइल SurfSense ने जारी नहीं की, या उसमें बदलाव किया गया है।",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "वीडियो",
|
||||
"settings_video_models_body": "वह मॉडल जो वीडियो बनाता है। इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।",
|
||||
"settings_video_models_title": "वीडियो जनरेशन मॉडल",
|
||||
"source_preview_panel_aria": "स्रोत का पूर्वावलोकन",
|
||||
"source_preview_panel_close_aria": "स्रोत का पूर्वावलोकन बंद करें",
|
||||
"source_preview_panel_open_aria": "स्रोत खोलें",
|
||||
"source_preview_panel_open_tooltip": "स्रोत खोलें",
|
||||
"source_preview_panel_reveal_aria": "फ़ोल्डर में स्रोत दिखाएं",
|
||||
"source_preview_panel_reveal_tooltip": "फ़ोल्डर में दिखाएं",
|
||||
"sources_action_failed_title": "स्रोत की कार्रवाई विफल रही",
|
||||
"sources_add_button": "जोड़ें",
|
||||
"sources_add_file_aria": "स्रोत फ़ाइलें अपलोड करें",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "हटाएं",
|
||||
"sources_row_menu_deselect_label": "चयन हटाएं",
|
||||
"sources_row_menu_open_label": "खोलें",
|
||||
"sources_row_menu_preview_label": "पूर्वावलोकन",
|
||||
"sources_row_menu_retry_label": "फिर से कोशिश करें",
|
||||
"sources_row_menu_reveal_label": "फ़ोल्डर में दिखाएं",
|
||||
"sources_row_menu_select_label": "चुनें",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "復旧するには SurfSense を再起動してください。",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense が停止しました} documents {ドキュメント処理が停止しました} studio {Studio の処理が停止しました} chat {ローカルチャットが停止しました} images {画像生成が停止しました} audio {音声生成が停止しました} other {バックグラウンドサービスが停止しました}}",
|
||||
"feedback_toast_report_button": "問題を報告",
|
||||
"file_viewers_pdf_error_title": "PDFを読み込めませんでした",
|
||||
"file_viewers_pdf_load_error": "PDFの取得時にサーバーが{status}を返しました",
|
||||
"file_viewers_pdf_retry_button": "再試行",
|
||||
"file_viewers_pdf_unknown_error": "PDFを読み込めませんでした",
|
||||
"file_viewers_pdf_zoom_in_aria": "拡大",
|
||||
"file_viewers_pdf_zoom_out_aria": "縮小",
|
||||
"license_clock_notice_body": "このコンピューターの時計が、SurfSenseを前回実行した時刻より遅れています。ライセンスを使うには時計を正しく設定してください。",
|
||||
"license_clock_notice_title": "時計がずれています",
|
||||
"license_error_bad_signature": "このライセンスファイルはSurfSenseが発行したものではないか、改ざんされています。",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "動画",
|
||||
"settings_video_models_body": "動画を生成するモデルです。このコンピューターで実行するか、サーバー上のモデルを使います。",
|
||||
"settings_video_models_title": "動画生成モデル",
|
||||
"source_preview_panel_aria": "ソースのプレビュー",
|
||||
"source_preview_panel_close_aria": "ソースのプレビューを閉じる",
|
||||
"source_preview_panel_open_aria": "ソースを開く",
|
||||
"source_preview_panel_open_tooltip": "ソースを開く",
|
||||
"source_preview_panel_reveal_aria": "フォルダーにソースを表示",
|
||||
"source_preview_panel_reveal_tooltip": "フォルダーに表示",
|
||||
"sources_action_failed_title": "ソースの操作に失敗しました",
|
||||
"sources_add_button": "追加",
|
||||
"sources_add_file_aria": "ソースファイルをアップロード",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "削除",
|
||||
"sources_row_menu_deselect_label": "選択解除",
|
||||
"sources_row_menu_open_label": "開く",
|
||||
"sources_row_menu_preview_label": "プレビュー",
|
||||
"sources_row_menu_retry_label": "再試行",
|
||||
"sources_row_menu_reveal_label": "フォルダーに表示",
|
||||
"sources_row_menu_select_label": "選択",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "복원하려면 SurfSense를 다시 시작하세요.",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense가 중지되었습니다} documents {문서 처리가 중지되었습니다} studio {Studio 처리가 중지되었습니다} chat {로컬 채팅이 중지되었습니다} images {이미지 생성이 중지되었습니다} audio {오디오 생성이 중지되었습니다} other {백그라운드 서비스가 중지되었습니다}}",
|
||||
"feedback_toast_report_button": "문제 신고",
|
||||
"file_viewers_pdf_error_title": "PDF를 불러오지 못했어요",
|
||||
"file_viewers_pdf_load_error": "PDF를 가져오는 중 서버가 {status}를 반환했어요",
|
||||
"file_viewers_pdf_retry_button": "다시 시도",
|
||||
"file_viewers_pdf_unknown_error": "PDF를 불러오지 못했어요",
|
||||
"file_viewers_pdf_zoom_in_aria": "확대",
|
||||
"file_viewers_pdf_zoom_out_aria": "축소",
|
||||
"license_clock_notice_body": "이 컴퓨터의 시계가 SurfSense를 마지막으로 실행한 시각보다 이전입니다. 라이선스를 사용하려면 시계를 올바르게 맞추세요.",
|
||||
"license_clock_notice_title": "시계가 맞지 않음",
|
||||
"license_error_bad_signature": "이 라이선스 파일은 SurfSense가 발급한 것이 아니거나 변조되었습니다.",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "동영상",
|
||||
"settings_video_models_body": "동영상을 만드는 모델입니다. 이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.",
|
||||
"settings_video_models_title": "동영상 생성 모델",
|
||||
"source_preview_panel_aria": "소스 미리보기",
|
||||
"source_preview_panel_close_aria": "소스 미리보기 닫기",
|
||||
"source_preview_panel_open_aria": "소스 열기",
|
||||
"source_preview_panel_open_tooltip": "소스 열기",
|
||||
"source_preview_panel_reveal_aria": "폴더에서 소스 보기",
|
||||
"source_preview_panel_reveal_tooltip": "폴더에서 보기",
|
||||
"sources_action_failed_title": "소스 작업 실패",
|
||||
"sources_add_button": "추가",
|
||||
"sources_add_file_aria": "소스 파일 업로드",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "삭제",
|
||||
"sources_row_menu_deselect_label": "선택 해제",
|
||||
"sources_row_menu_open_label": "열기",
|
||||
"sources_row_menu_preview_label": "미리보기",
|
||||
"sources_row_menu_retry_label": "다시 시도",
|
||||
"sources_row_menu_reveal_label": "폴더에서 표시",
|
||||
"sources_row_menu_select_label": "선택",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "Reinicie o SurfSense para restaurar o serviço.",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {O SurfSense parou de funcionar} documents {O processamento de documentos parou} studio {O processamento do Studio parou} chat {O chat local parou} images {A geração de imagens parou} audio {A geração de áudio parou} other {Um serviço em segundo plano parou}}",
|
||||
"feedback_toast_report_button": "Relatar problema",
|
||||
"file_viewers_pdf_error_title": "Não foi possível carregar o PDF",
|
||||
"file_viewers_pdf_load_error": "O servidor retornou {status} ao buscar o PDF",
|
||||
"file_viewers_pdf_retry_button": "Tentar novamente",
|
||||
"file_viewers_pdf_unknown_error": "Não foi possível carregar o PDF",
|
||||
"file_viewers_pdf_zoom_in_aria": "Ampliar",
|
||||
"file_viewers_pdf_zoom_out_aria": "Reduzir",
|
||||
"license_clock_notice_body": "O relógio deste computador está atrasado em relação à última vez que o SurfSense foi executado. Acerte-o para usar sua licença.",
|
||||
"license_clock_notice_title": "Relógio desajustado",
|
||||
"license_error_bad_signature": "Este arquivo de licença não foi emitido pelo SurfSense ou foi alterado.",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "Vídeo",
|
||||
"settings_video_models_body": "O modelo que cria vídeos. Execute um neste computador ou use um de um servidor.",
|
||||
"settings_video_models_title": "Modelos de geração de vídeo",
|
||||
"source_preview_panel_aria": "Prévia da fonte",
|
||||
"source_preview_panel_close_aria": "Fechar prévia da fonte",
|
||||
"source_preview_panel_open_aria": "Abrir fonte",
|
||||
"source_preview_panel_open_tooltip": "Abrir fonte",
|
||||
"source_preview_panel_reveal_aria": "Mostrar fonte na pasta",
|
||||
"source_preview_panel_reveal_tooltip": "Mostrar na pasta",
|
||||
"sources_action_failed_title": "Falha na ação da fonte",
|
||||
"sources_add_button": "Adicionar",
|
||||
"sources_add_file_aria": "Enviar arquivos como fontes",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "Excluir",
|
||||
"sources_row_menu_deselect_label": "Desmarcar",
|
||||
"sources_row_menu_open_label": "Abrir",
|
||||
"sources_row_menu_preview_label": "Prévia",
|
||||
"sources_row_menu_retry_label": "Tentar novamente",
|
||||
"sources_row_menu_reveal_label": "Mostrar na pasta",
|
||||
"sources_row_menu_select_label": "Selecionar",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "Перезапустите SurfSense, чтобы восстановить работу.",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense перестал работать} documents {Обработка документов остановлена} studio {Обработка в Studio остановлена} chat {Локальный чат остановлен} images {Создание изображений остановлено} audio {Создание аудио остановлено} other {Фоновая служба остановлена}}",
|
||||
"feedback_toast_report_button": "Сообщить о проблеме",
|
||||
"file_viewers_pdf_error_title": "Не удалось загрузить PDF",
|
||||
"file_viewers_pdf_load_error": "При загрузке PDF сервер вернул статус {status}",
|
||||
"file_viewers_pdf_retry_button": "Повторить",
|
||||
"file_viewers_pdf_unknown_error": "Не удалось загрузить PDF",
|
||||
"file_viewers_pdf_zoom_in_aria": "Увеличить",
|
||||
"file_viewers_pdf_zoom_out_aria": "Уменьшить",
|
||||
"license_clock_notice_body": "Часы этого компьютера отстают от времени последнего запуска SurfSense. Установите правильное время, чтобы пользоваться лицензией.",
|
||||
"license_clock_notice_title": "Часы сбились",
|
||||
"license_error_bad_signature": "Этот файл лицензии выдан не SurfSense или был изменён.",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "Видео",
|
||||
"settings_video_models_body": "Модель, которая создаёт видео. Запустите её на этом компьютере или используйте модель с сервера.",
|
||||
"settings_video_models_title": "Модели генерации видео",
|
||||
"source_preview_panel_aria": "Предпросмотр источника",
|
||||
"source_preview_panel_close_aria": "Закрыть предпросмотр источника",
|
||||
"source_preview_panel_open_aria": "Открыть источник",
|
||||
"source_preview_panel_open_tooltip": "Открыть источник",
|
||||
"source_preview_panel_reveal_aria": "Показать источник в папке",
|
||||
"source_preview_panel_reveal_tooltip": "Показать в папке",
|
||||
"sources_action_failed_title": "Не удалось выполнить действие с источником",
|
||||
"sources_add_button": "Добавить",
|
||||
"sources_add_file_aria": "Загрузить файлы источников",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "Удалить",
|
||||
"sources_row_menu_deselect_label": "Снять выделение",
|
||||
"sources_row_menu_open_label": "Открыть",
|
||||
"sources_row_menu_preview_label": "Предпросмотр",
|
||||
"sources_row_menu_retry_label": "Повторить",
|
||||
"sources_row_menu_reveal_label": "Показать в папке",
|
||||
"sources_row_menu_select_label": "Выбрать",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@
|
|||
"feedback_sidecar_crashed_body": "请重启 SurfSense 以恢复该功能。",
|
||||
"feedback_sidecar_crashed_toast": "{part, select, core {SurfSense 已停止运行} documents {文档处理已停止} studio {Studio 处理已停止} chat {本地聊天已停止} images {图像生成已停止} audio {音频生成已停止} other {后台服务已停止}}",
|
||||
"feedback_toast_report_button": "报告问题",
|
||||
"file_viewers_pdf_error_title": "无法加载 PDF",
|
||||
"file_viewers_pdf_load_error": "获取 PDF 时服务器返回了 {status}",
|
||||
"file_viewers_pdf_retry_button": "重试",
|
||||
"file_viewers_pdf_unknown_error": "无法加载 PDF",
|
||||
"file_viewers_pdf_zoom_in_aria": "放大",
|
||||
"file_viewers_pdf_zoom_out_aria": "缩小",
|
||||
"license_clock_notice_body": "这台电脑的时钟早于 SurfSense 上次运行的时间。请把时钟调准才能使用你的许可证。",
|
||||
"license_clock_notice_title": "时钟不准",
|
||||
"license_error_bad_signature": "这个许可证文件不是 SurfSense 签发的,或者已被改动。",
|
||||
|
|
@ -586,6 +592,12 @@
|
|||
"settings_nav_video_label": "视频",
|
||||
"settings_video_models_body": "生成视频的模型。可以在这台电脑上运行一个,也可以使用服务器上的。",
|
||||
"settings_video_models_title": "视频生成模型",
|
||||
"source_preview_panel_aria": "来源预览",
|
||||
"source_preview_panel_close_aria": "关闭来源预览",
|
||||
"source_preview_panel_open_aria": "打开来源",
|
||||
"source_preview_panel_open_tooltip": "打开来源",
|
||||
"source_preview_panel_reveal_aria": "在文件夹中显示来源",
|
||||
"source_preview_panel_reveal_tooltip": "在文件夹中显示",
|
||||
"sources_action_failed_title": "来源操作失败",
|
||||
"sources_add_button": "添加",
|
||||
"sources_add_file_aria": "上传来源文件",
|
||||
|
|
@ -613,6 +625,7 @@
|
|||
"sources_row_menu_delete_label": "删除",
|
||||
"sources_row_menu_deselect_label": "取消选择",
|
||||
"sources_row_menu_open_label": "打开",
|
||||
"sources_row_menu_preview_label": "预览",
|
||||
"sources_row_menu_retry_label": "重试",
|
||||
"sources_row_menu_reveal_label": "在文件夹中显示",
|
||||
"sources_row_menu_select_label": "选择",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue