יום 6: ציור עם סוכן
במשחק האיקס עיגול למדנו שסוכנים חכמים יכולים לבצע פעולות על המסך. היום ניקח את הרעיון הזה צעד גדול קדימה ונבנה מערכת עם ממשק כפול - סוכן וממשק גרפי. משתמשים יכולים לעבוד עם המערכת בכל אחד משני הממשקים ושניהם תמיד מתואמים.
1. מה אנחנו בונים
האפליקציה שנבנה הוא אפליקציית צייר מבוסס תבניות. יש ציורים קטנים בצד שמאל של קשת בענן, חד קרן, בית, כוכב, עץ, לב, עיגול ופרח ופלטת צבעים בחלק העליון של העמוד. משתמשים יכולים לגרור תבנית ללוח העבודה המרכזי, שם הם יוכלו לגרור את הצורה, לשנות לה את הגודל או לבחור לה צבע אחר. עד פה אפליקציית Front End רגילה לגמרי.
החידוש הוא שבנוסף לכל זה יש גם תיבת שיחה עם סוכן בתיבת הצד הימנית. משתמשים יכולים לשאול את הסוכן מה מצויר על הלוח, לשאול מה דעתו על הציור וגם לבקש שינויים למשל לבקש מהסוכן שיוסיף בית, חד קרן או שיסדר את הצבעים. לסוכן ולמשתמש יש גישה בדיוק לאותן הפעולות והם רואים תמיד את אותו הציור. כך נוצר שיתוף פעולה בין משתמשים לסוכנים חכמים שמוסיף קסם למערכות שלנו.
2. איך מפעילים
קוד הדוגמה נמצא בתיקיית הדוגמאות:
https://github.com/ynonp/pydanticai-demos/tree/main/06-ai-painter
והוא מורכב מאפליקציית Vue ומשרת Fastapi בפייתון. בשביל להפעיל נצטרך שני חלונות. בחלון אחד מפעילים את הפייתון:
uv run main.py
ובחלון שני מפעילים את אפליקציית הפרונטאנד:
cd frontend
npm run dev
לאחר מכן נכנסים ל localhost:5173 כדי לראות את הממשק.
3. איך זה עובד
מנגנון שיתוף הפעולה בנוי משני חלקים: הראשון הוא שיתוף המידע והשני הוא הוא הפעלת הכלים.
בכל הודעה מצד הלקוח לסוכן אנו מצרפים להודעה עותק של לוח הציור כלומר ה Endpoint של הודעה מקבל:
class ChatRequest(BaseModel):
message: str
canvas: CanvasState
# Opaque pydantic-ai message history, exactly as returned by the previous
# /api/chat response. Empty on a fresh page load.
history: list[Any] = Field(default_factory=list)
class CanvasState(BaseModel):
"""The full canvas: its dimensions plus every shape on it."""
width: int = CANVAS_WIDTH
height: int = CANVAS_HEIGHT
shapes: list[Shape] = Field(default_factory=list)
class Shape(BaseModel):
"""A single shape placed on the canvas."""
# Re-validate on assignment so the tools' `shape.size = ...` mutations go
# through the same clamp as construction (keeps server + browser in sync).
model_config = ConfigDict(validate_assignment=True)
id: str
type: ShapeType
x: int = Field(description="Left position in pixels (0 = left edge).")
y: int = Field(description="Top position in pixels (0 = top edge).")
size: int = Field(default=DEFAULT_SHAPE_SIZE, description="Width/height in pixels.")
color: str = Field(default=DEFAULT_SHAPE_COLOR, description="CSS color, e.g. '#ff0000'.")
@field_validator("size")
@classmethod
def _clamp_size(cls, value: int) -> int:
# Mirror the frontend store: never smaller than MIN_SHAPE_SIZE.
return max(MIN_SHAPE_SIZE, value)
לוח הציור עובר לסוכן באמצעות הפונקציה add_canvas_state שמוגדרת בקובץ agent.py:
@agent.instructions
def add_canvas_state(ctx: RunContext[PainterDeps]) -> str:
canvas = ctx.deps.canvas
return (
f"Canvas size: {canvas.width}x{canvas.height} pixels.\n"
f"Current canvas state (JSON):\n{canvas.model_dump_json(indent=2)}"
)
הפעולות שסוכן יכול לעשות עוברות באמצעות Tool Calls לדוגמה בשביל להוסיף צורה הסוכן יפעיל את הכלי:
@agent.tool
async def add_shape(
ctx: RunContext[PainterDeps],
type: ShapeType,
x: int,
y: int,
size: int = DEFAULT_SHAPE_SIZE,
color: str = DEFAULT_SHAPE_COLOR,
) -> str:
"""Add a new shape to the canvas. Returns the new shape's id."""
shape = Shape(id=uuid.uuid4().hex, type=type, x=x, y=y, size=size, color=color)
ctx.deps.canvas.shapes.append(shape)
return f"Added {type} (id={shape.id}) at ({x}, {y})."
שימו לב שהכל קורה בצד השרת בפייתון. הוספת צורה היא בסך הכל הוספה של אוביקט למערך. שינוי צבע של צורה יהיה בסך הכל פעולת השמה לשדה באוביקט:
@agent.tool
async def recolor_shape(ctx: RunContext[PainterDeps], id: str, color: str) -> str:
"""Change the color of the shape with the given id (CSS hex string)."""
shape = _find_shape(ctx.deps.canvas, id)
if shape is None:
return f"No shape with id {id}."
shape.color = color
return f"Recolored shape {id} to {color}."
בסוף הטיפול בהודעה אנחנו שולחים את ה canvas המעודכן חזרה לצד הלקוח עם השורה:
yield emit({"type": "canvas", "state": deps.canvas.model_dump(mode="json")})
4. עכשיו אתם
הפעילו את התוכנית על המכונה שלכם. ציירו ודברו עם הסוכן כדי לראות את הסוכן מעדכן את הציור שיצרתם או חווה את דעתו על היצירה.
הוסיפו כפתור שמוחק את כל הלוח ומתחיל ציור חדש. האם צריך לעדכן את הסוכן במחיקה? מה לגבי היסטוריית השיחה?
היסטוריית השיחה נשמרת בדפדפן בצד הלקוח יחד עם הציור. העבירו את ההיסטוריה ואת הציור הנוכחי לצד שרת כדי שנוכל לפתוח את הציור מכמה דפדפנים ולראות תמיד את אותו ציור. מה עכשיו קורה כשמוחקים את הלוח? ומה קורה כשמוחקים רק את היסטוריית השיחה עם כפתור Reset?
עדכנו את התוכנית כך שהסוכן יקבל הזדמנות לחוות דעה אחרי כל שינוי או ציור שאתם עושים על הלוח. השתמשו בסוכן אחר ללא כלים כדי שלא יקלקל את הפעולות שעכשיו עשיתם.