From 26cae4db418a4f3626325295aa007f8fd482c773 Mon Sep 17 00:00:00 2001 From: kimsungmin1011 Date: Mon, 14 Sep 2026 03:58:25 +0900 Subject: [PATCH] feat: download owned code artifacts as source files --- .github/workflows/ci.yml | 3 + apps/api/app/routers/workspace.py | 35 +++- apps/api/app/services/tools/builtin.py | 6 +- .../tests/test_code_artifact_source_export.py | 195 ++++++++++++++++++ apps/web/e2e/artifact-source-download.spec.ts | 163 +++++++++++++++ apps/web/playwright.artifact-source.config.ts | 17 ++ .../components/artifacts/ArtifactPanel.tsx | 32 +++ apps/web/src/lib/api.ts | 19 +- apps/web/src/lib/i18n.ts | 2 + 9 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 apps/api/tests/test_code_artifact_source_export.py create mode 100644 apps/web/e2e/artifact-source-download.spec.ts create mode 100644 apps/web/playwright.artifact-source.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f84208b..a5bec62b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,9 @@ jobs: - name: Test search masking feedback run: npx playwright test --config playwright.search-masking.config.ts + - name: Test artifact source downloads + run: npx playwright test --config playwright.artifact-source.config.ts + api: name: API · lint, tests runs-on: ubuntu-latest diff --git a/apps/api/app/routers/workspace.py b/apps/api/app/routers/workspace.py index 127dc41b..8c1b983c 100644 --- a/apps/api/app/routers/workspace.py +++ b/apps/api/app/routers/workspace.py @@ -2086,6 +2086,35 @@ def _attachment(body: bytes, media: str, stem: str, suffix: str) -> Response: ) +_SOURCE_EXTENSIONS = { + "csv": "csv", "tsv": "tsv", "json": "json", "yaml": "yaml", "yml": "yml", + "python": "py", "py": "py", "javascript": "js", "js": "js", "jsx": "jsx", + "typescript": "ts", "ts": "ts", "tsx": "tsx", "bash": "sh", "shell": "sh", + "sh": "sh", "zsh": "zsh", "sql": "sql", "css": "css", "xml": "xml", + "markdown": "md", "md": "md", "text": "txt", "txt": "txt", "plain": "txt", +} +_SOURCE_MEDIA = { + "csv": "text/csv", "tsv": "text/tab-separated-values", "json": "application/json", +} + + +def _export_code_source(artifact: Artifact) -> Response: + """Download stored source, with no execution, conversion or language inference.""" + data = artifact.data or {} + content = data.get("content") + if not isinstance(content, str): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="missing_source") + language = str(data.get("language") or "").strip().lower() + suffix = _SOURCE_EXTENSIONS.get(language, "txt") + media = _SOURCE_MEDIA.get(suffix, "text/plain") + stem = re.sub(r'[\\/:*?"<>|\x00-\x1f\x7f]+', "_", artifact.title or "").strip(" .") + if stem.lower().endswith("." + suffix): + stem = stem[: -len(suffix) - 1].rstrip(" .") + response = _attachment(content.encode("utf-8"), media, stem[:60] or "code", suffix) + response.headers["Access-Control-Expose-Headers"] = "Content-Disposition" + return response + + def _export_deck(artifact: Artifact, format: str) -> Response: """A deck as `.pptx`, `.pdf` or Markdown.""" slides = list((artifact.data or {}).get("slides") or []) @@ -2216,12 +2245,14 @@ async def _export_page(artifact: Artifact, format: str) -> Response: @router.get("/artifacts/{artifact_id}/export") async def export_artifact(artifact_id: str, user: CurrentUser, db: DbSession, format: str = "docx"): - """A report, deck, or HTML artifact as a file. + """An owned artifact as a file. Reports take `docx`, `pdf`, `hwpx` or `md`; decks take `pptx`, `pdf` or `md`; - HTML artifacts take `html` plus the set matching their template. + HTML artifacts take `html` plus the set matching their template; code takes `source`. """ artifact = await _own(db, Artifact, "user_id", user, artifact_id) + if artifact.kind is ArtifactKind.code and format == "source": + return _export_code_source(artifact) if artifact.kind not in (ArtifactKind.report, ArtifactKind.deck, ArtifactKind.html): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="not_exportable") diff --git a/apps/api/app/services/tools/builtin.py b/apps/api/app/services/tools/builtin.py index f5fd9374..1f7c6ffc 100644 --- a/apps/api/app/services/tools/builtin.py +++ b/apps/api/app/services/tools/builtin.py @@ -1051,7 +1051,11 @@ async def create_artifact(args: dict[str, Any], ctx: ToolContext) -> ToolResult: }, "language": { "type": "string", - "description": "kind 가 code 일 때의 언어 (python, bash, yaml 등).", + "description": ( + "kind 가 code 일 때 원본 다운로드의 확장자를 결정하는 언어. " + "CSV는 csv, JSON은 json, YAML은 yaml, Python은 python으로 지정하세요. " + "생략하거나 지원하지 않는 언어면 text로 취급해 .txt로 다운로드합니다." + ), }, "userRequested": { "type": "boolean", diff --git a/apps/api/tests/test_code_artifact_source_export.py b/apps/api/tests/test_code_artifact_source_export.py new file mode 100644 index 00000000..37146fa1 --- /dev/null +++ b/apps/api/tests/test_code_artifact_source_export.py @@ -0,0 +1,195 @@ +"""Stored code can be downloaded without re-generation, execution or format guessing.""" + +from copy import deepcopy +from urllib.parse import unquote + +import httpx +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from app.core import deps +from app.core.db import get_session +from app.models.user import User +from app.models.workspace import Artifact, ArtifactKind +from app.routers import workspace +from app.services.tools.builtin import CREATE_ARTIFACT + + +class _ReadOnlyDb: + def __init__(self, artifact): + self.artifact = artifact + self.reads = [] + + async def get(self, model, item_id): + self.reads.append((model, item_id)) + return self.artifact + + def add(self, _row): + pytest.fail("Downloading stored code must not write to the database") + + async def commit(self): + pytest.fail("Downloading stored code must not commit") + + +def _artifact(language="csv", *, title="합성 자료", content="label,value\r\n가,1\r\n"): + data = {"content": content} + if language is not None: + data["language"] = language + return Artifact( + id="artifact-1", user_id="owner", kind=ArtifactKind.code, title=title, data=data + ) + + +def _user(user_id="owner"): + return User( + id=user_id, email="synthetic@example.test", password_hash="unused", name="Synthetic" + ) + + +def _filename(response): + disposition = response.headers["content-disposition"] + assert disposition.startswith("attachment; filename*=UTF-8''") + return unquote(disposition.split("''", 1)[1]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("language,suffix,media", [ + ("csv", "csv", "text/csv"), + ("tsv", "tsv", "text/tab-separated-values"), + ("json", "json", "application/json"), + ("python", "py", "text/plain"), + ("py", "py", "text/plain"), + ("javascript", "js", "text/plain"), + ("typescript", "ts", "text/plain"), + ("yaml", "yaml", "text/plain"), + ("yml", "yml", "text/plain"), + ("bash", "sh", "text/plain"), + ("sql", "sql", "text/plain"), + ("markdown", "md", "text/plain"), + ("text", "txt", "text/plain"), + (None, "txt", "text/plain"), + ("", "txt", "text/plain"), + ("unknown-format", "txt", "text/plain"), + ("html", "txt", "text/plain"), +]) +async def test_source_download_keeps_exact_saved_bytes_and_explicit_format(language, suffix, media): + artifact = _artifact(language) + original = deepcopy(artifact.data) + db = _ReadOnlyDb(artifact) + response = await workspace.export_artifact(artifact.id, _user(), db, format="source") + assert response.status_code == 200 + assert response.body == artifact.data["content"].encode("utf-8") + assert response.headers["content-type"].split(";", 1)[0] == media + assert response.headers["x-content-type-options"] == "nosniff" + assert _filename(response) == f"합성 자료.{suffix}" + assert artifact.data == original + assert db.reads == [(Artifact, artifact.id)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("title,language,wanted", [ + ("data.csv", "csv", "data.csv"), + ("data.CSV", " CSV ", "data.csv"), + ("data.csv", None, "data.csv.txt"), + ("../../자료\r\n.csv", "csv", "_.._자료_.csv"), + ("", None, "code.txt"), +]) +async def test_filename_uses_safe_title_without_guessing_language(title, language, wanted): + artifact = _artifact(language, title=title) + response = await workspace.export_artifact( + artifact.id, _user(), _ReadOnlyDb(artifact), "source" + ) + assert _filename(response) == wanted + assert "\r" not in response.headers["content-disposition"] + assert "\n" not in response.headers["content-disposition"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("artifact,user", [(_artifact(), _user("other")), (None, _user())]) +async def test_other_owner_and_absent_ids_are_indistinguishable(artifact, user): + with pytest.raises(HTTPException) as error: + await workspace.export_artifact("requested-id", user, _ReadOnlyDb(artifact), "source") + assert error.value.status_code == 404 + assert error.value.detail == "not_found" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", [kind for kind in ArtifactKind if kind is not ArtifactKind.code]) +async def test_source_export_does_not_admit_other_artifact_kinds(kind): + artifact = _artifact() + artifact.kind = kind + with pytest.raises(HTTPException) as error: + await workspace.export_artifact(artifact.id, _user(), _ReadOnlyDb(artifact), "source") + assert error.value.status_code == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("format", ["docx", "pdf", "csv", "html"]) +async def test_code_export_does_not_add_conversions(format): + artifact = _artifact() + with pytest.raises(HTTPException) as error: + await workspace.export_artifact(artifact.id, _user(), _ReadOnlyDb(artifact), format) + assert error.value.status_code == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("content", [None, 17, {"guess": "not source"}]) +async def test_non_text_source_is_not_stringified(content): + artifact = _artifact(content=content) + with pytest.raises(HTTPException) as error: + await workspace.export_artifact(artifact.id, _user(), _ReadOnlyDb(artifact), "source") + assert error.value.status_code == 400 + + +def test_tool_description_explains_the_optional_language_download_contract(): + language = CREATE_ARTIFACT.parameters["properties"]["language"]["description"] + assert "확장자" in language + assert "csv" in language and "json" in language and ".txt" in language + assert "language" not in CREATE_ARTIFACT.parameters["required"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("caller,status", [("owner", 200), ("other", 404)]) +@pytest.mark.parametrize("origin,allowed", [ + ("https://app.example.test", True), ("https://other.example.test", False), +]) +async def test_http_source_endpoint_preserves_bytes_and_owner_boundary( + caller, status, origin, allowed +): + artifact = _artifact(content='"label","value"\r\n"가,나","=1+1"\r\n') + db = _ReadOnlyDb(artifact) + app = FastAPI() + app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.test"]) + app.add_api_route("/artifacts/{artifact_id}/export", workspace.export_artifact, methods=["GET"]) + app.dependency_overrides[deps.current_user] = lambda: _user(caller) + app.dependency_overrides[get_session] = lambda: db + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get( + f"/artifacts/{artifact.id}/export?format=source", + headers={"Origin": origin}, + ) + assert response.status_code == status + if status == 200: + assert response.content == artifact.data["content"].encode("utf-8") + assert response.headers["content-type"] == "text/csv; charset=utf-8" + assert _filename(response) == "합성 자료.csv" + assert response.headers.get("access-control-allow-origin") == (origin if allowed else None) + assert response.headers.get("access-control-expose-headers") == "Content-Disposition" + else: + assert response.json() == {"detail": "not_found"} + + +@pytest.mark.asyncio +async def test_code_with_markup_is_downloaded_as_inert_text_without_rendering(): + source = '' + artifact = _artifact("html", title="page.html", content=source) + response = await workspace.export_artifact( + artifact.id, _user(), _ReadOnlyDb(artifact), "source" + ) + assert response.body == source.encode() + assert response.headers["content-type"] == "text/plain; charset=utf-8" + assert response.headers["x-content-type-options"] == "nosniff" + assert _filename(response) == "page.html.txt" diff --git a/apps/web/e2e/artifact-source-download.spec.ts b/apps/web/e2e/artifact-source-download.spec.ts new file mode 100644 index 00000000..d64c8b46 --- /dev/null +++ b/apps/web/e2e/artifact-source-download.spec.ts @@ -0,0 +1,163 @@ +import { expect, test, type Download, type Page, type TestInfo } from '@playwright/test' +import { mkdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +test.use({ serviceWorkers: 'block' }) + +const sessionId = '11111111111111111111111111111111' +const artifactId = '22222222222222222222222222222222' +const at = '2026-09-14T00:00:00.000Z' +const source = 'day,activity\n화요일,복습\n목요일,문제풀이\n' + +async function fixture(page: Page, testInfo: TestInfo, options: { + kind?: 'code' | 'html'; language?: string; title?: string; filename?: string; fail?: boolean +} = {}) { + const origin = new URL(String(testInfo.project.use.baseURL)).origin + expect(['localhost', '127.0.0.1']).toContain(new URL(origin).hostname) + const unexpected: string[] = [] + const exports: { format: string | null; authorization: string | undefined }[] = [] + const kind = options.kind ?? 'code' + const title = options.title ?? '주간 일정' + const content = kind === 'html' ? '

스터디 일정

화요일 복습

' : source + const artifact = { id: artifactId, title, kind, version: 1, partial: false, + sessionId, projectId: null, createdAt: at, updatedAt: at, + data: { kind, content, ...(options.language ? { language: options.language } : {}) } } + const session = { id: sessionId, title, kind: 'chat', model: 'fixture/base', routingMode: 'manual', + projectId: null, agentId: null, artifactId, pinned: false, messages: [], messageCount: 0, + made: null, createdAt: at, updatedAt: at } + await page.context().routeWebSocket('**/*', (socket) => { unexpected.push('WebSocket'); socket.close() }) + await page.context().route('**/*', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (url.origin !== origin) { + unexpected.push(`external ${url.origin}`) + return route.abort('blockedbyclient') + } + if (!url.pathname.startsWith('/api/')) return route.continue() + const path = url.pathname.slice(4) + const method = request.method() + if (method === 'POST' && path === '/auth/refresh') return route.fulfill({ json: { accessToken: 'fixture-only', expiresIn: 3600, + user: { id: 'fixture-user', name: 'Download fixture', email: 'fixture@example.test', role: 'user', + status: 'active', monthlyCredits: 1000, creditsUsed: 0, avatarColor: '#168267', allowedModels: [], + createdAt: at, preferences: { autoMemory: false, showUsage: false, streamResponses: true } } } }) + if (method === 'GET' && path === '/auth/config') return route.fulfill({ json: { brand: { name: 'KloudChat', logo: '' }, + enabledKinds: ['chat', 'report'], privacy: { externalDataGuard: false }, passwordResetEnabled: false, + dictationEnabled: false } }) + if (method === 'GET' && path === '/models') return route.fulfill({ json: { models: [{ id: 'fixture/base', label: 'Fixture', + name: 'Fixture', vendor: 'Fixture', provider: 'fixture', kinds: ['chat'], modality: 'chat', + dataBoundary: 'external', creditCost: 1, inputCreditCost: 1, supportsTools: true, contextWindow: 64000 }], + defaultChatModel: 'fixture/base', litellmAvailable: false, autoRouting: { enabled: false, available: false } } }) + if (method === 'GET' && path === '/credits') return route.fulfill({ json: { monthlyCredits: 1000, creditsUsed: 0, creditsRemaining: 1000 } }) + if (method === 'GET' && path === '/sessions') return route.fulfill({ json: [session] }) + if (method === 'GET' && path === `/sessions/${sessionId}`) return route.fulfill({ json: session }) + if (method === 'GET' && path === '/artifacts') return route.fulfill({ json: [artifact] }) + if (method === 'GET' && path === `/artifacts/${artifactId}`) return route.fulfill({ json: artifact }) + if (method === 'GET' && path === `/artifacts/${artifactId}/export`) { + exports.push({ format: url.searchParams.get('format'), authorization: request.headers().authorization }) + if (options.fail && exports.length === 1) return route.fulfill({ status: 404, json: { detail: 'Artifact not found' } }) + return route.fulfill({ body: content, headers: { + 'Content-Type': kind === 'html' ? 'text/html' : options.language === 'csv' ? 'text/csv' : 'text/plain', + ...(options.filename ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(options.filename)}` } : {}), + } }) + } + if (method === 'GET' && path === '/artifacts/counts') return route.fulfill({ json: { counts: { [kind]: 1 }, total: 1 } }) + if (method === 'GET' && [`/sessions/${sessionId}/messages`, `/sessions/${sessionId}/jobs`, '/projects', + '/skills', '/memory', '/agents', '/tools', '/templates', '/connectors', '/connectors/catalog', '/jobs', + '/designs', '/design-templates', '/prompt-templates', '/shares'].includes(path)) return route.fulfill({ json: [] }) + unexpected.push(`${method} ${path}`) + return route.abort('blockedbyclient') + }) + await page.goto(`/s/${sessionId}?artifact=${artifactId}`) + return { exports, unexpected, content } +} + +async function downloadedText(download: Download) { + const path = await download.path() + expect(path).not.toBeNull() + return readFile(path!, 'utf8') +} + +for (const width of [1440, 390]) { + test(`CSV source downloads through the authenticated export API at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }) + const state = await fixture(page, testInfo, { language: 'csv', title: '주간 일정.csv', filename: '주간 일정.csv' }) + await page.reload() + const button = page.getByRole('button', { name: '원본 다운로드', exact: true }) + await expect(button).toBeVisible() + await expect(page.locator('pre').filter({ hasText: source.trim() })).toBeVisible() + const pending = page.waitForEvent('download') + await button.click() + const download = await pending + expect(download.suggestedFilename()).toBe('주간 일정.csv') + expect(await downloadedText(download)).toBe(source) + expect(state.exports).toEqual([{ format: 'source', authorization: 'Bearer fixture-only' }]) + await expect(button).toBeEnabled() + await expect(page.getByRole('button', { name: '내보내기', exact: true })).toHaveCount(0) + const bounds = await button.boundingBox() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width) + const screenshot = testInfo.outputPath(`artifact-source-${width}.png`) + await page.screenshot({ path: screenshot }) + await testInfo.attach('source-download', { path: screenshot, contentType: 'image/png' }) + if (process.env.QA_SCREENSHOT_DIR) { + await mkdir(process.env.QA_SCREENSHOT_DIR, { recursive: true }) + await page.screenshot({ path: join(process.env.QA_SCREENSHOT_DIR, `artifact-source-${width}.png`) }) + } + expect(state.unexpected).toEqual([]) + }) +} + +for (const language of ['text', 'unknown', undefined]) { + test(`a ${language ?? 'missing'} language uses the server txt filename without CSV inference`, async ({ page }, testInfo) => { + const state = await fixture(page, testInfo, { language, filename: '주간 일정.txt' }) + const pending = page.waitForEvent('download') + await page.getByRole('button', { name: '원본 다운로드', exact: true }).click() + const download = await pending + expect(download.suggestedFilename()).toBe('주간 일정.txt') + expect(await downloadedText(download)).toBe(source) + expect(state.exports).toHaveLength(1) + expect(state.unexpected).toEqual([]) + }) +} + +test('a missing download filename falls back to text, not a source extension', async ({ page }, testInfo) => { + const state = await fixture(page, testInfo) + const pending = page.waitForEvent('download') + await page.getByRole('button', { name: '원본 다운로드', exact: true }).click() + expect((await pending).suggestedFilename()).toBe('주간 일정.txt') + expect(state.unexpected).toEqual([]) +}) + +test('an owner-scoped export failure shows an error and permits retry without a local fallback', async ({ page }, testInfo) => { + const state = await fixture(page, testInfo, { language: 'csv', filename: '주간 일정.csv', fail: true }) + const downloads: Download[] = [] + page.on('download', (download) => downloads.push(download)) + const button = page.getByRole('button', { name: '원본 다운로드', exact: true }) + await button.click() + await expect(page.getByRole('alert')).toContainText('Artifact not found') + expect(downloads).toEqual([]) + await expect(button).toBeEnabled() + const pending = page.waitForEvent('download') + await button.click() + await pending + await expect(page.getByRole('alert')).toHaveCount(0) + expect(state.exports).toHaveLength(2) + expect(state.unexpected).toEqual([]) +}) + +test('HTML keeps its preview, source tabs and existing export formats', async ({ page }, testInfo) => { + const state = await fixture(page, testInfo, { kind: 'html', language: 'html' }) + await expect(page.getByRole('button', { name: '원본 다운로드', exact: true })).toHaveCount(0) + await page.getByRole('button', { name: '소스', exact: true }).click() + await expect(page.locator('pre')).toHaveText(state.content) + await page.getByRole('button', { name: '미리보기', exact: true }).click() + await expect(page.locator('iframe').first()).toBeVisible() + await page.getByRole('button', { name: '내보내기', exact: true }).click() + const pending = page.waitForEvent('download') + await page.getByRole('menuitem').filter({ hasText: 'HTML' }).click() + const download = await pending + expect(download.suggestedFilename()).toBe('주간 일정.html') + expect(await downloadedText(download)).toBe(state.content) + expect(state.exports).toEqual([{ format: 'html', authorization: 'Bearer fixture-only' }]) + expect(state.unexpected).toEqual([]) +}) diff --git a/apps/web/playwright.artifact-source.config.ts b/apps/web/playwright.artifact-source.config.ts new file mode 100644 index 00000000..0da9a33f --- /dev/null +++ b/apps/web/playwright.artifact-source.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test' +import base from './playwright.config' + +export default defineConfig({ + ...base, + testMatch: 'artifact-source-download.spec.ts', + reporter: process.env.CI ? 'github' : 'list', + use: { ...base.use, baseURL: 'http://127.0.0.1:5233', trace: 'retain-on-failure' }, + webServer: { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 5233 --strictPort', + env: { API_BASE_URL: 'http://127.0.0.1:59999' }, + url: 'http://127.0.0.1:5233', + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [{ name: 'chromium', use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } } }], +}) diff --git a/apps/web/src/components/artifacts/ArtifactPanel.tsx b/apps/web/src/components/artifacts/ArtifactPanel.tsx index 72070b1a..3e4d69d9 100644 --- a/apps/web/src/components/artifacts/ArtifactPanel.tsx +++ b/apps/web/src/components/artifacts/ArtifactPanel.tsx @@ -482,9 +482,41 @@ export function CodePanel({ const [tab, setTab] = useState<'preview' | 'source'>( artifact.kind === 'html' ? 'preview' : 'source', ) + const [downloading, setDownloading] = useState(false) + const [downloadError, setDownloadError] = useState(null) const isDeck = useIsDeck(artifact) + const downloadSource = async () => { + setDownloading(true) + setDownloadError(null) + try { + await downloadArtifact(artifact.id, 'source', artifact.title || 'document') + } catch (err) { + setDownloadError(errorMessage(err, t('원본을 다운로드하지 못했습니다.'))) + } finally { + setDownloading(false) + } + } return (
+ {artifact.kind === 'code' && ( + <> +
+ + {headerControls} + +
+ {downloadError &&

{downloadError}

} + + )} {artifact.kind === 'html' && (
{( diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9335500d..3e794b92 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -606,12 +606,12 @@ export const keysApi = { } /** - * Downloads a report export. A plain link cannot carry the access token, so + * Downloads an artifact export. A plain link cannot carry the access token, so * the file is fetched and handed to the browser as a blob. */ export async function downloadArtifact( id: string, - format: 'docx' | 'pdf' | 'hwpx' | 'pptx' | 'md' | 'html', + format: 'docx' | 'pdf' | 'hwpx' | 'pptx' | 'md' | 'html' | 'source', title: string, ) { const res = await fetch(`${BASE_URL}/artifacts/${id}/export?format=${format}`, { @@ -620,10 +620,23 @@ export async function downloadArtifact( }) if (!res.ok) throw new ApiError(res.status, await readDetail(res)) + let filename = `${title.replace(/[\\/:*?"<>|]+/g, '_').slice(0, 60) || 'report'}.${format === 'source' ? 'txt' : format}` + if (format === 'source') { + // The owner-scoped endpoint chooses the extension from stored language, + // never from content sniffing in the browser. + const encoded = res.headers.get('Content-Disposition')?.match(/(?:^|;)\s*filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) { + try { + filename = decodeURIComponent(encoded).replace(/[\\/:*?"<>|]+/g, '_').slice(0, 180) || filename + } catch { + // An invalid or absent filename keeps the conservative text fallback. + } + } + } const url = URL.createObjectURL(await res.blob()) const anchor = document.createElement('a') anchor.href = url - anchor.download = `${title.replace(/[\\/:*?"<>|]+/g, '_').slice(0, 60) || 'report'}.${format}` + anchor.download = filename anchor.click() URL.revokeObjectURL(url) } diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index 294b29c0..db78944d 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -342,6 +342,8 @@ const EN: Record = { '다시 쓰기': 'Rewrite', '다시 연결': 'Reconnect', '다운로드': 'Download', + '원본 다운로드': 'Download source', + '원본을 다운로드하지 못했습니다.': 'Could not download the source file.', '다음 리필': 'Next refill', '답변': 'Answer', '대규모': 'Large',