Skip to content

Commit 1401873

Browse files
authored
feat(artifacts): preview markdown slide decks
1 parent de27fb5 commit 1401873

10 files changed

Lines changed: 484 additions & 4 deletions

File tree

docs/design/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
| [ui-components-detail.md](./ui-components-detail.md) || 全コンポーネントJSX、状態管理、エラー/ローディング/空状態、トースト |
2323
| [tool-ui-refinement-design.md](./tool-ui-refinement-design.md) || ツール実行結果の表示改善設計 |
2424
| [artifact-panel-detail.md](./artifact-panel-detail.md) || アーティファクトパネル、ファイルプレビュー、サンドボックス |
25+
| [artifact-slides.md](./artifact-slides.md) | 設計 | HTML / Markdown スライド artifact と将来 PPTX 出力への接続 |
2526
| [sandbox-implementation.md](./sandbox-implementation.md) || HTMLサンドボックス、postMessage通信 |
2627
| [tab-management.md](./tab-management.md) || タブ一覧・切替・新規作成ツール |
2728

docs/design/artifact-panel-detail.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,16 @@ export interface ArtifactSlice {
169169
分岐:
170170

171171
- `type === "html"``HtmlPreview` (extension `sandbox.html` iframe で実行)
172+
- `*.slides.html` / `*.deck.html` は HTML 内に 1 ファイル完結のスライド UI を持たせる。ArtifactPreview 側では通常の `HtmlPreview` を使う
172173
- `type === "markdown"``MarkdownPreview` (react-markdown)
174+
- `*.slides.md` / `*.deck.md``MarkdownSlidePreview``---` 区切りのスライドとして表示する
173175
- `type === "image"``ImagePreview` (`kind: "file"` の bytes を data URL に変換)
174176
- `type === "json"``DataPreview` (テーブル / 整形 / ソース タブ)
175177
- その他の code 拡張子 → `CodePreview`
176178
- それ以外 → `DefaultPreview`
177179

180+
HTML / Markdown スライド artifact の詳細は [artifact-slides.md](./artifact-slides.md) を参照。
181+
178182
### HtmlPreview (sandbox iframe)
179183

180184
Extension Pages の CSP により `new Function()` / `eval()` は不可。そのため `public/sandbox.html` を iframe で読み込み、`postMessage` で HTML を流し込んで `document.write()` + console 転送で実行する ([ADR-005](../decisions/005-extension-page-csp-constraints.md) 参照)。

docs/design/artifact-slides.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Artifact Slides 詳細設計
2+
3+
## 概要
4+
5+
Artifact Panel で HTML / Markdown ベースのスライドを扱えるようにする。初期対応では **新しい ArtifactType は追加せず**、既存の `html` / `markdown` artifact をファイル名規約でスライドとして判定する。
6+
7+
将来的な PPTX 出力に備え、Markdown スライドの分割ロジックは UI コンポーネントに閉じ込めず、純粋関数として `shared` に置く。
8+
9+
## 目標
10+
11+
- AI が `saveArtifact()` / `artifacts` tool でスライドを作りやすくする
12+
- `*.slides.html` / `*.deck.html` は HTML スライドとして扱う
13+
- `*.slides.md` / `*.deck.md` は Markdown スライドとして扱う
14+
- Markdown スライドは `---` 区切りで 1 枚ずつ表示できる
15+
- 既存の通常 HTML / Markdown artifact の挙動を変えない
16+
- 将来の PPTX 生成に使える Markdown slide AST を用意する
17+
18+
## 非目標
19+
20+
- 初期対応では `.pptx` の生成・プレビューは実装しない
21+
- 初期対応では Reveal.js / Marp など外部ライブラリには依存しない
22+
- 初期対応ではスライドアニメーション、発表者ノート、テーマ編集 UI は持たない
23+
- 通常の `.html` / `.md` を自動でスライド扱いしない
24+
25+
## ファイル名規約
26+
27+
スライド artifact として扱うファイル名:
28+
29+
```txt
30+
*.slides.html
31+
*.deck.html
32+
*.slides.md
33+
*.deck.md
34+
```
35+
36+
通常 artifact のまま扱うファイル名:
37+
38+
```txt
39+
*.html
40+
*.md
41+
*.markdown
42+
```
43+
44+
この規約により、既存の `ArtifactType = "html" | "markdown"` を維持したまま preview だけを切り替える。
45+
46+
## データモデル
47+
48+
### ArtifactType は増やさない
49+
50+
```ts
51+
export type ArtifactType = "json" | "html" | "markdown" | "text" | "image" | "binary";
52+
```
53+
54+
`slide` type は追加しない。理由:
55+
56+
- storage / adapter / handler の影響範囲を広げない
57+
- `html` / `markdown` として保存・取得できる既存互換を維持する
58+
- PPTX 対応時も、まずは Markdown slide AST から別ファイルを生成すればよい
59+
60+
### shared helper
61+
62+
```ts
63+
// src/shared/artifact-types.ts
64+
export function isSlideArtifactName(name: string): boolean;
65+
export function isHtmlSlideArtifactName(name: string): boolean;
66+
export function isMarkdownSlideArtifactName(name: string): boolean;
67+
```
68+
69+
```ts
70+
// src/shared/slide-markdown.ts
71+
export interface MarkdownSlide {
72+
index: number;
73+
markdown: string;
74+
}
75+
76+
export function parseMarkdownSlides(markdown: string): MarkdownSlide[];
77+
```
78+
79+
`parseMarkdownSlides` の仕様:
80+
81+
- 単独行の `---` でスライド分割する
82+
- fenced code block 内の `---` は区切りにしない
83+
- 前後の空白は slide ごとに `trim()` する
84+
- 空 slide は除外する
85+
- 区切りが無い非空 markdown は全体を 1 枚として扱う
86+
- 空文字や区切りだけで内容が無い場合、parser は空配列を返し、UI 側で空の 1 枚表示にフォールバックする
87+
88+
## UI 設計
89+
90+
### HTML スライド
91+
92+
`*.slides.html` / `*.deck.html` は既存の `HtmlPreview` を使う。
93+
94+
HTML 側で 1 ファイル完結のスライドアプリにすることを推奨する:
95+
96+
```html
97+
<section class="slide">
98+
<h1>Title</h1>
99+
<p>Content</p>
100+
</section>
101+
```
102+
103+
推奨テンプレートは tool description に書く。初期対応では `ArtifactPreview` 側に HTML 専用のスライド操作 UI は追加しない。HTML 内の JS で左右キー、ページ番号、印刷 CSS を持たせる。
104+
105+
### Markdown スライド
106+
107+
`*.slides.md` / `*.deck.md``MarkdownSlidePreview` で表示する。
108+
109+
UI:
110+
111+
- `表示` tab: 現在の slide だけを `MarkdownContent` で描画
112+
- `ソース` tab: 元 markdown 全体を `CodeView` で表示
113+
- 前へ / 次へボタン
114+
- `n / total` のページ表示
115+
- 左右キーでページ移動
116+
117+
初期レイアウト:
118+
119+
```txt
120+
┌──────────────────────────────┐
121+
│ 表示 | ソース ◀ 1/5 ▶ │
122+
├──────────────────────────────┤
123+
│ │
124+
│ Slide body │
125+
│ │
126+
└──────────────────────────────┘
127+
```
128+
129+
スライド表示領域は Artifact Panel 内に収める。プレゼン全画面モードは初期対応では持たない。
130+
131+
## AI / tool description 方針
132+
133+
`ArtifactProvider.getDescription()` と top-level `artifactsTool.description` に以下を追記する。
134+
135+
- スライドを作る場合は `*.slides.md` または `*.slides.html` を推奨
136+
- Markdown スライドは `---` 区切り
137+
- HTML スライドは 1 ファイル完結で CSS / JS を含める
138+
- 将来 PPTX 化したい場合は、まず `*.slides.md` を作るのが望ましい
139+
140+
推奨順:
141+
142+
1. 文章中心・後で PPTX 化したい → `deck.slides.md`
143+
2. 視覚表現やインタラクション重視 → `deck.slides.html`
144+
145+
## 将来の PPTX 出力
146+
147+
PPTX 対応時は以下の流れを想定する。
148+
149+
```txt
150+
*.slides.md
151+
↓ parseMarkdownSlides()
152+
MarkdownSlide[]
153+
↓ pptx generator
154+
*.pptx artifact
155+
```
156+
157+
初期対応では `MarkdownSlide` を「タイトル・本文・画像」などに細かく構造化しない。PPTX 対応時に必要になったら、`MarkdownSlide` の markdown 本文を追加 parser に渡す。
158+
159+
## 変更予定ファイル
160+
161+
```txt
162+
src/shared/artifact-types.ts
163+
src/shared/slide-markdown.ts
164+
src/shared/__tests__/slide-markdown.test.ts
165+
src/features/artifacts/ArtifactPreview.tsx
166+
src/features/artifacts/__tests__/types.test.ts
167+
src/features/artifacts/__tests__/ArtifactPanel.test.ts
168+
src/features/tools/providers/artifact-provider.ts
169+
src/features/tools/definitions/artifacts-tool.ts
170+
```
171+
172+
## テスト方針
173+
174+
### Unit
175+
176+
- `isSlideArtifactName()` が対象ファイルだけ true を返す
177+
- `parseMarkdownSlides()``---` で分割する
178+
- fenced code block 内の `---` を分割しない
179+
- 空 slide を除外する
180+
181+
### Component
182+
183+
- `.slides.md``MarkdownSlidePreview` を表示する
184+
- 通常 `.md` は従来の `MarkdownPreview` を表示する
185+
- next / prev 操作で表示 slide が変わる
186+
- source tab で元 markdown を表示する
187+
188+
### Tool description
189+
190+
- `saveArtifact` / `artifacts` の説明に slide artifact の推奨ファイル名が含まれる
191+
192+
## 実装順
193+
194+
1. `isSlideArtifactName``parseMarkdownSlides` の failing test を追加
195+
2. shared helper を実装
196+
3. `MarkdownSlidePreview` を追加
197+
4. `ArtifactPreview` の markdown 分岐に slide 判定を追加
198+
5. tool description を更新
199+
6. 関連テストと build を通す

src/features/artifacts/ArtifactPreview.tsx

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { useEffect, useRef, useState } from "react";
2-
import { Box, Group, Tabs, ActionIcon, Text, Paper, useComputedColorScheme } from "@mantine/core";
2+
import {
3+
Box,
4+
Group,
5+
Tabs,
6+
ActionIcon,
7+
Text,
8+
Paper,
9+
Button,
10+
useComputedColorScheme,
11+
} from "@mantine/core";
312
import { RotateCw } from "lucide-react";
4-
import type { ArtifactEntry } from "./types";
13+
import { parseMarkdownSlides } from "@/shared/slide-markdown";
514
import { MarkdownContent } from "@/shared/ui/MarkdownContent";
615
import { CodeView } from "./CodeView";
16+
import { isMarkdownSlideArtifactName, type ArtifactEntry } from "./types";
717

818
interface ArtifactData {
919
name: string;
@@ -290,6 +300,73 @@ function MarkdownPreview({ content, name }: { content: string; name: string }) {
290300
);
291301
}
292302

303+
function MarkdownSlidePreview({ content, name }: { content: string; name: string }) {
304+
const slides = parseMarkdownSlides(content);
305+
const [activeTab, setActiveTab] = useState<string>("rendered");
306+
const [current, setCurrent] = useState(0);
307+
const total = Math.max(slides.length, 1);
308+
const currentSlide = slides[current]?.markdown ?? "";
309+
310+
useEffect(() => {
311+
setCurrent(0);
312+
setActiveTab("rendered");
313+
}, [content, name]);
314+
315+
const goTo = (next: number) => setCurrent(Math.min(Math.max(next, 0), total - 1));
316+
317+
return (
318+
<Box
319+
h="100%"
320+
tabIndex={0}
321+
onKeyDown={(event) => {
322+
if (event.key === "ArrowLeft") goTo(current - 1);
323+
if (event.key === "ArrowRight") goTo(current + 1);
324+
}}
325+
style={{ display: "flex", flexDirection: "column", outline: "none" }}
326+
>
327+
<Group
328+
px="sm"
329+
py={4}
330+
gap="xs"
331+
style={{ borderBottom: "1px solid var(--mantine-color-default-border)", flexShrink: 0 }}
332+
>
333+
<Tabs value={activeTab} onChange={(v) => v && setActiveTab(v)} style={{ flex: 1 }}>
334+
<Tabs.List>
335+
<Tabs.Tab value="rendered">表示</Tabs.Tab>
336+
<Tabs.Tab value="source">ソース</Tabs.Tab>
337+
</Tabs.List>
338+
</Tabs>
339+
<Button
340+
size="xs"
341+
variant="subtle"
342+
disabled={current === 0}
343+
onClick={() => goTo(current - 1)}
344+
>
345+
前へ
346+
</Button>
347+
<Text size="xs" c="dimmed" miw={48} ta="center">
348+
{current + 1} / {total}
349+
</Text>
350+
<Button
351+
size="xs"
352+
variant="subtle"
353+
disabled={current >= total - 1}
354+
onClick={() => goTo(current + 1)}
355+
>
356+
次へ
357+
</Button>
358+
</Group>
359+
<Box flex={1} style={{ overflow: "auto", padding: "var(--mantine-spacing-md)" }}>
360+
{activeTab === "rendered" ? (
361+
<MarkdownContent content={currentSlide} />
362+
) : (
363+
<CodeView content={content} filename={name} />
364+
)}
365+
</Box>
366+
</Box>
367+
);
368+
}
369+
293370
function CodePreview({ content, name }: { content: string; name: string }) {
294371
return <CodeView content={content} filename={name} />;
295372
}
@@ -519,6 +596,8 @@ export function ArtifactPreview({ data }: ArtifactPreviewProps) {
519596
<Box flex={1} style={{ minHeight: 0, overflow: "hidden" }}>
520597
{type === "html" ? (
521598
<HtmlPreview content={stringContent} name={name} />
599+
) : type === "markdown" && isMarkdownSlideArtifactName(name) ? (
600+
<MarkdownSlidePreview content={stringContent} name={name} />
522601
) : type === "markdown" ? (
523602
<MarkdownPreview content={stringContent} name={name} />
524603
) : type === "image" ? (

0 commit comments

Comments
 (0)