Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# 0.0.12 / 2026-10-03

### :tada: Enhancements
- `useWebLLMModel` follows the current model id: switching models shows the new one as not loaded (it used to keep reporting the old one as ready); the previous model stays in memory until the next load frees it first; new `unload()` and `loadedModelId`
- `useAgent`: a setting changed mid-run no longer flips the panel out of "running"; the next run uses the new agent
- "No thinking" (`none`) thinking level in the composer chip and labels
- Demo: chess endings decided by the rules (a mating move is never answered by the agent; the result covers the board and closes the chat), a failed agent turn shown with its reason and the board locked until it moves (Retry / Engine move); the model card names the model in GPU memory and can unload it
- Updated dependencies: @dudko.dev/agent-web 0.0.21 (WebLLM thinking switch)

# 0.0.11 / 2026-10-03

### :tada: Enhancements
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ function LocalAgent() {
> not silently on the first message) and `ready` flips only once the model can
> actually answer. The `create` option is what makes it work under a bundler —
> see below.
>
> Change the `modelId` and everything the hook reports — `model`, `ready`,
> `loading`, `progress`, `error` — is about the new one, which is not loaded
> yet. The previous model stays in memory (`loadedModelId`; switching back is
> instant) until the new one is loaded — that frees it first, so two models
> never share the GPU — or until `unload()`.

## Models in a bundler (Vite, Next, CRA)

Expand Down
13 changes: 11 additions & 2 deletions demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ Three tabs, sharing one "Agent settings" panel:
- **Chess vs agent** — you play White; **your move is the trigger**: the agent
reads the position with `get_position`, weighs candidates (`evaluate_moves`,
or analyst **subagents** — each in its own **Web Worker**, or in-process),
and plays with `make_move`. No chat message needed.
and plays with `make_move`. No chat message needed. The rules, not the
model, decide the outcome: a mate, stalemate or draw ends the game on the
spot (a mating move is never answered), and a turn the agent ends without
moving — an API error, a spent quota, a stop — is shown as such, with the
board locked until it moves (Retry, or the built-in engine).

The **Agent settings** panel drives the core's features live: tool consent
(⚡ autopilot / ask before changes / ask everything / read-only), thinking
level, token / tool-call / step limits, context window and auto-compaction,
level ("none" turns a local Qwen's thinking off), token / tool-call / step limits, context window and auto-compaction,
skills (each tab lists its own built-in examples; your own or imported
`SKILL.md` ones apply to every tab), the chess analysts — and the **chat UI**: tool calls, token usage, saved chats
(IndexedDB), the files panel, theme, and the labels (English / Russian — every
Expand All @@ -40,6 +44,11 @@ and in total, thoughts, subagents and consent prompts; the composer has
attachments, "/" commands, a run timer, the running-agents count, the model +
thinking chip, the consent chip and a speech-to-text mic.

Local models: pick one and press "Download & load". Switching to another one
shows it as not loaded (the loaded one stays in memory, so switching back is
instant); loading it frees the previous model's GPU memory first, and
"Unload" frees it on demand.

**Live:** https://dudko-dev.github.io/agent-web-react/

## Run locally
Expand Down
8 changes: 4 additions & 4 deletions demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@ai-sdk/google": "^4.0.87",
"@ai-sdk/openai": "^4.0.83",
"@browser-ai/web-llm": "^3.0.4",
"@dudko.dev/agent-web": "^0.0.20",
"@dudko.dev/agent-web": "^0.0.21",
"@dudko.dev/pdf-to-md-core": "^0.3.5",
"@mlc-ai/web-llm": "^0.2.85",
"@modelcontextprotocol/sdk": "^1.32.0",
Expand Down
68 changes: 61 additions & 7 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Square } from 'chess.js'
import type { BrowserAgentConfig, ModelInput } from '@dudko.dev/agent-web'
import {
Expand All @@ -18,7 +18,7 @@ import {
import type { LanguageModel } from 'ai'
import { ANALYST_PROMPT, engineTools } from './chess/analyst-tools'
import { ChessPanel } from './chess/ChessPanel'
import { useChessGame } from './chess/game'
import { outcomeText, useChessGame } from './chess/game'
import { AgentSettingsPanel } from './components/AgentSettingsPanel'
import { McpPanel } from './components/McpPanel'
import { NotesBoard } from './components/NotesBoard'
Expand Down Expand Up @@ -190,6 +190,8 @@ export const App = () => {

// ── Tab 3: chess against the agent, with analyst subagents ─────────────────
const game = useChessGame()
// The last step error of the agent's turn (see playAgentTurn).
const chessErrorRef = useRef<string | undefined>(undefined)
const workerAnalystsBlocked = settings.analysts === 'worker' && local
const analysts = useMemo<AgentToolSet>(() => {
if (settings.analysts === 'off' || !resolvedModel) return {} as AgentToolSet
Expand Down Expand Up @@ -252,22 +254,59 @@ export const App = () => {
maxPlanSteps: 1,
replan: false,
},
{ deps: [...deps, analysts, skillsByTab.chess] },
{
deps: [...deps, analysts, skillsByTab.chess],
// A step error (quota, network) doesn't end the run; keep it for the board.
onEvent: (e) => {
if (e.type === 'error') chessErrorRef.current = e.error
},
},
)

// Whether the agent moved is read off the board, never off its words: a turn
// that ends with Black still to move is a failed turn, and the board stays
// locked until it does move (Retry, or the engine).
const [chessIssue, setChessIssue] = useState<string | undefined>()
const chessRun = chessAgent.run
const { current: chessNow, agentColor } = game
const playAgentTurn = useCallback(
async (goal: string) => {
setChessIssue(undefined)
chessErrorRef.current = undefined
const result = await chessRun(goal)
const now = chessNow()
if (now.outcome || now.turn !== agentColor) return // it moved
const error = chessErrorRef.current
setChessIssue(
result === undefined && !error
? 'The agent could not start its turn (still loading, or busy).'
: result?.stopped
? 'The agent’s turn was stopped before it moved.'
: result?.budgetExceeded
? `The agent hit its ${result.budgetExceeded.kind} limit before moving.`
: error
? `The agent failed: ${error}`
: 'The agent ended its turn without making a move.',
)
},
[chessRun, chessNow, agentColor],
)
const onUserMove = useCallback(
(from: Square, to: Square) => {
const san = game.userMove(from, to)
if (!san) return
// The rules decide the end: a mating (or drawing) move ends the game here,
// and the agent isn't asked to play on.
if (game.current().outcome) return
// The move IS the trigger: no chat message, the agent just answers it.
void chessRun(`White played ${san}. Your move.`)
void playAgentTurn(`White played ${san}. Your move.`)
},
[game, chessRun],
[game, playAgentTurn],
)
const onNewGame = () => {
game.newGame()
chessAgent.reset()
setChessIssue(undefined)
}

// ── Routing & one-model-at-a-time ──────────────────────────────────────────
Expand Down Expand Up @@ -430,6 +469,18 @@ export const App = () => {
composer={composer('Chat with your opponent — or just make a move on the board')}
{...chatCommon}
files={undefined}
// The game's own verdict, after whatever the model said.
slots={{
afterMessages: game.outcome ? (
<div className="chess-note chess-note--result" role="status">
🏁 {outcomeText(game.outcome)}
</div>
) : chessIssue && !chessAgent.isRunning ? (
<div className="chess-note chess-note--issue" role="alert">
⚠ {chessIssue} The board waits for its move — Retry or Engine move.
</div>
) : undefined,
}}
/>
)}
</div>
Expand Down Expand Up @@ -462,8 +513,11 @@ export const App = () => {
}
onAnalysts={(analysts) => update({ analysts })}
onUserMove={onUserMove}
onAskAgent={() => void chessRun("It's your move.")}
onEngineMove={() => void game.engineMove()}
agentIssue={chessIssue}
onAskAgent={() => void playAgentTurn("It's your move.")}
onEngineMove={() => {
if (game.engineMove()) setChessIssue(undefined)
}}
onNewGame={onNewGame}
/>
)}
Expand Down
47 changes: 47 additions & 0 deletions demo/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ a {
color: #16a34a;
font-size: 13px;
}
.settings__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}

/* ── Board ────────────────────────────────────────────────────────────────── */
.board {
Expand Down Expand Up @@ -803,6 +809,7 @@ a {
overflow: hidden;
border: 1px solid var(--border);
user-select: none;
position: relative;
}
.cboard__sq {
position: relative;
Expand Down Expand Up @@ -889,6 +896,46 @@ a {
gap: 8px;
flex-wrap: wrap;
}
.chess__agent-turn--issue > [role='alert'] {
color: #b45309;
}
/* The game's verdict in the chat, after the model's last words. */
/* Inside the chat, so it follows the chat's theme tokens (light / dark). */
.chess-note {
margin: 4px 0 8px;
padding: 8px 12px;
border-radius: 10px;
font-size: 13px;
color: var(--awr-fg);
border: 1px solid var(--awr-border);
background: var(--awr-subtle);
}
.chess-note--result {
border-color: var(--awr-accent);
font-weight: 600;
}
.chess-note--issue {
border-color: var(--awr-warn);
}
/* The game result covers the board: the rules decided it, the board is done. */
.cboard__result {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 16px;
text-align: center;
background: rgba(15, 23, 42, 0.55);
color: #fff;
font-size: clamp(16px, 4.5cqi, 26px);
backdrop-filter: blur(1px);
}
.cboard__result .settings__btn {
font-size: 15px;
}
.agentset__peek {
font-weight: 400;
font-size: 12px;
Expand Down
7 changes: 5 additions & 2 deletions demo/src/chess/ChessBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import type { Square } from 'chess.js'
import type { ChessGame } from './game'

Expand All @@ -24,10 +24,12 @@ export interface ChessBoardProps {
/** The user can move (their turn, agent idle). */
interactive: boolean
onMove: (from: Square, to: Square) => void
/** Covers the board (the game result). */
overlay?: ReactNode
}

/** A click-to-move board, White at the bottom. Click a piece, then a highlighted square. */
export const ChessBoard = ({ game, interactive, onMove }: ChessBoardProps) => {
export const ChessBoard = ({ game, interactive, onMove, overlay }: ChessBoardProps) => {
const [selected, setSelected] = useState<Square | undefined>()
const targets = selected ? new Set(game.targets(selected)) : new Set<Square>()

Expand Down Expand Up @@ -81,6 +83,7 @@ export const ChessBoard = ({ game, interactive, onMove }: ChessBoardProps) => {
)
}),
)}
{overlay}
</div>
)
}
Loading
Loading