diff --git a/typescript-sdk/apps/dojo/src/agents.ts b/typescript-sdk/apps/dojo/src/agents.ts index 0222e59cd..756fbd479 100644 --- a/typescript-sdk/apps/dojo/src/agents.ts +++ b/typescript-sdk/apps/dojo/src/agents.ts @@ -101,6 +101,10 @@ export const agentsIntegrations: AgentIntegrationConfig[] = [ deploymentUrl: "http://localhost:2024", graphId: "tool_based_generative_ui", }), + gomoku: new LangGraphAgent({ + deploymentUrl: "http://localhost:2024", + graphId: "gomoku", + }), }; }, }, diff --git a/typescript-sdk/apps/dojo/src/app/[integrationId]/feature/gomoku/page.tsx b/typescript-sdk/apps/dojo/src/app/[integrationId]/feature/gomoku/page.tsx new file mode 100644 index 000000000..e39039c4a --- /dev/null +++ b/typescript-sdk/apps/dojo/src/app/[integrationId]/feature/gomoku/page.tsx @@ -0,0 +1,175 @@ +"use client"; +import { CopilotKit, useCoAgent, useCopilotChat } from "@copilotkit/react-core"; +import { CopilotSidebar } from "@copilotkit/react-ui"; +import React, { useState } from "react"; +import "@copilotkit/react-ui/styles.css"; +import "./style.css"; +import { Role, TextMessage } from "@copilotkit/runtime-client-gql"; + +const BOARD_SIZE = 11; +const EMPTY = 0; +const BLACK = 1; +const WHITE = 2; + +interface GomokuState { + board: number[][]; + current_player: number; + winner?: number | null; + last_move?: { row: number; col: number } | null; +} + +const INITIAL_STATE: GomokuState = { + board: Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(EMPTY)), + current_player: BLACK, + winner: EMPTY, + last_move: null, +}; + +const ZEN_QUOTES = [ + "Observe the changes with a calm mind 🍃", + "A game ends, mind like still water đŸĒˇ", + "Victory and defeat are common, peace of mind is key đŸ§˜â€â™‚ī¸", + "Like fallen cherry blossoms, the game concludes 🌸", + "Wind leaves no trace, but the game's spirit remains đŸ¯", + "Meeting through Go, endless zen đŸĒ¨", + "Watching quietly, mind wanders far đŸžī¸", +]; + +const USER_MESSAGES = [ + "Stone placed at {row}, {col}. The game flows like water.", + "Moving to {row}, {col}. Like a leaf in the wind.", + "Position {row}, {col} chosen. The path reveals itself.", + "Stone at {row}, {col}. Silence speaks volumes.", + "Playing {row}, {col}. Each move, a new beginning.", +]; + +const ZEN_EMOJIS = ["🌸", "🍃", "đŸĒˇ", "đŸ¯", "đŸ§˜â€â™‚ī¸", "đŸĒ¨", "đŸžī¸", "🎋", "â›Šī¸"]; + +export default function GomokuPage({ params }: { params: Promise<{ integrationId: string }> }) { + const { integrationId } = React.use(params); + return ( + +
+ + +
+
+ ); +} + +function GomokuGame() { + const { state: agentState, setState: setAgentState } = useCoAgent({ + name: "gomoku", + initialState: INITIAL_STATE, + }); + const { appendMessage, isLoading } = useCopilotChat(); + const [showModal, setShowModal] = useState(false); + const [zenMsg, setZenMsg] = useState(""); + const [zenEmoji, setZenEmoji] = useState(""); + const [footerQuote] = useState(() => ZEN_QUOTES[Math.floor(Math.random() * ZEN_QUOTES.length)]); + + React.useEffect(() => { + if (agentState?.winner && !showModal) { + const msg = ZEN_QUOTES[Math.floor(Math.random() * ZEN_QUOTES.length)]; + const emoji = ZEN_EMOJIS[Math.floor(Math.random() * ZEN_EMOJIS.length)]; + setZenMsg(msg); + setZenEmoji(emoji); + setTimeout(() => setShowModal(true), 600); + } + if (!agentState?.winner) { + setShowModal(false); + } + }, [agentState?.winner]); + + if (!agentState) { + return
Loading...
; + } + + const handleCellClick = (row: number, col: number) => { + if (!isLoading && !agentState.winner && agentState.board[row][col] === EMPTY) { + setAgentState(prevState => ({ + ...prevState!, + last_move: { row, col }, + board: prevState!.board.map((rowArr, r) => + rowArr.map((_, c) => (r === row && c === col ? prevState!.current_player : _)), + ), + current_player: prevState!.current_player === BLACK ? WHITE : BLACK, + })); + + const randomMessage = USER_MESSAGES[Math.floor(Math.random() * USER_MESSAGES.length)] + .replace("{row}", row.toString()) + .replace("{col}", col.toString()); + + appendMessage( + new TextMessage({ + content: randomMessage, + role: Role.User, + }), + ); + } + }; + + const renderCell = (row: number, col: number) => { + const value = agentState.board[row][col]; + let cellClass = "gomoku-cell"; + if (agentState.last_move && agentState.last_move.row === row && agentState.last_move.col === col) { + cellClass += " gomoku-last-move"; + } + return ( +
handleCellClick(row, col)} + > + {value === BLACK && } + {value === WHITE && } +
+ ); + }; + + return ( +
+

Gomoku 🌸

+
+ {agentState.winner !== EMPTY + ? `Winner: ${agentState.winner === BLACK ? "Black (You)" : "White (AI)"}` + : isLoading + ? "AI is thinking..." + : `Current Player: ${agentState.current_player === BLACK ? "Black (You)" : "White (AI)"}`} +
+ {showModal && ( +
+ {zenEmoji} + {zenMsg} +
+ )} +
+ {agentState.board.map((rowArr, row) => ( +
+ {rowArr.map((_, col) => renderCell(row, col))} +
+ ))} +
+ {agentState.winner !== EMPTY && ( + + )} +
{footerQuote}
+
+ ); +} \ No newline at end of file diff --git a/typescript-sdk/apps/dojo/src/app/[integrationId]/feature/gomoku/style.css b/typescript-sdk/apps/dojo/src/app/[integrationId]/feature/gomoku/style.css new file mode 100644 index 000000000..8b8b3605d --- /dev/null +++ b/typescript-sdk/apps/dojo/src/app/[integrationId]/feature/gomoku/style.css @@ -0,0 +1,191 @@ +.gomoku-page { + min-height: 100vh; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #f7f3ee; + position: relative; + overflow: auto; +} + +.gomoku-zen-container { + background: #f7f3ee; + padding: 40px 32px; + width: 100%; + max-width: 440px; + margin: 20px auto; + position: relative; + z-index: 1; +} + +.section-title { + font-family: 'Noto Serif', serif; + font-size: 1.5rem; + font-weight: 400; + color: #5c4b3c; + text-align: center; + margin-bottom: 24px; + letter-spacing: 4px; +} + +.zen-sakura { + font-size: 1rem; + vertical-align: middle; + opacity: 0.7; +} + +.gomoku-status { + font-family: 'Noto Sans', sans-serif; + color: #7c6f57; + font-size: 0.9rem; + margin-bottom: 24px; + text-align: center; + letter-spacing: 1px; + min-height: 1.2em; + transition: color 0.2s ease; +} + +.gomoku-board-zen { + display: grid; + grid-template-rows: repeat(11, minmax(32px, 1fr)); + gap: 0; + margin: 24px 0; + background: #f0e6d9; + padding: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + aspect-ratio: 1; + transition: opacity 0.2s ease; +} + +.gomoku-board-loading { + opacity: 0.7; + cursor: not-allowed; +} + +.gomoku-board-loading .gomoku-cell { + cursor: not-allowed; + pointer-events: none; +} + +.gomoku-row-zen { + display: grid; + grid-template-columns: repeat(11, 1fr); + gap: 0; + height: 100%; +} + +.gomoku-cell { + aspect-ratio: 1; + background: #f0e6d9; + border: 1px solid #c4b5a2; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; +} + +.gomoku-cell.gomoku-last-move::after { + content: ''; + position: absolute; + top: -1px; + left: -1px; + right: -1px; + bottom: -1px; + border: 2px solid rgba(255, 124, 67, 0.5); + pointer-events: none; + animation: lastMove 1s ease-out forwards; +} + +@keyframes lastMove { + 0% { + opacity: 1; + transform: scale(1.2); + } + 100% { + opacity: 0.6; + transform: scale(1); + } +} + +.gomoku-stone { + width: 24px; + height: 24px; + border-radius: 50%; + display: inline-block; + position: relative; + z-index: 1; +} + +.gomoku-black { + background: #2c2522; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.gomoku-white { + background: #f5f2ed; + border: 1px solid #d9d1c7; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.zen-restart-btn { + margin-top: 24px; + padding: 8px 24px; + background: none; + color: #7c6f57; + border: 1px solid #c4b5a2; + font-size: 0.9rem; + font-family: 'Noto Sans', sans-serif; + cursor: pointer; + transition: all 0.2s ease; + letter-spacing: 1px; + display: block; + margin-left: auto; + margin-right: auto; +} + +.zen-restart-btn:hover { + background: #f0e6d9; +} + +.zen-footer { + color: #a39889; + font-family: 'Noto Serif', serif; + font-size: 0.9rem; + letter-spacing: 2px; + text-align: center; + margin-top: 32px; + user-select: none; +} + +.zen-message { + text-align: center; + margin-bottom: 20px; + color: #7c6f57; + font-family: 'Noto Serif', serif; + font-size: 0.9rem; + letter-spacing: 1px; + animation: fadeIn 0.5s ease-out; +} + +.zen-message-emoji { + font-size: 1.2rem; + margin-right: 8px; + opacity: 0.8; +} + +.zen-message-text { + font-style: italic; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/typescript-sdk/apps/dojo/src/config.ts b/typescript-sdk/apps/dojo/src/config.ts index 14a90fa6c..c780a0a22 100644 --- a/typescript-sdk/apps/dojo/src/config.ts +++ b/typescript-sdk/apps/dojo/src/config.ts @@ -53,6 +53,12 @@ export const featureConfig: FeatureConfig[] = [ description: "Use collaboration to edit a document in real time with your Copilot", tags: ["State", "Streaming", "Tools"], }), + createFeatureConfig({ + id: "gomoku", + name: "Gomoku", + description: "A game of Gomoku", + tags: ["Game", "Gomoku"], + }), ]; export default featureConfig; diff --git a/typescript-sdk/apps/dojo/src/menu.ts b/typescript-sdk/apps/dojo/src/menu.ts index b9060d6dd..520d1ded8 100644 --- a/typescript-sdk/apps/dojo/src/menu.ts +++ b/typescript-sdk/apps/dojo/src/menu.ts @@ -43,6 +43,7 @@ export const menuIntegrations: MenuIntegrationConfig[] = [ "tool_based_generative_ui", "predictive_state_updates", "shared_state", + "gomoku", ], }, { diff --git a/typescript-sdk/apps/dojo/src/types/integration.ts b/typescript-sdk/apps/dojo/src/types/integration.ts index 11b632ac3..6d63a53cc 100644 --- a/typescript-sdk/apps/dojo/src/types/integration.ts +++ b/typescript-sdk/apps/dojo/src/types/integration.ts @@ -6,7 +6,8 @@ export type Feature = | "human_in_the_loop" | "predictive_state_updates" | "shared_state" - | "tool_based_generative_ui"; + | "tool_based_generative_ui" + | "gomoku"; export interface MenuIntegrationConfig { id: string; diff --git a/typescript-sdk/integrations/langgraph/examples/.gitignore b/typescript-sdk/integrations/langgraph/examples/.gitignore new file mode 100644 index 000000000..8302abec2 --- /dev/null +++ b/typescript-sdk/integrations/langgraph/examples/.gitignore @@ -0,0 +1,5 @@ + +# LangGraph API +.langgraph_api +__pycache__/ +uv.lock \ No newline at end of file diff --git a/typescript-sdk/integrations/langgraph/examples/agents/gomoku/__init__.py b/typescript-sdk/integrations/langgraph/examples/agents/gomoku/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/typescript-sdk/integrations/langgraph/examples/agents/gomoku/agent.py b/typescript-sdk/integrations/langgraph/examples/agents/gomoku/agent.py new file mode 100644 index 000000000..6fdd74f81 --- /dev/null +++ b/typescript-sdk/integrations/langgraph/examples/agents/gomoku/agent.py @@ -0,0 +1,353 @@ +""" +A demo of Gomoku (Five in a Row) agent with shared state between the agent and CopilotKit using LangGraph. +""" + +import json +from typing import Dict, List, Any, Optional + +from langchain_core.runnables import RunnableConfig +from langgraph.graph import StateGraph, END, START +from langgraph.types import Command +from langchain_core.callbacks.manager import adispatch_custom_event +from langgraph.graph import MessagesState +from langchain_openai import ChatOpenAI + +def check_winner(board): + """ + Check if there are five in a row on the board, return the winner (1=Black, 2=White), 0 if none. + board: 11x11 2D array + """ + BOARD_SIZE = 11 + EMPTY = 0 + directions = [ + (1, 0), (0, 1), (1, 1), (1, -1) + ] + for r in range(BOARD_SIZE): + for c in range(BOARD_SIZE): + player = board[r][c] + if player == EMPTY: + continue + for dr, dc in directions: + count = 1 + nr, nc = r, c + for _ in range(4): + nr += dr + nc += dc + if 0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE and board[nr][nc] == player: + count += 1 + else: + break + if count >= 5: + return player + return 0 + + +def parse_partial_json(text): + try: + return json.loads(text) + except Exception: + pass + import re + match = re.search(r'\{.*?\}', text, re.DOTALL) + if match: + try: + return json.loads(match.group(0)) + except Exception: + pass + return None + +BOARD_SIZE = 11 +EMPTY = 0 +BLACK = 1 +WHITE = 2 + +def empty_board(): + return [[EMPTY for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)] + +class GomokuState(MessagesState): + board: List[List[int]] = [] # 0=empty, 1=black, 2=white + current_player: int = BLACK # 1=black, 2=white + winner: Optional[int] = None + last_move: Optional[Dict[str, int]] = None # {"row": int, "col": int} + tools: List[Any] + +# Tool definition: user places a stone +PLACE_STONE_TOOL = { + "type": "function", + "function": { + "name": "place_stone", + "description": "Use the place_stone tool to place a stone on the board. Return the row and column to place.", + "parameters": { + "type": "object", + "properties": { + "row": {"type": "integer", "description": "The row index (0-based) where you want to place the stone."}, + "col": {"type": "integer", "description": "The column index (0-based) where you want to place the stone."}, + }, + "required": ["row", "col"] + } + } +} + +async def start_flow(state: Dict[str, Any], config: RunnableConfig): + if "board" not in state or not state["board"]: + state["board"] = empty_board() + if "current_player" not in state or state["current_player"] not in [BLACK, WHITE]: + state["current_player"] = BLACK + if "winner" not in state: + state["winner"] = None + if "last_move" not in state: + state["last_move"] = None + if "messages" not in state: + state["messages"] = [] + await adispatch_custom_event( + "manually_emit_intermediate_state", + state, + config=config, + ) + return Command( + goto="chat_node", + update=state + ) + +def check_urgent_threat(board, player): + """ + Check if there's an urgent threat that needs immediate response. + Returns: List of threat positions that need to be blocked immediately + """ + BOARD_SIZE = 11 + EMPTY = 0 + threats = [] + + # Check horizontal, vertical, and diagonal lines + directions = [(1, 0), (0, 1), (1, 1), (1, -1)] + + for r in range(BOARD_SIZE): + for c in range(BOARD_SIZE): + if board[r][c] != EMPTY: + continue + + for dr, dc in directions: + # Check both directions + count = 0 + gaps = 0 + player_stones = 0 + positions = [] + + # Check forward + nr, nc = r, c + for i in range(4): # Check 4 positions ahead + if not (0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE): + break + if board[nr][nc] == EMPTY: + gaps += 1 + positions.append((nr, nc)) + elif board[nr][nc] == player: + player_stones += 1 + else: # opponent's stone + break + nr += dr + nc += dc + + # Check backward + nr, nc = r - dr, c - dc + for i in range(4): # Check 4 positions behind + if not (0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE): + break + if board[nr][nc] == EMPTY: + gaps += 1 + positions.append((nr, nc)) + elif board[nr][nc] == player: + player_stones += 1 + else: # opponent's stone + break + nr -= dr + nc -= dc + + # If there's a potential winning threat + if player_stones >= 3 and gaps <= 2: + threats.append((r, c)) + + return threats + +async def chat_node(state: Dict[str, Any], config: RunnableConfig): + board = state["board"] + current_player = state["current_player"] + winner = state.get("winner") + last_move = state.get("last_move") + messages = state["messages"] + + if winner: + await adispatch_custom_event( + "manually_emit_intermediate_state", + state, + config=config, + ) + return Command(goto=END, update=state) + + if current_player == WHITE: + moves = [] + urgent_threats = [] + for r in range(BOARD_SIZE): + for c in range(BOARD_SIZE): + player = board[r][c] + if player == 1: + moves.append((r, c, "Black")) + elif player == 2: + moves.append((r, c, "White")) + + # Check for urgent threats from black stones + urgent_threats = check_urgent_threat(board, BLACK) + threat_positions = "" + if urgent_threats: + threat_positions = f"\nURGENT THREATS DETECTED at positions: {urgent_threats}" + + prompt = f""" +You are a Gomoku (Five in a Row) master. + +Game rules: +1. Two players take turns placing stones on empty positions on the board. +2. The first player uses black stones, the second player uses white stones. +3. The goal is to form a straight line of five consecutive stones of the same color. +4. CRITICAL: You can ONLY place stones on EMPTY positions (value = 0). +5. The board is {BOARD_SIZE}x{BOARD_SIZE}, valid coordinates are 0-{BOARD_SIZE-1}. + +Current board state: +Empty positions: 0 +Black stones: 1 +White stones: 2 + +DEFENSIVE PRIORITIES: +1. HIGHEST PRIORITY - Block immediate winning threats: + - If opponent has 4 stones in a row with an empty end + - If opponent has 3 stones with both ends empty (double-sided threat) +2. HIGH PRIORITY - Block potential threats: + - If opponent has 3 stones in a row with one empty end + - If opponent can create multiple threats in next move +3. MEDIUM PRIORITY - Create your own opportunities while blocking +4. LOW PRIORITY - Develop your own attacking position + +Strategy: +1. FIRST CHECK: Scan for immediate threats that must be blocked{threat_positions} +2. If multiple threats exist, block the most critical one +3. If no immediate threats: + - Look for opportunities to create your own winning line + - Prevent opponent from creating future threats +4. Always verify chosen position is empty (value = 0) + +The current move history is as follows, each tuple is (row, col, color), color=Black or White: +{moves} + +Your role: White (value = 2) +User's latest move: {last_move if last_move else {'row': -1, 'col': -1}} + +Before making your move: +1. VERIFY the position is within bounds (0-{BOARD_SIZE-1}) +2. VERIFY the position is empty (value = 0) +3. AVOID positions that are already occupied +4. PRIORITIZE blocking urgent threats if they exist + +You must first use the place_stone tool to make your move. After the tool call is completed, output a short (no more than 20 characters) taunt to the user as the assistant. You must strictly output in two steps, not combined. +""" + model = ChatOpenAI(model="gpt-4o") + + model_with_tools = model.bind_tools([ + PLACE_STONE_TOOL + ], parallel_tool_calls=False) + + response = await model_with_tools.ainvoke([ + {"role": "user", "content": prompt} + ], config) + + try: + tool_call = None + if hasattr(response, "tool_calls") and response.tool_calls: + tool_call = response.tool_calls[0] + if tool_call: + if isinstance(tool_call, dict): + tool_call_args = tool_call.get("args") or tool_call.get("arguments") + if isinstance(tool_call_args, str): + tool_call_args = json.loads(tool_call_args) + else: + tool_call_args = getattr(tool_call, "args", None) or getattr(tool_call, "arguments", None) + if isinstance(tool_call_args, str): + tool_call_args = json.loads(tool_call_args) + + # Enhanced validation + if not tool_call_args or "row" not in tool_call_args or "col" not in tool_call_args: + raise ValueError("Invalid move: missing row/col coordinates") + + row = tool_call_args["row"] + col = tool_call_args["col"] + + # Validate coordinates + if not (0 <= row < BOARD_SIZE and 0 <= col < BOARD_SIZE): + raise ValueError(f"Invalid move: coordinates ({row}, {col}) out of bounds") + + # Validate empty position + if board[row][col] != EMPTY: + raise ValueError(f"Invalid move: position ({row}, {col}) is already occupied with value {board[row][col]}") + + # Make the move + board[row][col] = WHITE + state["last_move"] = {"row": row, "col": col} + state["winner"] = check_winner(board) + state["current_player"] = BLACK if not state["winner"] else WHITE + await adispatch_custom_event( + "manually_emit_intermediate_state", + state, + config=config, + ) + + trash = "" + if hasattr(response, "content") and response.content and response.content.strip(): + trash = response.content.strip()[:20] + if not trash: + trash_prompt = "Please give a taunt to the user in no more than 20 characters." + trash_response = await model.ainvoke([ + {"role": "user", "content": trash_prompt} + ], config) + trash = getattr(trash_response, "content", "")[:20] + if trash: + messages = messages + [{ + "role": "assistant", + "content": trash + }] + state["messages"] = messages + await adispatch_custom_event( + "manually_emit_intermediate_state", + state, + config=config, + ) + if state["winner"]: + return Command(goto=END, update=state) + return Command(goto=END, update=state) + raise ValueError("Invalid tool_call") + except Exception as e: + messages = messages + [{ + "role": "assistant", + "content": f"AI failed to place a stone: {str(e)}" + }] + state["messages"] = messages + await adispatch_custom_event( + "manually_emit_intermediate_state", + state, + config=config, + ) + return Command(goto=END, update=state) + + await adispatch_custom_event( + "manually_emit_intermediate_state", + state, + config=config, + ) + return Command(goto=END, update=state) + +# Define the graph +workflow = StateGraph(GomokuState) +workflow.add_node("start_flow", start_flow) +workflow.add_node("chat_node", chat_node) +workflow.set_entry_point("start_flow") +workflow.add_edge(START, "start_flow") +workflow.add_edge("start_flow", "chat_node") +workflow.add_edge("chat_node", END) +gomoku_graph = workflow.compile() \ No newline at end of file diff --git a/typescript-sdk/integrations/langgraph/examples/langgraph.json b/typescript-sdk/integrations/langgraph/examples/langgraph.json index ddd86229e..6e4c14932 100644 --- a/typescript-sdk/integrations/langgraph/examples/langgraph.json +++ b/typescript-sdk/integrations/langgraph/examples/langgraph.json @@ -8,6 +8,7 @@ "human_in_the_loop": "./agents/human_in_the_loop/agent.py:human_in_the_loop_graph", "predictive_state_updates": "./agents/predictive_state_updates/agent.py:predictive_state_updates_graph", "shared_state": "./agents/shared_state/agent.py:shared_state_graph", + "gomoku": "./agents/gomoku/agent.py:gomoku_graph", "tool_based_generative_ui": "./agents/tool_based_generative_ui/agent.py:tool_based_generative_ui_graph" }, "env": ".env"