Storm 061a7dfc14
feat(channels): select the model per conversation via /model (#6534)
* feat(channels): select the model per conversation via /model

IM channels could list models with /models but had no way to choose one:
the model for a conversation came only from channel/session config or the
server default, so a user who needed a stronger or cheaper model for one
task had to reconfigure the whole channel or move to the web UI.

- /model <name> pins a model to the current conversation, validated
  against the caller-visible /api/models list (list membership doubles
  as the authorization check), persisted in thread metadata so it
  survives a Gateway restart and applies from the next message in
  place — unlike /agent use, which must start a new conversation.
- The pin is written to all three runtime carriers (run context,
  configurable, context) after the session layers merge, so it wins
  over channel/session configured models; like any explicitly
  requested model it also takes precedence over a Custom Agent's
  configured model.
- /model (no args) reports the effective model and its source through
  one resolver that mirrors the run merge order (default -> channel ->
  user, context carrier over configurable, both session-config
  carriers scanned); /model default clears the pin; /models names the
  pinned model in a footer.
- Pin lookups degrade instead of failing messages: the status query
  reports "Current model: unknown" on a lookup failure (matching the
  /models footer), and the reuse-thread preload leaves the in-process
  cache untouched so the next turn retries a transient threads.get.
- Reserve "model" in the slash-skill reserved-name contract (backend,
  contract fixture, frontend), register the Telegram handler, and
  document the command in /help, README, and README_zh.

* fix(channels): fence model pin cache and mirror runtime model resolution

Review follow-up for /model:

- Serialize cold pin loads with persist+cache publication under a
  per-manager lock, so a slow threads.get that captured pre-pin
  metadata can never overwrite a concurrently committed selection
  (commands are not run-serialized).
- _resolve_configured_model_name now replicates the run path's shallow
  session merges and applies the real runtime precedence —
  config.context (preserved by build_run_config, unbeatable by the
  setdefault-filled body context) > top-level context > configurable —
  instead of scanning per-layer carriers; a later layer replacing a
  nested carrier wholesale no longer leaks a stale value into the
  status reply.
- /api/models resolves the trusted bound owner for internal channel
  calls and filters the list with the owner's role, so /model
  selection gates on the same principal that run admission admits for
  that account.

Tests: pin-write-vs-in-flight-load race, status compared against the
real merged runtime config (parametrized across carriers),
nested-carrier replacement, and owner-role filtering with the real
RBAC provider; each verified red by reverting exactly its guard.

* fix(channels): share model resolvers with the run path and harden the pin lifecycle

The /model status reply re-implemented the session-layer merge and the
Gateway carrier precedence, and it ignored the custom-agent model
fallback — on a thread pinned to a custom agent whose config sets
``model:`` it reported "server default" while the run actually used
the agent's model. Review also found the pin lock spanned a network
call and a stale pin bricked its conversation behind the generic
error fallback.

- Extract ``_select_carried_model_name`` as the single carrier-precedence
  rule; the status reply reuses the run path's exact ``_merge_dicts``
  layer shapes, so a future layering change has one place to touch.
- Extract ``_resolve_message_assistant_id`` / ``_resolve_assistant_id``
  and share the assistant precedence chain between the run path and
  the status reply.
- Fall back to the resolved custom agent's configured ``model`` when no
  session layer carries one, mirroring the agent factory's
  ``_resolve_model_name(requested or agent_model_name)``; the reply now
  distinguishes "(from channel configuration)" and "(from agent
  configuration)".
- Run the cold ``threads.get`` in ``_load_thread_model`` outside
  ``_model_pin_lock``; only cache publication takes the lock, with a
  post-GET re-check so an in-flight /model write still wins over the
  older snapshot.
- Detect the run-admission allowlist rejection in ``_handle_message``
  and recover: clear the stale pin (durable write plus cache under the
  lock) and reply with the reset and the /model recovery instead of
  the generic internal-error fallback.
- Document /model and the ``channel_model_name`` pin semantics in
  backend/app/channels/AGENTS.md.

* fix(channels): fence stale-pin recovery and cover streaming admission failures

Review round 4 found the stale-pin recovery itself unsafe and
incomplete:

- Recovery cleared whatever pin was current when the delayed
  admission rejection arrived, so a rejection for an old pin could
  erase a replacement selection committed mid-flight (and tell the
  user the new model was unavailable). ``_stale_pin_recovery`` now
  takes the failed run's own selection (``expected_pin``, read from
  its run context) and clears only while that selection is still
  current under the lock; a replaced or already-reset selection gets
  a non-destructive reply instead.
- Feishu/Telegram/DingTalk streaming turns never reached the recovery
  hook because ``_handle_streaming_chat`` swallows start_run's 400
  into its terminal-error reply. The recovery now runs in the
  streaming terminal-error path too, delivered as the single final
  outbound with the same dedupe cleanup; blocking, streaming, and
  fire-and-forget run-creation paths all handle the rejection at the
  site that knows the submitted selection.

* docs(channels): fit the /model entry within the agent-guidance size budget

The base file sits one byte under the AGENTS.md hard limit, so
documenting /model requires net-zero growth: keep the command-inventory
addition and a compact pin-semantics sentence, and losslessly tighten
the surrounding prose (the file was already at the limit).

* fix(channels): apply the review suggestions on /model replies

Three non-blocking findings from the latest review:

- The /models pin footer decorated the failure string too, advertising a
  pin next to an error that lists nothing. The command now fetches the
  listing through ``_fetch_gateway_json`` and appends the footer only on
  a successful fetch.
- The /model status reply labeled every session-layer model "from
  channel configuration", misreporting user- and default-layer models.
  The label is now "session configuration" for every session layer.
- Stale-pin recovery replies read as if the turn went through, but the
  run was rejected before starting and the triggering message never
  reached the agent. All recovery variants now end with an explicit
  "Your message was not processed — please send it again."

* fix(channels): recover stale model pins on the follow-up drain path

The drain is the fourth run-creation path and the only one without
stale-pin recovery: a /model pin that left the allowlist while follow-ups
were buffered made every drained runs.create fail admission, requeueing
the batch into the same 400 on every cycle with no user-facing recovery —
contradicting the documented "stale pins self-clear" invariant.

- Compute expected_pin from the drain's run context (mirroring
  _handle_chat_on_thread, initialized None since _resolve_run_params is
  itself inside the guarded try) and run _stale_pin_recovery on drain
  failure before requeueing; the pin self-clears and the requeued batch
  succeeds on the next cycle.
- Pass the caller-authoritative thread_id into _stale_pin_recovery:
  every call site already holds it, so recovery never re-derives it from
  the message — a store-mapping rewrite between dispatch and recovery
  can never clear the wrong conversation's pin, and the lookup's
  failure mode (and its guard) disappears entirely.
- Return StalePinRecovery(text, resolved) so requeueing callers can
  tell a cleared pin from a repeat failure: the drain logs INFO when
  the next cycle is free of this rejection and WARNING when recovery
  could not remove it (persist failed, or the selection came from
  session configuration, which still carries it) — no silent infinite
  requeue loop. Drain logs carry structured facts only: the reply text
  ends with a "please resend" note that would double-process buffered
  messages retried automatically (GitHub, the only buffering channel,
  sends log-only anyway).
- Reword the config-carried recovery reply: with no pin held, the
  rejected selection came from session configuration — name that source
  and the fix instead of claiming a default takeover that would not
  happen.
- Correct the _load_thread_model lock docstring (writes deliberately
  hold _model_pin_lock across threads.update) and read the agent config
  for /model status off the event loop via asyncio.to_thread, with a
  Blockbuster-gated blocking_io regression test.
- Pin the under-lock re-check in _stale_pin_recovery with a concurrency
  test (a /model committed during the lock-wait window wins; recovery
  persists nothing), mutation-verified — the drain is the only caller
  running on a background task without the serialized-run lock, so it is
  the path most exposed to that window.

* Revert "fix(channels): recover stale model pins on the follow-up drain path"

This reverts commit 0b4652c689afb56791e5e670901c97a4bf44a156. The drain
recovery work moves to a dedicated PR stacked on this branch, keeping #6534
scoped to the /model feature itself.

* fix(channels): recover stale model pins on the follow-up drain path

The drain is the fourth run-creation path and the only one without
stale-pin recovery: a /model pin that left the allowlist while follow-ups
were buffered made every drained runs.create fail admission, requeueing
the batch into the same 400 on every cycle with no user-facing recovery —
contradicting the documented "stale pins self-clear" invariant.

- Compute expected_pin from the drain's run context (mirroring
  _handle_chat_on_thread, initialized None since _resolve_run_params is
  itself inside the guarded try) and run _stale_pin_recovery on drain
  failure before requeueing; the pin self-clears and the requeued batch
  succeeds on the next cycle.
- Pass the caller-authoritative thread_id into _stale_pin_recovery:
  every call site already holds it, so recovery never re-derives it from
  the message — a store-mapping rewrite between dispatch and recovery
  can never clear the wrong conversation's pin, and the lookup's
  failure mode (and its guard) disappears entirely.
- Return StalePinRecovery(text, resolved) so requeueing callers can
  tell a cleared pin from a repeat failure: the drain logs INFO when
  the next cycle is free of this rejection and WARNING when recovery
  could not remove it (persist failed, or the selection came from
  session configuration, which still carries it) — no silent infinite
  requeue loop. Drain logs carry structured facts only: the reply text
  ends with a "please resend" note that would double-process buffered
  messages retried automatically (GitHub, the only buffering channel,
  sends log-only anyway).
- Reword the config-carried recovery reply: with no pin held, the
  rejected selection came from session configuration — name that source
  and the fix instead of claiming a default takeover that would not
  happen.
- Correct the _load_thread_model lock docstring (writes deliberately
  hold _model_pin_lock across threads.update) and read the agent config
  for /model status off the event loop via asyncio.to_thread, with a
  Blockbuster-gated blocking_io regression test.
- Pin the under-lock re-check in _stale_pin_recovery with a concurrency
  test (a /model committed during the lock-wait window wins; recovery
  persists nothing), mutation-verified — the drain is the only caller
  running on a background task without the serialized-run lock, so it is
  the path most exposed to that window.

(cherry picked from commit 0b4652c689afb56791e5e670901c97a4bf44a156)

* fix(channels): apply the third review round on /model command replies

- Delete the dead kind == "models" branch in _fetch_gateway: /models has
  rendered the listing inline since the pin-footer fix, so the helper's
  copy was unreachable and had already diverged (unguarded m["name"], no
  pin footer). The internal-auth-header test now drives the surviving
  /memory kind.
- Guard the inline /models listing comprehension like the
  _handle_model_command sibling (isinstance(m, Mapping) and
  isinstance(m.get("name"), str)), so a malformed payload entry can no
  longer turn the command into the generic internal-error reply;
  regression: test_models_command_skips_malformed_entries.
- Drop the unused user parameter from _resolve_bound_owner_for_model_list:
  the resolved owner comes solely from the trusted header, and the
  parameter implied the caller's own principal shaped the filter.

* fix(channels): apply the fourth review round on the /model lifecycle

- Preserve fresh model pins across cache eviction: a cold threads.get
  racing a successful /model write could still publish its stale
  snapshot after clear-on-pressure evicted the fresh entry — the
  in-cache re-check cannot see an eviction. The model-pin cache now
  carries a write generation; a load whose generation moved during the
  GET refetches once instead of publishing over the committed
  selection. Regression:
  test_fresh_model_pin_survives_cache_eviction_during_cold_load
  (blocked GET, write, eviction, then the refetch wins),
  mutation-verified.
- Resolve context-carried agents before reporting their model: /model
  status now mirrors the run path's effective-agent rule — explicit
  message/thread choice overwrites every carrier, then the merged
  carriers' agent_name (new _select_carried_agent_name, sharing the
  model-name precedence via _select_carried_value), then the
  session-layer assistant_id that only setdefaults into context. A
  session with context.agent_name=coder now reports coder-model instead
  of server default, and assistant_id=writer alongside
  context.agent_name=coder reports coder-model instead of writer-model.
  Regressions: context-carried, context-beats-assistant_id, and
  thread-pin-beats-context status tests.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 09:45:17 +08:00
..
2026-01-14 09:58:53 +08:00
2026-02-10 22:07:25 +08:00

DeerFlow Frontend

Like the original DeerFlow 1.0, we would love to give the community a minimalistic and easy-to-use web interface with a more modern and flexible architecture.

Tech Stack

Quick Start

Prerequisites

  • Node.js 22+
  • pnpm 10.26.2+

Installation

# Install dependencies
pnpm install

# Copy environment variables
cp .env.example .env
# Edit .env with your configuration

Development

# Start development server
pnpm dev

# The app will be available at http://localhost:3000

Build & Test

# Type check
pnpm typecheck

# Check formatting
pnpm format

# Apply formatting
pnpm format:write

# Lint
pnpm lint

# Run unit tests
pnpm test

# One-time setup: install Playwright Chromium browser
pnpm exec playwright install chromium

# Run E2E tests (builds and starts production server automatically)
pnpm test:e2e

# Build for production
pnpm build

# Start production server
pnpm start

Site Map

├── /                    # Landing page
├── /chats               # Chat list
├── /chats/new           # New chat page
└── /chats/[thread_id]   # A specific chat page

Configuration

Environment Variables

Key environment variables (see .env.example for full list):

# Backend API URL (optional, uses local Next.js/nginx proxy by default)
NEXT_PUBLIC_BACKEND_BASE_URL="http://localhost:8001"
# LangGraph-compatible API URL (optional, uses local Next.js/nginx proxy by default)
NEXT_PUBLIC_LANGGRAPH_BASE_URL="http://localhost:8001/api"

Project Structure

tests/
├── e2e/                    # E2E tests (Playwright, Chromium, mocked backend)
└── unit/                   # Unit tests (mirrors src/ layout)
src/
├── app/                    # Next.js App Router pages
│   ├── api/                # API routes
│   ├── showcase/           # Allowlisted public read-only demos
│   ├── workspace/          # Main workspace pages
│   └── mock/               # Mock/demo pages
├── components/             # React components
│   ├── ui/                 # Reusable UI components
│   ├── workspace/          # Workspace-specific components
│   ├── landing/            # Landing page components
│   └── ai-elements/        # AI-related UI elements
├── core/                   # Core business logic
│   ├── api/                # API client & data fetching
│   ├── artifacts/          # Artifact management
│   ├── config/              # App configuration
│   ├── i18n/               # Internationalization
│   ├── mcp/                # MCP integration
│   ├── messages/           # Message handling
│   ├── models/             # Data models & types
│   ├── settings/           # User settings
│   ├── skills/             # Skills system
│   ├── threads/            # Thread management
│   ├── todos/              # Todo system
│   └── utils/              # Utility functions
├── hooks/                  # Custom React hooks
├── lib/                    # Shared libraries & utilities
├── server/                 # Server-side code
│   └── better-auth/        # Authentication setup and session helpers
└── styles/                 # Global styles

Scripts

Command Description
pnpm dev Start development server with Webpack
pnpm build Build for production
pnpm start Start production server
pnpm test Run unit tests with Rstest
pnpm test:e2e Run E2E tests with Playwright
pnpm format Check formatting with Prettier
pnpm format:write Apply formatting with Prettier
pnpm lint Run ESLint
pnpm lint:fix Fix ESLint issues
pnpm typecheck Run TypeScript type checking
pnpm check Run both lint and typecheck

Development Notes

  • Uses pnpm workspaces (see packageManager in package.json)
  • Webpack is the default development bundler until the upstream Turbopack PostCSS worker leak is fixed in a stable Next.js release (#5132). Set DEER_FLOW_DEV_BUNDLER=turbo to opt in to Turbopack for local diagnosis, or DEER_FLOW_DEV_BUNDLER=webpack to select Webpack explicitly. Reconsider the default after the stable fix is verified on macOS arm64 and Linux.
  • Environment validation can be skipped with SKIP_ENV_VALIDATION=1 (useful for Docker)
  • Backend API URLs are optional; nginx proxy is used by default in development

License

MIT License. See LICENSE for details.