Skip to content

Commit 820f1b3

Browse files
committed
Phase 4 완료: 외부 연결, 모바일 반응형, 익스텐션 프레임워크
외부 연결: - http-request AI 도구: GET/POST/PUT/PATCH/DELETE, 타임아웃, URL 스킴 차단 - Webhook 트리거: POST /api/webhooks/trigger/{taskId}로 외부에서 태스크 실행 모바일 반응형: - codaro-skin.css: 768px/480px 모바일 미디어쿼리 (셀 간격, 폰트, 사이드바) - Sidebar: 768px 이하에서 고정 오버레이로 전환 익스텐션 프레임워크: - Extension 모델: 6가지 capability (tool/renderer/panel/command/provider/theme) - ExtensionRegistry: 등록/해제, capability 필터, 동적 entry point 로딩 - CRUD API 4종 + capability 필터 조회 테스트: 266개 통과 (노트북 분할/생성 4개 추가)
1 parent 3ac1073 commit 820f1b3

11 files changed

Lines changed: 359 additions & 1 deletion

File tree

editor/src/lib/codaro/chrome/Sidebar.svelte

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,17 @@
208208
50% { opacity: 0.4; }
209209
}
210210
211+
@media (max-width: 768px) {
212+
.sidebar {
213+
position: fixed;
214+
z-index: 50;
215+
left: 0;
216+
top: 0;
217+
bottom: 0;
218+
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.2);
219+
}
220+
}
221+
211222
@media print {
212223
.sidebar {
213224
display: none;

editor/src/lib/codaro/styles/codaro-skin.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,47 @@
316316
transition: border-color 0.15s, box-shadow 0.15s;
317317
}
318318

319+
/* Mobile responsive */
320+
@media (max-width: 768px) {
321+
.codaro-cell {
322+
border-radius: 0 !important;
323+
border-left: none !important;
324+
border-right: none !important;
325+
}
326+
327+
.codaro-cell .cm-editor {
328+
font-size: 13px;
329+
}
330+
331+
.shoulder-right {
332+
right: 4px !important;
333+
gap: 2px !important;
334+
}
335+
336+
.shoulder-bottom {
337+
padding: 4px 8px !important;
338+
}
339+
340+
.toolbar-pill {
341+
padding: 4px 8px !important;
342+
font-size: 11px;
343+
}
344+
345+
.cell-actions-popover {
346+
min-width: auto !important;
347+
}
348+
}
349+
350+
@media (max-width: 480px) {
351+
.codaro-cell .cm-editor {
352+
font-size: 12px;
353+
}
354+
355+
.shoulder-right {
356+
display: none !important;
357+
}
358+
}
359+
319360
/* Print styles */
320361
@media print {
321362
.codaro-cell {

src/codaro/ai/toolExecutor.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,3 +600,39 @@ async def _handle_generateNotebook(self, args: dict[str, Any]) -> dict[str, Any]
600600
"saved": False,
601601
"loadedInEditor": self._documentSetter is not None,
602602
}
603+
604+
async def _handle_httpRequest(self, args: dict[str, Any]) -> dict[str, Any]:
605+
import urllib.request
606+
import urllib.error
607+
608+
method = args["method"]
609+
url = args["url"]
610+
headers = args.get("headers", {})
611+
body = args.get("body")
612+
timeout = min(args.get("timeout", 30), 60)
613+
614+
BLOCKED_PREFIXES = ("file://", "ftp://", "data:")
615+
if any(url.lower().startswith(p) for p in BLOCKED_PREFIXES):
616+
return {"error": f"URL scheme not allowed: {url.split(':')[0]}"}
617+
618+
try:
619+
data = body.encode("utf-8") if body else None
620+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
621+
with urllib.request.urlopen(req, timeout=timeout) as resp:
622+
respBody = resp.read(1024 * 512).decode("utf-8", errors="replace")
623+
return {
624+
"status": resp.status,
625+
"headers": dict(resp.headers),
626+
"body": respBody,
627+
"url": resp.url,
628+
}
629+
except urllib.error.HTTPError as exc:
630+
return {
631+
"status": exc.code,
632+
"error": str(exc.reason),
633+
"body": exc.read(1024 * 64).decode("utf-8", errors="replace"),
634+
}
635+
except urllib.error.URLError as exc:
636+
return {"error": f"Connection failed: {exc.reason}"}
637+
except TimeoutError:
638+
return {"error": f"Request timed out after {timeout}s"}

src/codaro/ai/tools.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,40 @@ def toolSchemas() -> list[dict[str, Any]]:
496496
)
497497

498498

499+
TOOL_HTTP_REQUEST = ToolDef(
500+
name="http-request",
501+
description="Make an HTTP request to an external API and return the response.",
502+
parameters={
503+
"type": "object",
504+
"properties": {
505+
"method": {
506+
"type": "string",
507+
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
508+
"description": "HTTP method.",
509+
},
510+
"url": {
511+
"type": "string",
512+
"description": "Target URL.",
513+
},
514+
"headers": {
515+
"type": "object",
516+
"description": "Request headers as key-value pairs.",
517+
},
518+
"body": {
519+
"type": "string",
520+
"description": "Request body (for POST/PUT/PATCH).",
521+
},
522+
"timeout": {
523+
"type": "integer",
524+
"description": "Timeout in seconds (default 30).",
525+
},
526+
},
527+
"required": ["method", "url"],
528+
},
529+
handler="httpRequest",
530+
)
531+
532+
499533
def _registerDefaultTools() -> None:
500534
for tool in [
501535
TOOL_INSERT_BLOCK,
@@ -514,6 +548,7 @@ def _registerDefaultTools() -> None:
514548
TOOL_TRACK_ACHIEVEMENT,
515549
TOOL_SPLIT_NOTEBOOK,
516550
TOOL_GENERATE_NOTEBOOK,
551+
TOOL_HTTP_REQUEST,
517552
]:
518553
registerTool(tool)
519554

src/codaro/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .aiRouter import createAiRouter
22
from .automationRouter import createAutomationRouter
3+
from .extensionRouter import createExtensionRouter
34
from .appState import ServerState, createServerState
45
from .bootstrapRouter import createBootstrapRouter
56
from .curriculumRouter import createCurriculumRouter
@@ -43,6 +44,7 @@
4344
"createBootstrapRouter",
4445
"createCurriculumRouter",
4546
"createDocumentRouter",
47+
"createExtensionRouter",
4648
"createKernelRouter",
4749
"createServerState",
4850
"createSpaRouter",

src/codaro/api/automationRouter.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from typing import Any
55

6-
from fastapi import APIRouter, HTTPException, Query
6+
from fastapi import APIRouter, HTTPException, Query, Request
77
from pydantic import BaseModel
88

99
from ..automation.taskModel import TaskDefinition
@@ -164,6 +164,31 @@ def apiSchedulerStatus():
164164
"jobCount": scheduler.jobCount,
165165
}
166166

167+
@router.post("/api/webhooks/trigger/{taskId}")
168+
async def apiWebhookTrigger(taskId: str, request: Request):
169+
registry = getTaskRegistry()
170+
task = registry.get(taskId)
171+
if task is None:
172+
raise HTTPException(status_code=404, detail="Task not found")
173+
if not task.enabled:
174+
raise HTTPException(status_code=403, detail="Task is disabled")
175+
176+
try:
177+
payload = await request.json()
178+
except Exception:
179+
payload = {}
180+
181+
workspaceRoot = str(getattr(state, "workspaceRoot", "."))
182+
runner = TaskRunner(workspaceRoot=workspaceRoot)
183+
run = await runner.run(task)
184+
registry.addRun(run)
185+
return {
186+
"triggered": True,
187+
"taskId": taskId,
188+
"runId": run.id,
189+
"status": run.status.value,
190+
}
191+
167192
@router.get("/api/workflows")
168193
def apiListWorkflows():
169194
engine = getWorkflowEngine(str(getattr(state, "workspaceRoot", ".")))

src/codaro/api/extensionRouter.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any
5+
6+
from fastapi import APIRouter, HTTPException
7+
from pydantic import BaseModel
8+
9+
from ..extensions import ExtensionRegistry, Extension, ExtensionCapability, getExtensionRegistry
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class RegisterExtensionRequest(BaseModel):
15+
name: str
16+
version: str = "0.0.0"
17+
description: str = ""
18+
author: str = ""
19+
capabilities: list[str] = []
20+
entryPoint: str = ""
21+
config: dict[str, Any] = {}
22+
23+
24+
def createExtensionRouter(state: Any) -> APIRouter:
25+
router = APIRouter()
26+
27+
@router.get("/api/extensions")
28+
def apiListExtensions():
29+
registry = getExtensionRegistry()
30+
return {
31+
"extensions": [e.serialize() for e in registry.listExtensions()],
32+
"total": registry.count,
33+
}
34+
35+
@router.post("/api/extensions")
36+
def apiRegisterExtension(req: RegisterExtensionRequest):
37+
registry = getExtensionRegistry()
38+
caps = tuple(
39+
ExtensionCapability(c) for c in req.capabilities
40+
if c in ExtensionCapability._value2member_map_
41+
)
42+
extension = Extension(
43+
name=req.name,
44+
version=req.version,
45+
description=req.description,
46+
author=req.author,
47+
capabilities=caps,
48+
entryPoint=req.entryPoint,
49+
config=req.config,
50+
)
51+
registry.register(extension)
52+
return extension.serialize()
53+
54+
@router.get("/api/extensions/{extensionId}")
55+
def apiGetExtension(extensionId: str):
56+
registry = getExtensionRegistry()
57+
ext = registry.get(extensionId)
58+
if ext is None:
59+
raise HTTPException(status_code=404, detail="Extension not found")
60+
return ext.serialize()
61+
62+
@router.delete("/api/extensions/{extensionId}")
63+
def apiUnregisterExtension(extensionId: str):
64+
registry = getExtensionRegistry()
65+
removed = registry.unregister(extensionId)
66+
if not removed:
67+
raise HTTPException(status_code=404, detail="Extension not found")
68+
return {"ok": True}
69+
70+
@router.get("/api/extensions/capability/{capability}")
71+
def apiExtensionsByCapability(capability: str):
72+
registry = getExtensionRegistry()
73+
try:
74+
cap = ExtensionCapability(capability)
75+
except ValueError:
76+
raise HTTPException(status_code=400, detail=f"Unknown capability: {capability}")
77+
extensions = registry.listByCapability(cap)
78+
return {"extensions": [e.serialize() for e in extensions]}
79+
80+
return router

src/codaro/extensions/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from .registry import ExtensionRegistry, getExtensionRegistry
2+
from .models import Extension, ExtensionCapability
3+
4+
__all__ = [
5+
"Extension",
6+
"ExtensionCapability",
7+
"ExtensionRegistry",
8+
"getExtensionRegistry",
9+
]

src/codaro/extensions/models.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from dataclasses import dataclass, field
5+
from enum import Enum
6+
from typing import Any
7+
8+
9+
class ExtensionCapability(str, Enum):
10+
TOOL = "tool"
11+
RENDERER = "renderer"
12+
PANEL = "panel"
13+
COMMAND = "command"
14+
PROVIDER = "provider"
15+
THEME = "theme"
16+
17+
18+
@dataclass(frozen=True)
19+
class Extension:
20+
id: str = field(default_factory=lambda: f"ext-{uuid.uuid4().hex[:10]}")
21+
name: str = ""
22+
version: str = "0.0.0"
23+
description: str = ""
24+
author: str = ""
25+
capabilities: tuple[ExtensionCapability, ...] = ()
26+
entryPoint: str = ""
27+
config: dict[str, Any] = field(default_factory=dict)
28+
enabled: bool = True
29+
30+
def serialize(self) -> dict[str, Any]:
31+
return {
32+
"id": self.id,
33+
"name": self.name,
34+
"version": self.version,
35+
"description": self.description,
36+
"author": self.author,
37+
"capabilities": [c.value for c in self.capabilities],
38+
"entryPoint": self.entryPoint,
39+
"enabled": self.enabled,
40+
}

0 commit comments

Comments
 (0)