diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile new file mode 100644 index 0000000..757021f --- /dev/null +++ b/apps/agent/Dockerfile @@ -0,0 +1,14 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY main.py ./ +COPY src ./src +COPY skills ./skills + +EXPOSE 8123 +CMD ["uv", "run", "--no-sync", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8123"] diff --git a/apps/agent/src/model.py b/apps/agent/src/model.py index 33725ab..c1e7062 100644 --- a/apps/agent/src/model.py +++ b/apps/agent/src/model.py @@ -6,6 +6,7 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.messages import AIMessageChunk +from langchain_core.runnables import RunnableBinding from pydantic import Field from langchain_anthropic import ChatAnthropic from langchain_core.language_models.chat_models import BaseChatModel @@ -146,31 +147,48 @@ def _target(self, kwargs): tool_options = options.pop("_credential_tool_options", {}) if tools is not None: model = model.bind_tools(tools, **tool_options) + # Call the provider's private methods so only this wrapper's run reports + # events. A public nested call inherits the graph's callbacks and streams + # every token and tool-call delta to AG-UI a second time. + if isinstance(model, RunnableBinding): + options = {**model.kwargs, **options} + model = model.bound return model, options def _generate(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - message = model.invoke(messages, stop=stop, **options) - return ChatResult(generations=[ChatGeneration(message=message)]) + return model._generate(messages, stop=stop, **options) async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - message = await model.ainvoke(messages, stop=stop, **options) - return ChatResult(generations=[ChatGeneration(message=message)]) + return await model._agenerate(messages, stop=stop, **options) def _stream(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - for chunk in model.stream(messages, stop=stop, **options): - yield ChatGenerationChunk(message=as_chunk(chunk)) + if not streams(model): + yield as_generation_chunk(model._generate(messages, stop=stop, **options)) + return + yield from model._stream(messages, stop=stop, **options) async def _astream(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - async for chunk in model.astream(messages, stop=stop, **options): - yield ChatGenerationChunk(message=as_chunk(chunk)) + if not streams(model): + yield as_generation_chunk(await model._agenerate(messages, stop=stop, **options)) + return + async for chunk in model._astream(messages, stop=stop, **options): + yield chunk + + +def streams(model): + return type(model)._stream is not BaseChatModel._stream or type(model)._astream is not BaseChatModel._astream + + +def as_generation_chunk(result): + return ChatGenerationChunk(message=as_chunk(result.generations[0].message)) def as_chunk(message): diff --git a/apps/agent/tests/test_model.py b/apps/agent/tests/test_model.py index b9fc29f..7b860a6 100644 --- a/apps/agent/tests/test_model.py +++ b/apps/agent/tests/test_model.py @@ -84,3 +84,32 @@ def test_missing_provider_key_has_actionable_error(monkeypatch, model, key, valu monkeypatch.setenv(key, value) with pytest.raises(ValueError, match=key): build_model() + + +def test_credential_scoped_model_streams_each_chunk_once(): + import asyncio + from langchain_core.language_models.fake_chat_models import GenericFakeChatModel + from langchain_core.messages import AIMessage + from langchain_core.runnables import RunnableLambda + from src.model import CredentialScopedModel + + class ToolFake(GenericFakeChatModel): + def bind_tools(self, tools, **kwargs): + return self.bind(tools=tools) + + inner = ToolFake(messages=iter([AIMessage(content="a b c")])) + # Bound tools wrap the provider in a RunnableBinding, as in the real agent. + wrapped = CredentialScopedModel(fallback=inner).bind_tools([{"name": "t"}]) + + # Like a LangGraph node: the parent run's callbacks are inherited by calls inside it. + async def node(_): + return await wrapped.ainvoke("hi") + + async def stream_events(): + return [ + e async for e in RunnableLambda(node).astream_events("x", version="v2") + if e["event"] == "on_chat_model_stream" + ] + + tokens = [e["data"]["chunk"].content for e in asyncio.run(stream_events())] + assert "".join(tokens) == "a b c" diff --git a/apps/app/src/components/chat/provider-keys.test.tsx b/apps/app/src/components/chat/provider-keys.test.tsx index 6b69843..204acb0 100644 --- a/apps/app/src/components/chat/provider-keys.test.tsx +++ b/apps/app/src/components/chat/provider-keys.test.tsx @@ -97,4 +97,11 @@ describe("memory-only provider keys", () => { fireEvent.click(screen.getByText("Cancel")); expect(options?.signal?.aborted).toBe(true); }); + it("treats server-held keys as configured when NEXT_PUBLIC_SERVER_KEYS is true", () => { + vi.stubEnv("NEXT_PUBLIC_SERVER_KEYS", "true"); + render(); + // Gate passes, but no browser headers are sent: the agent uses its own .env keys. + expect(screen.getByTestId("active")).toBeEmptyDOMElement(); + vi.unstubAllEnvs(); + }); }); diff --git a/apps/app/src/components/chat/provider-keys.tsx b/apps/app/src/components/chat/provider-keys.tsx index 8840ee6..1309b53 100644 --- a/apps/app/src/components/chat/provider-keys.tsx +++ b/apps/app/src/components/chat/provider-keys.tsx @@ -27,7 +27,8 @@ export function ProviderKeysProvider({ children }: { children: ReactNode }) { return ( { setKeys(value); setKeysOpen(false); setSession((value) => value + 1); }, clear: () => { setKeys(null); newChat(); }, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c23b468 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + agent: + build: apps/agent + env_file: apps/agent/.env + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8123/health')"] + interval: 30s + + mcp: + build: + context: . + dockerfile: docker/Dockerfile.mcp + ports: + - "127.0.0.1:3100:3100" + restart: unless-stopped + + app: + build: + context: . + dockerfile: docker/Dockerfile.app + args: + NEXT_PUBLIC_SERVER_KEYS: "true" + environment: + LANGGRAPH_DEPLOYMENT_URL: http://agent:8123 + MCP_SERVER_URL: http://mcp:3100/mcp + ports: + - "127.0.0.1:${APP_PORT:-3000}:3000" + depends_on: [agent, mcp] + restart: unless-stopped diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index 0ae73b2..bb0bb2e 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -32,6 +32,9 @@ COPY --from=deps /app/packages/design-system/node_modules ./packages/design-syst # Copy source code COPY . . +# Inlined at build time; "true" skips the browser key prompt. +ARG NEXT_PUBLIC_SERVER_KEYS + # Enable pnpm RUN corepack enable && corepack prepare pnpm@latest --activate diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp new file mode 100644 index 0000000..1412ca2 --- /dev/null +++ b/docker/Dockerfile.mcp @@ -0,0 +1,19 @@ +# The MCP depends on @repo/design-system, so build from the workspace root. +FROM node:22-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9 --activate +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/mcp/package.json ./apps/mcp/ +COPY packages/design-system/package.json ./packages/design-system/ +RUN pnpm install --frozen-lockfile --filter open-intelligent-ui-mcp... +COPY packages/design-system ./packages/design-system +COPY apps/mcp ./apps/mcp +RUN pnpm exec turbo run build --filter=open-intelligent-ui-mcp + +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /app ./ +WORKDIR /app/apps/mcp +USER node +EXPOSE 3100 +CMD ["node", "dist/index.js"] diff --git a/docs/getting-started.md b/docs/getting-started.md index 73fa6fc..ba65efb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -40,9 +40,20 @@ Optional settings belong in `apps/app/.env.local` or the frontend process enviro | `RATE_LIMIT_ENABLED` | `false` | Per-IP runtime rate limit | | `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window | | `RATE_LIMIT_MAX` | `40` | Requests per window | +| `NEXT_PUBLIC_SERVER_KEYS` | Unset | Build-time; `true` skips the browser key prompt when the agent has its own keys | The root [.env.example](../.env.example) documents both services' settings; placing variables only in a root `.env` is not the setup described here. +## Run with Docker Compose + +To self-host the app, agent, and MCP server with the agent's own keys, fill in `apps/agent/.env` (see above) and run: + +```bash +docker compose up -d +``` + +The app listens on `http://localhost:3000` (set `APP_PORT` to change it) and the MCP server on `http://localhost:3100/mcp`; both bind to `127.0.0.1`. The compose build sets `NEXT_PUBLIC_SERVER_KEYS=true`, so chat uses the agent's `.env` keys without asking for browser keys. After editing `apps/agent/.env`, run `docker compose up -d agent` to apply it. + ## Start and verify ```bash