Refactor project layout and fix workspace/subagent paths

- Remove global workspace (workspace/) — all data migrated to .smallclaw/
- Move ppt/ skins & templates to .smallclaw/skills/presenter/ppt/
- Add .smallclaw/templates/ for user bootstrap files
- Move dental_images to .smallclaw/databases/, update 1,485 DB entries
- Fix subagent store paths to use SMALLCLAW_DATA_DIR instead of workspace.path
- Per-user Telegram bots: MultiUserTelegramManager, vault-stored tokens
- Fix Telegram token persistence (vault fallback, strip from config.json)
- Fix session migration: delete global originals even when skipping
- Delete unused root images, MD docs, tests/, smallclawworkspace/ artifacts
- Add meteorologist skill, counselor skill, presenter skill scaffold
- Strip workspace.path from config.json (was causing path confusion)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-28 15:21:21 +09:00
co-authored by Claude Sonnet 4.6
parent d7eb0abc6d
commit 5608b83850
146 changed files with 1653 additions and 14870 deletions
+442 -10
View File
@@ -557,14 +557,18 @@ def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
return slide
def make_section_slide(prs, slide_spec, colors, fs):
def make_section_slide(prs, slide_spec, colors, fs, bg_image=None):
"""Generate a section divider slide."""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, colors.get("accent", "1668E3"))
if bg_image:
set_slide_bg_image(slide, bg_image)
else:
add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, colors.get("accent", "1668E3"))
title = slide_spec.get("title") or "Section"
text_color = "FFFFFF"
add_text_box(slide, Inches(1), Inches(2.5), Inches(11.3), Inches(1.5),
title, font_size=Pt(fs["section"]), color_hex="FFFFFF",
title, font_size=Pt(fs["section"]), color_hex=text_color,
bold=True, alignment=PP_ALIGN.CENTER)
return slide
@@ -627,6 +631,40 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04),
colors["accent"])
# ── Compare layout: two text columns side by side ─────────────────────────
if layout == "compare":
col_w = (CONTENT_W - COL_GAP) / 2
right_x = L_MARGIN + col_w + COL_GAP
# Vertical divider
div_x = L_MARGIN + col_w + COL_GAP / 2 - Inches(0.02)
add_shape_rect(slide, div_x, content_y + Inches(0.1),
Inches(0.04), content_h - Inches(0.2), colors["accent"])
for col_x, t_key, b_key, pts_key, body_key in [
(L_MARGIN, "left_title", "left_bullets", "left_points", "left_body"),
(right_x, "right_title", "right_bullets", "right_points", "right_body"),
]:
col_title = slide_spec.get(t_key) or ""
col_bullets = slide_spec.get(b_key) or slide_spec.get(pts_key) or []
col_body = slide_spec.get(body_key) or ""
sub_y = content_y
sub_h = content_h
if col_title:
add_text_box(slide, col_x, sub_y, col_w, Inches(0.5),
col_title, font_size=Pt(fs["slide_title"] - 2),
color_hex=colors["accent"], bold=True)
sub_y += Inches(0.6)
sub_h -= Inches(0.6)
if col_bullets:
add_bullet_list(slide, col_x, sub_y, col_w, sub_h,
col_bullets, font_size=Pt(fs["bullets"]),
color_hex=colors["body"],
bullet_color_hex=colors["accent"])
elif col_body:
add_text_box(slide, col_x, sub_y, col_w, sub_h,
col_body, font_size=Pt(fs["body"]),
color_hex=colors["body"])
return slide
# ── Column geometry ───────────────────────────────────────────────────────
if has_image:
text_ratio = _auto_text_ratio(slide_spec)
@@ -712,6 +750,259 @@ def make_image_slide(prs, slide_spec, project_dir, workspace_path, colors, warni
return slide
# ─── Table / Chart / Timeline Slide Generators ─────────────────────────────────
def _style_table_cell(cell, text, font_pt, font_name, color_hex, bold=False,
alignment=PP_ALIGN.LEFT, bg_hex=None):
"""Set text and styling on a python-pptx table cell."""
tf = cell.text_frame
tf.word_wrap = True
cell.text = str(text) if text is not None else ""
p = tf.paragraphs[0]
p.alignment = alignment
if p.runs:
run = p.runs[0]
run.font.size = Pt(font_pt)
run.font.bold = bold
run.font.name = font_name
run.font.color.rgb = hex_to_rgb(color_hex)
if bg_hex:
cell.fill.solid()
cell.fill.fore_color.rgb = hex_to_rgb(bg_hex)
def make_table_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None):
"""Generate a table slide with optional header row and data rows."""
slide = prs.slides.add_slide(prs.slide_layouts[6])
if bg_image:
set_slide_bg_image(slide, bg_image)
elif is_dark:
set_slide_bg(slide, colors["background"])
has_title = bool(slide_spec.get("title"))
if has_title:
add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8),
slide_spec["title"], font_size=Pt(fs["slide_title"]),
color_hex=colors["title"], bold=True)
add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04), colors["accent"])
headers = slide_spec.get("headers") or []
rows = slide_spec.get("rows") or []
col_count = len(headers) if headers else (len(rows[0]) if rows else 0)
if col_count == 0:
return slide
has_header_row = bool(headers)
row_count = len(rows) + (1 if has_header_row else 0)
if row_count == 0:
return slide
table_top = Inches(1.5) if has_title else Inches(0.6)
HEADER_H = Inches(0.5)
ROW_H = Inches(0.55)
ideal_h = (HEADER_H if has_header_row else 0) + len(rows) * ROW_H
available_h = SLIDE_H - table_top - Inches(0.4)
table_h = min(ideal_h, available_h)
if table_h < available_h:
table_top = int(table_top + (available_h - table_h) // 2)
table_w = int(SLIDE_W * 0.88)
table_left = (SLIDE_W - table_w) // 2
tbl = slide.shapes.add_table(row_count, col_count, table_left, table_top, table_w, table_h).table
# Set explicit row heights so python-pptx doesn't stretch them evenly
for ri2 in range(row_count):
tbl.rows[ri2].height = HEADER_H if (has_header_row and ri2 == 0) else ROW_H
fn = get_font()
body_pt = fs.get("body", 16)
header_pt = min(body_pt, 15)
if has_header_row:
for j, h in enumerate(headers[:col_count]):
_style_table_cell(tbl.cell(0, j), h, header_pt, fn,
"FFFFFF", bold=True, alignment=PP_ALIGN.CENTER,
bg_hex=colors.get("accent", "1668E3"))
for i, row in enumerate(rows):
ri = i + (1 if has_header_row else 0)
if ri >= row_count:
break
if is_dark:
bg = "2A3040" if i % 2 == 0 else "222836"
else:
bg = "F2F4F8" if i % 2 == 0 else "FFFFFF"
for j, val in enumerate(row[:col_count]):
_style_table_cell(tbl.cell(ri, j), val, body_pt, fn,
colors.get("body", "2D3748"), bg_hex=bg)
return slide
def make_chart_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None):
"""Generate a chart slide (column/bar/line/pie/doughnut)."""
try:
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE
except ImportError:
warnings.append("Chart support requires python-pptx >= 0.6.18")
return prs.slides.add_slide(prs.slide_layouts[6])
slide = prs.slides.add_slide(prs.slide_layouts[6])
if bg_image:
set_slide_bg_image(slide, bg_image)
elif is_dark:
set_slide_bg(slide, colors["background"])
has_title = bool(slide_spec.get("title"))
if has_title:
add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8),
slide_spec["title"], font_size=Pt(fs["slide_title"]),
color_hex=colors["title"], bold=True)
add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04), colors["accent"])
type_map = {
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
"bar_stacked": XL_CHART_TYPE.BAR_STACKED,
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
"column_stacked": XL_CHART_TYPE.COLUMN_STACKED,
"line": XL_CHART_TYPE.LINE,
"line_markers": XL_CHART_TYPE.LINE_MARKERS,
"pie": XL_CHART_TYPE.PIE,
"doughnut": XL_CHART_TYPE.DOUGHNUT,
}
xl_type = type_map.get((slide_spec.get("chart_type") or "column").lower(),
XL_CHART_TYPE.COLUMN_CLUSTERED)
categories = slide_spec.get("categories") or []
series_data = slide_spec.get("series") or []
if not categories or not series_data:
warnings.append(f"Chart slide '{slide_spec.get('title', '')}' missing categories or series — skipped")
return slide
chart_data = ChartData()
chart_data.categories = [str(c) for c in categories]
for s in series_data:
name = str(s.get("name") or s.get("label") or "Series")
values = [float(v) if v is not None else 0.0 for v in (s.get("values") or s.get("data") or [])]
chart_data.add_series(name, values)
chart_top = Inches(1.5) if has_title else Inches(0.5)
chart_frame = slide.shapes.add_chart(
xl_type, L_MARGIN, chart_top,
SLIDE_W - L_MARGIN - R_MARGIN, SLIDE_H - chart_top - Inches(0.4),
chart_data,
)
chart = chart_frame.chart
# Hide built-in chart title (slide title is sufficient)
chart.has_title = False
if len(series_data) > 1:
chart.has_legend = True
# Apply white text on dark backgrounds
if is_dark:
from pptx.dml.color import RGBColor
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
for _axis in [chart.value_axis, chart.category_axis]:
try:
_axis.tick_labels.font.color.rgb = WHITE
except Exception:
pass
try:
if _axis.has_title:
for _para in _axis.axis_title.text_frame.paragraphs:
for _run in _para.runs:
_run.font.color.rgb = WHITE
except Exception:
pass
if chart.has_legend:
try:
chart.legend.font.color.rgb = WHITE
except Exception:
pass
for plot in chart.plots:
try:
plot.data_labels.font.color.rgb = WHITE
except Exception:
pass
return slide
def make_timeline_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None):
"""Generate a horizontal timeline slide with alternating labels above/below."""
from pptx.enum.shapes import MSO_SHAPE_TYPE
slide = prs.slides.add_slide(prs.slide_layouts[6])
if bg_image:
set_slide_bg_image(slide, bg_image)
elif is_dark:
set_slide_bg(slide, colors["background"])
has_title = bool(slide_spec.get("title"))
if has_title:
add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8),
slide_spec["title"], font_size=Pt(fs["slide_title"]),
color_hex=colors["title"], bold=True)
add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04), colors["accent"])
events = slide_spec.get("events") or []
if not events:
return slide
n = len(events)
accent = colors.get("accent", "1668E3")
body_color = colors.get("body", "2D3748")
line_y = Inches(4.0)
line_left = Inches(1.2)
line_right = SLIDE_W - Inches(1.2)
line_len = line_right - line_left
# Horizontal axis bar
add_shape_rect(slide, line_left, line_y - Inches(0.025), line_len, Inches(0.05), accent)
dot_r = Inches(0.18)
label_w = Inches(1.9)
for i, event in enumerate(events):
cx = line_left + (line_len * i / (n - 1) if n > 1 else line_len / 2)
# Oval dot
from pptx.enum.shapes import MSO_SHAPE
dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, cx - dot_r, line_y - dot_r, dot_r * 2, dot_r * 2)
dot.fill.solid()
dot.fill.fore_color.rgb = hex_to_rgb(accent)
dot.line.fill.background()
label = str(event.get("year") or event.get("label") or str(i + 1))
desc = str(event.get("text") or event.get("description") or "")
lx = cx - label_w / 2
if i % 2 == 0:
# Label above line
add_text_box(slide, lx, line_y - Inches(1.35), label_w, Inches(0.5),
label, font_size=Pt(14), color_hex=accent,
bold=True, alignment=PP_ALIGN.CENTER)
if desc:
add_text_box(slide, lx, line_y - Inches(0.85), label_w, Inches(0.55),
desc, font_size=Pt(11), color_hex=body_color,
alignment=PP_ALIGN.CENTER)
else:
# Label below line
add_text_box(slide, lx, line_y + Inches(0.35), label_w, Inches(0.5),
label, font_size=Pt(14), color_hex=accent,
bold=True, alignment=PP_ALIGN.CENTER)
if desc:
add_text_box(slide, lx, line_y + Inches(0.85), label_w, Inches(0.55),
desc, font_size=Pt(11), color_hex=body_color,
alignment=PP_ALIGN.CENTER)
return slide
# ─── Download Page Generator ────────────────────────────────────────────────────
def _create_download_page(pptx_path: str, download_url: str, title: str, slide_count: int):
@@ -760,6 +1051,8 @@ def generate(spec: dict, workspace_path: str) -> dict:
project_slug = slugify(title)
# Derive filename from project slug if not explicitly provided
filename = spec.get("filename", "").replace(" ", "_") if spec.get("filename") else f"{project_slug}.pptx"
if not filename.endswith(".pptx"):
filename += ".pptx"
slides_spec = spec.get("slides") or []
theme = spec.get("theme") or "light"
is_dark = theme == "dark"
@@ -772,6 +1065,73 @@ def generate(spec: dict, workspace_path: str) -> dict:
existing_path = spec.get("existing_path", "")
is_edit = bool(existing_path) and os.path.exists(existing_path)
default_skin = spec.get("default_skin") or ""
# Auto-detect dark theme from existing presentation when editing without explicit theme.
# Strategy 1: solid background fill → check luminance.
# Auto-detect dark theme + extract background image from existing presentation.
# When editing without explicit theme, inherit the visual style of the existing slides.
inherited_bg_image = None # path to extracted background_skin image, if any
# Skip auto-detection when default_skin is explicitly provided — derive darkness from skin name.
if is_edit and not spec.get("theme") and default_skin:
if is_dark_skin(default_skin):
is_dark = True
theme = "dark"
elif is_edit and not spec.get("theme"):
try:
from pptx import Presentation as _Prs
_prs_check = _Prs(existing_path)
_detected = False
_bg_image_path = None
# Check each existing slide for background_skin or solid dark fill
for _slide_check in _prs_check.slides:
# Strategy 1: background_skin picture → extract image and reuse
for _sh in _slide_check.shapes:
if _sh.name == "background_skin":
_detected = True
try:
_img = _sh.image
_ext = _img.ext or "jpg"
_bg_image_path = os.path.join(
os.path.dirname(existing_path),
f"_inherited_bg.{_ext}"
)
with open(_bg_image_path, "wb") as _f:
_f.write(_img.blob)
except Exception:
pass
break
if _detected:
break
# Strategy 2: solid dark fill
try:
_bg = _slide_check.background.fill
if str(_bg.type) == "SOLID (1)":
_rgb = _bg.fore_color.rgb
if (int(_rgb[0]) + int(_rgb[1]) + int(_rgb[2])) / 765.0 < 0.4:
_detected = True
break
except Exception:
pass
# Strategy 3: white/light font → dark bg
for _sh in _slide_check.shapes:
if _sh.has_text_frame and _sh.text_frame.text.strip():
try:
_fc = _sh.text_frame.paragraphs[0].runs[0].font.color.rgb
if (int(_fc[0]) + int(_fc[1]) + int(_fc[2])) / 765.0 > 0.7:
_detected = True
except Exception:
pass
break
if _detected:
break
if _detected:
is_dark = True
theme = "dark"
inherited_bg_image = _bg_image_path
print(f"[pptx_gen] edit: auto-detected dark theme, bg_image={_bg_image_path}", file=sys.stderr, flush=True)
except Exception:
pass
# Create project folder (no separate images/ subdir — images go directly in project dir)
if is_edit:
@@ -809,9 +1169,6 @@ def generate(spec: dict, workspace_path: str) -> dict:
tpl = resolve_template(tpl_name)
tpl_colors = get_template_colors(tpl)
# Default skin from spec or empty
default_skin = spec.get("default_skin") or ""
# Determine base colors: dark theme overrides template
if is_dark:
colors = COLORS_DARK
@@ -827,7 +1184,47 @@ def generate(spec: dict, workspace_path: str) -> dict:
prs.slide_height = SLIDE_H
start_slide_num = 0
for slide_spec in slides_spec:
# Collect replace_index targets (1-based → 0-based)
replace_targets = {} # slide_spec_index → zero-based position to replace
for i, s in enumerate(slides_spec):
ri = s.get("replace_index")
if ri is not None:
pos = int(ri) - 1
if is_edit and 0 <= pos < len(prs.slides):
replace_targets[i] = pos
else:
warnings.append(f"replace_index {ri} out of range — slide will be appended instead")
# Track the sldIdLst entry added for each spec index (for post-pass reordering)
spec_sld_entries = {} # spec_index → sldIdLst XML element
_SLIDE_TYPES = {"title", "content", "section", "image", "table", "chart", "timeline"}
for spec_i, slide_spec in enumerate(slides_spec):
# Normalize spec variants produced by different models
slide_spec = dict(slide_spec) # shallow copy — don't mutate original
# 1. Infer `type` from `layout` when missing (e.g. Kimi uses layout="chart")
if not slide_spec.get("type"):
raw_layout = (slide_spec.get("layout") or "").lower()
if raw_layout in _SLIDE_TYPES:
slide_spec["type"] = raw_layout
slide_spec.pop("layout", None)
# 2. camelCase chartType → chart_type
if "chartType" in slide_spec and "chart_type" not in slide_spec:
slide_spec["chart_type"] = slide_spec["chartType"]
# 3. Nested table object → flatten (table / table_data both supported)
for _tbl_key in ("table", "table_data"):
if _tbl_key in slide_spec and isinstance(slide_spec.get(_tbl_key), dict):
tbl = slide_spec[_tbl_key]
if "headers" not in slide_spec and "headers" in tbl:
slide_spec["headers"] = tbl["headers"]
if "rows" not in slide_spec and "rows" in tbl:
slide_spec["rows"] = tbl["rows"]
# 4. content field is a list → treat as bullets
if isinstance(slide_spec.get("content"), list) and "bullets" not in slide_spec:
slide_spec["bullets"] = [str(b) for b in slide_spec["content"]]
slide_spec.pop("content", None)
slide_type = slide_spec.get("type") or "content"
try:
@@ -845,6 +1242,11 @@ def generate(spec: dict, workspace_path: str) -> dict:
# Not a skin name — might be a file path already resolved
pass
# Inherit background image from existing presentation if none specified
if not bg_image and inherited_bg_image and os.path.exists(inherited_bg_image):
bg_image = inherited_bg_image
slide_is_dark = is_dark # use global theme, not always-dark
# Pick colors for this slide: dark skin → light text on dark bg
if slide_is_dark and not is_dark:
slide_colors = COLORS_DARK
@@ -853,14 +1255,19 @@ def generate(spec: dict, workspace_path: str) -> dict:
else:
slide_colors = colors
# Section slides always use accent fill — no background image
if slide_type == "section":
slide = make_section_slide(prs, slide_spec, slide_colors, fs)
slide = make_section_slide(prs, slide_spec, slide_colors, fs, bg_image)
elif slide_type == "title":
slide = make_title_slide(prs, slide_spec, spec, slide_colors, slide_is_dark, fs, bg_image)
elif slide_type == "image":
slide = make_image_slide(prs, slide_spec, project_dir, workspace_path,
slide_colors, warnings, slide_is_dark, fs, bg_image)
elif slide_type == "table":
slide = make_table_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image)
elif slide_type == "chart":
slide = make_chart_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image)
elif slide_type == "timeline":
slide = make_timeline_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image)
else: # content or default
slide = make_content_slide(prs, slide_spec, project_dir, workspace_path, slide_colors, slide_is_dark, fs, warnings, bg_image)
@@ -868,11 +1275,36 @@ def generate(spec: dict, workspace_path: str) -> dict:
notes = slide_spec.get("notes")
if notes and hasattr(slide, "notes_slide"):
slide.notes_slide.notes_text_frame.text = notes
# Track the newly added sldIdLst entry for replacement slides
if spec_i in replace_targets:
spec_sld_entries[spec_i] = prs.slides._sldIdLst[-1]
except Exception as e:
err_msg = f"Failed to generate slide (type={slide_type}, title={slide_spec.get('title','')[:30]}): {e}"
warnings.append(err_msg)
print(f"[pptx_gen] ERROR: {err_msg}", file=sys.stderr, flush=True)
# Post-pass: now that new slides are added (safe filenames), remove old slides
# and move new ones into their target positions.
# IMPORTANT: add-before-remove avoids _next_slide_partname collisions.
if replace_targets and is_edit:
sldIdLst = prs.slides._sldIdLst
# Remove old slides in reverse position order (keeps lower indices stable)
for spec_i in sorted(replace_targets.keys(), key=lambda k: replace_targets[k], reverse=True):
pos = replace_targets[spec_i]
if 0 <= pos < len(prs.slides):
slide_part = prs.slides[pos].part
for rId, rel in list(prs.part.rels.items()):
if rel.reltype.endswith('/slide') and rel.target_partname == slide_part.partname:
prs.part.drop_rel(rId)
break
sldIdLst.remove(sldIdLst[pos])
# Move new slides from end to target positions (ascending order)
for spec_i, target_pos in sorted(replace_targets.items(), key=lambda kv: kv[1]):
entry = spec_sld_entries.get(spec_i)
if entry is not None and entry in sldIdLst:
sldIdLst.remove(entry)
sldIdLst.insert(target_pos, entry)
# Save
try:
prs.save(output_path)
@@ -893,7 +1325,7 @@ def generate(spec: dict, workspace_path: str) -> dict:
# Compose stdout with download link so UI can render it inline
# Do NOT include [Preview slides] link — it causes the model to think more work is needed
stdout_text = f"Presentation {action}: [{os.path.basename(output_path)}]({download_url}) ({total_slides} total slides, {added_slides} added, folder: {os.path.basename(project_dir)}/)"
stdout_text = f"Presentation {action}: [{os.path.basename(output_path)}]({download_url}) ({total_slides} total slides, {added_slides} added, folder: {os.path.basename(project_dir)}/)\nEXACT PATH (use this verbatim for future edits): {rel_for_link}"
if warnings:
stdout_text += "\n\nWarnings:\n" + "\n".join(f"- {w}" for w in warnings)
if any("download" in w.lower() or "image" in w.lower() for w in warnings):