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
76 changes: 76 additions & 0 deletions backend/api/intents_onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""ADR-012 stage 12.6: the bundled sample workspace.

One intent, one fixed fixture: `loadSampleWorkspace` populates the CURRENT
session's scene with a small, hardcoded 3-node demo (a note explaining what
Graphlink is, plus a short chat exchange) - no LLM call, nothing random, so
it is trivially repeatable both for a new user's first click and for an E2E
test fixture (doc/adr/ADR-012-ui-ux-system.md's stage 12.6 exit criterion:
"E2E uses the sample fixture"). See OnboardingDialog.tsx (web_ui) for the
frontend wizard that calls this, and SceneCanvas.tsx's empty-canvas hint for
the other caller.

Registered the same "scene" topic every other canvas-mutating intent uses
(register_node_intents/register_groups_intents precedent) rather than a new
topic - this creates ordinary scene nodes through the ordinary SceneDocument
API, it just picks their content for the caller instead of the caller typing
it.
"""

from __future__ import annotations

from backend.api._shared import make_publish_scene
from backend.domain.graph import SceneDocument
from backend.events import SessionBus

# Fixed, hand-written content - deliberately NOT generated at request time.
# Keeping this hardcoded (rather than, say, a one-shot LLM call) is what
# makes the fixture deterministic: the same 3 nodes, same kinds, same text,
# every single call, on a fresh machine with no provider configured at all.
SAMPLE_NOTE_CONTENT = (
"Welcome to Graphlink - a visual AI workspace. Every message becomes a "
"node on this canvas; branch a conversation by replying from any earlier "
"node, and connect nodes into a graph as your thinking grows. The short "
"exchange below shows the idea in miniature."
)
SAMPLE_USER_MESSAGE = "What can I do with Graphlink?"
SAMPLE_ASSISTANT_MESSAGE = (
"Graphlink turns each conversation turn into a node you can branch, "
"compare, and connect - explore multiple directions from the same "
"prompt, write and run code inline, or bring in documents and web "
"research alongside your chat. Try replying to any node to start your "
"own branch, or just type a message below to begin for real."
)


def register_onboarding_intents(bus: SessionBus, document: SceneDocument) -> None:
publish_scene = make_publish_scene(bus)

async def load_sample_workspace():
# ADR-010 stage 10.3: one composite so a single Ctrl+Z removes the
# whole sample workspace as one action, not 3 separate undo steps for
# a fixture the user did not build node-by-node themselves - the
# documented use case composite() itself names (commands.py's own
# docstring).
with document.composite("loadSampleWorkspace", "user"):
note, _command = document.record_command(
"addNote", "user", lambda: document.add_note(-360, -140),
)
document.record_command(
"setNoteContent", "user",
lambda: document.set_note_content(note.id, SAMPLE_NOTE_CONTENT),
node_ids=[note.id],
)
user_node, _command = document.record_command(
"addChatNode", "user",
lambda: document.add_chat_node(40, -140, SAMPLE_USER_MESSAGE, True),
)
document.record_command(
"addChatNode", "user",
lambda: document.add_chat_node(
40, 60, SAMPLE_ASSISTANT_MESSAGE, False, user_node.id,
),
node_ids=[user_node.id],
)
await publish_scene()

bus.register_intent("scene", "loadSampleWorkspace", load_sample_workspace)
82 changes: 82 additions & 0 deletions backend/api/intents_settings_general.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
Ollama/Llama.cpp from API mode without a restart. It routes through
backend.agents.apply_provider_mode (the exact same three-way dispatch
bootstrap_provider_state uses at startup) and persists the choice.

ADR-012 stage 12.6 adds setMcpServers - the deferred UI-write half of
ADR-007 stage 7.5's MCP client (backend/mcp_client.py's own module
docstring explicitly deferred this surface to ADR-012). Bulk-replace, same
"whole value" persistence shape as SettingsManager.get_mcp_servers/
set_mcp_servers themselves (graphlink_settings_store.py) - the frontend's
MCP Servers page always sends the FULL updated array, never a single-
server patch.
"""

from __future__ import annotations
Expand All @@ -35,6 +43,47 @@
)


def _mcp_servers_from_wire(servers: object) -> list[dict] | None:
"""Maps setMcpServers' wire-shaped args (camelCase, mirroring
AppSettingsStatePayload's own McpServerConfigPayload) back to the
snake_case keys SettingsManager.set_mcp_servers expects - the write-
side counterpart of backend/settings.py's _mcp_servers_for_wire, kept
here rather than there since intents_settings_*.py modules cannot
import backend.settings (see _settings_shared.py's own docstring on
why: backend.settings imports THIS module to register these intents,
so the reverse import would be circular).

Returns None when the payload isn't even list-of-dicts shaped - the
ONE thing set_mcp_servers below rejects outright, matching
setProviderMode's own "reject a shape validation can't make sense of,
without touching persisted state" posture just above. Per-entry field
problems (a blank name/command, wrong-typed args/scopes/timeout) are
deliberately NOT re-validated here: SettingsManager.set_mcp_servers
already does its own tolerant normalization on the way in (name/command
required, everything else defaulted - see its own docstring), the same
"malformed entries dropped, not raised on" policy get_mcp_servers reads
back with. Duplicating that per-field logic here would just be a second
copy that could drift from the one SettingsManager itself is already
tested against."""
if not isinstance(servers, list):
return None
normalized = []
for entry in servers:
if not isinstance(entry, dict):
return None
normalized.append({
"name": entry.get("name", ""),
"command": entry.get("command", ""),
"args": entry.get("args", []),
"scopes": entry.get("scopes", []),
"approval": entry.get("approval", "always"),
"enabled_tools": entry.get("enabledTools", []),
"enabled": entry.get("enabled", True),
"timeout": entry.get("timeout", 30.0),
})
return normalized


def register_settings_general_intents(
bus: SessionBus,
manager: SettingsManager,
Expand Down Expand Up @@ -85,6 +134,22 @@ async def set_auto_model_policy(policy: str):
await asyncio.to_thread(run_locked, manager.set_auto_model_policy, str(policy))
await bus.publish("app-settings")

async def set_theme(theme: str):
# ADR-012 stage 12.2: persist only, same shape as set_auto_model_policy
# above - unlike set_log_level, there is no backend-side live-apply
# step, since theme is purely a frontend DOM concern (App.tsx's own
# applyTheme reads it straight off the republished snapshot below).
await asyncio.to_thread(run_locked, manager.set_theme, str(theme))
await bus.publish("app-settings")

async def set_has_completed_onboarding(completed: bool):
# ADR-012 stage 12.6: persist only, same shape as set_theme above -
# OnboardingDialog.tsx fires this the moment it closes (any
# dismissal, not only a "Done" click) so the wizard never auto-opens
# again once the user has seen it once.
await asyncio.to_thread(run_locked, manager.set_has_completed_onboarding, bool(completed))
await bus.publish("app-settings")

async def set_notification_preference(notification_type: str, enabled: bool):
await asyncio.to_thread(
run_locked, manager.set_notification_preferences, {str(notification_type): bool(enabled)}
Expand Down Expand Up @@ -135,12 +200,29 @@ async def set_provider_mode(mode: str):
await bus.publish("notification")
await bus.publish("app-settings")

async def set_mcp_servers(servers: object):
# ADR-012 stage 12.6: bulk-replace, matching SettingsManager.
# set_mcp_servers' own "replace the whole collection" posture (see
# its docstring) - the MCP Servers settings page always sends the
# FULL updated array, never a single-server patch.
normalized = _mcp_servers_from_wire(servers)
if normalized is None:
if notifications is not None:
notifications.show("MCP server list is malformed - nothing was saved.", "warning")
await bus.publish("notification")
return
await asyncio.to_thread(run_locked, manager.set_mcp_servers, normalized)
await bus.publish("app-settings")

bus.register_intent("app-settings", "setActiveSection", set_active_section)
bus.register_intent("app-settings", "setShowTokenCounter", set_show_token_counter)
bus.register_intent("app-settings", "setEnableSystemPrompt", set_enable_system_prompt)
bus.register_intent("app-settings", "setLogLevel", set_log_level)
bus.register_intent("app-settings", "setAutoModelPolicy", set_auto_model_policy)
bus.register_intent("app-settings", "setTheme", set_theme)
bus.register_intent("app-settings", "setHasCompletedOnboarding", set_has_completed_onboarding)
bus.register_intent("app-settings", "setNotificationPreference", set_notification_preference)
bus.register_intent("app-settings", "setGithubToken", set_github_token)
bus.register_intent("app-settings", "clearGithubToken", clear_github_token)
bus.register_intent("app-settings", "setProviderMode", set_provider_mode)
bus.register_intent("app-settings", "setMcpServers", set_mcp_servers)
4 changes: 4 additions & 0 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ def _placeholder_chart_data(chart_type: str) -> dict[str, Any]:
from backend.api.intents_knowledge import register_knowledge_intents # noqa: E402
from backend.api.intents_model_routing import register_model_routing_intents # noqa: E402
from backend.api.intents_nodes import register_node_intents # noqa: E402
from backend.api.intents_onboarding import register_onboarding_intents # noqa: E402
from backend.api.intents_pins import register_pins_intents # noqa: E402
from backend.api.intents_builder import register_builder_intents # noqa: E402
from backend.api.intents_pycoder import register_pycoder_intents # noqa: E402
Expand Down Expand Up @@ -368,6 +369,9 @@ def register_canvas(
register_builder_intents(bus, document, notifications, agent_dispatcher)

register_groups_intents(bus, document)
# ADR-012 stage 12.6: the bundled sample workspace - see that module's
# own docstring.
register_onboarding_intents(bus, document)
register_knowledge_intents(bus, document, notifications)
register_model_routing_intents(bus, document)
register_pins_intents(bus, document)
Expand Down
48 changes: 48 additions & 0 deletions backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,28 @@ def _api_model_catalog_for_wire(manager: SettingsManager, provider: str) -> list
]


def _mcp_servers_for_wire(manager: SettingsManager) -> list[dict[str, Any]]:
# SettingsManager.get_mcp_servers (graphlink_settings_store.py) always
# returns snake_case enabled_tools keys - its own internal convention,
# not this payload's - same boundary-mapping posture as
# _api_model_catalog_for_wire above, which maps model_id -> modelId for
# exactly the same reason. ADR-012 stage 12.6: the read side of the new
# setMcpServers intent (backend/api/intents_settings_general.py).
return [
{
"name": entry["name"],
"command": entry["command"],
"args": list(entry.get("args", [])),
"scopes": list(entry.get("scopes", [])),
"approval": entry.get("approval", "always"),
"enabledTools": list(entry.get("enabled_tools", [])),
"enabled": bool(entry.get("enabled", True)),
"timeout": float(entry.get("timeout", 30.0)),
}
for entry in manager.get_mcp_servers()
]


def _flatten_ollama_assignment(assignment: Any) -> str:
# Wire representation collapses {"mode": ..., "model_id": ...} to a
# single string ("inherit"/"auto"/an explicit model id) - matches the
Expand Down Expand Up @@ -205,6 +227,15 @@ def settings_payload(manager: SettingsManager) -> dict[str, Any]:
# policy api_provider's auto-fallback consults when neither an
# explicit task assignment nor a node/branch override exists.
"autoModelPolicy": manager.get_auto_model_policy(),
# ADR-012 stage 12.2: "system" | "light" | "dark" - the frontend
# stamps this straight onto <html data-theme=...> (or removes the
# attribute entirely for "system", handing off to
# prefers-color-scheme - see App.tsx's own applyTheme).
"theme": manager.get_theme(),
# ADR-012 stage 12.6: whether the first-run onboarding wizard has
# ever been completed/dismissed - see AppSettingsStatePayload's own
# field doc for the full contract.
"hasCompletedOnboarding": manager.get_has_completed_onboarding(),
}


Expand Down Expand Up @@ -248,6 +279,15 @@ def _api_key_source(stored: bool, provider: str) -> str:
def _build_settings_payload(manager: SettingsManager, state: SettingsSessionState) -> dict[str, Any]:
payload = settings_payload(manager)
payload["activeSection"] = state.active_section
# ADR-012 stage 12.6: the provider-mode switcher's read side - which of
# the 3 mode pages (Ollama/Llama.cpp/API Endpoint) is actually live.
# ADR-006 stage 6.5 added setProviderMode as the write side but nothing
# ever surfaced this back to the frontend, so the per-mode Settings
# pages had no way to show which mode was active or reflect a switch
# that just happened. Same value bootstrap_provider_state (backend/
# agents.py) applies at startup and setProviderMode persists at
# runtime - one field, one source of truth.
payload["providerMode"] = manager.get_current_mode()
viewing_provider = state.viewing_api_provider
payload["activeApiProvider"] = manager.get_api_provider()
payload["viewingApiProvider"] = viewing_provider
Expand Down Expand Up @@ -311,6 +351,14 @@ def _build_settings_payload(manager: SettingsManager, state: SettingsSessionStat
payload["llamaCppScanSummary"] = _llama_cpp_scan_summary(manager)
payload["llamaCppScanStatus"] = state.llama_scan_status
payload["llamaCppNotice"] = state.llama_notice

# ADR-012 stage 12.6: MCP Servers page - the read side of the new
# setMcpServers intent (backend/api/intents_settings_general.py). The
# ADR-007 stage 7.5 gap backend/mcp_client.py's own module docstring
# explicitly deferred to ADR-012: an McpServerConfig dataclass and
# SettingsManager.get_mcp_servers/set_mcp_servers persistence already
# existed with zero UI surface to view or edit the configured list.
payload["mcpServers"] = _mcp_servers_for_wire(manager)
return payload


Expand Down
102 changes: 102 additions & 0 deletions backend/tests/test_intents_onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""ADR-012 stage 12.6: the bundled sample workspace's one intent
(backend/api/intents_onboarding.py) - loadSampleWorkspace.

Mirrors test_intents_knowledge.py's own shape: a dedicated test file per
intents_*.py module, built on test_canvas.py's shared make_bus() (the
full register_canvas surface), rather than folding into test_canvas.py
directly."""

from __future__ import annotations

import asyncio

from backend.api.intents_onboarding import (
SAMPLE_ASSISTANT_MESSAGE,
SAMPLE_NOTE_CONTENT,
SAMPLE_USER_MESSAGE,
)
from backend.tests.test_canvas import make_bus


def _run(coro):
return asyncio.run(coro)


def test_load_sample_workspace_creates_the_expected_fixture():
bus, document, recorder = make_bus()

_run(bus.dispatch_intent("scene", "loadSampleWorkspace", []))

assert len(document.nodes) == 3
kinds = sorted(node.kind for node in document.nodes.values())
assert kinds == ["chat", "chat", "note"]

note = next(n for n in document.nodes.values() if n.kind == "note")
assert note.content == SAMPLE_NOTE_CONTENT

user_chat = next(n for n in document.nodes.values() if n.kind == "chat" and n.state.is_user)
assistant_chat = next(n for n in document.nodes.values() if n.kind == "chat" and not n.state.is_user)
assert user_chat.content == SAMPLE_USER_MESSAGE
assert assistant_chat.content == SAMPLE_ASSISTANT_MESSAGE

# The assistant reply is a real branch continuation of the user message,
# not a free-floating node - one edge, connecting exactly those two.
assert len(document.edges) == 1
edge = next(iter(document.edges.values()))
assert (edge.source, edge.target) == (user_chat.id, assistant_chat.id)


def test_load_sample_workspace_is_deterministic_and_repeatable():
"""No LLM call, nothing random - the exit criterion ("E2E uses the
sample fixture") needs this to produce the SAME 3 nodes/kinds every
time, not just once."""
bus, document, recorder = make_bus()

_run(bus.dispatch_intent("scene", "loadSampleWorkspace", []))
first_kinds = sorted(node.kind for node in document.nodes.values())
first_contents = sorted(node.content for node in document.nodes.values())

bus2, document2, _recorder2 = make_bus()
_run(bus2.dispatch_intent("scene", "loadSampleWorkspace", []))
second_kinds = sorted(node.kind for node in document2.nodes.values())
second_contents = sorted(node.content for node in document2.nodes.values())

assert first_kinds == second_kinds
assert first_contents == second_contents


def test_load_sample_workspace_publishes_the_scene_topic():
bus, document, recorder = make_bus()
recorder.messages.clear()

_run(bus.dispatch_intent("scene", "loadSampleWorkspace", []))

assert recorder.topics_seen().count("scene") == 1


def test_load_sample_workspace_is_undoable_as_a_single_composite():
"""ADR-010 stage 10.3: the 3-node create + the note's content-set are one
composite - one Ctrl+Z removes the whole fixture, not one node at a
time."""
bus, document, recorder = make_bus()

_run(bus.dispatch_intent("scene", "loadSampleWorkspace", []))
assert len(document.nodes) == 3
assert document.can_undo()

document.undo()
assert len(document.nodes) == 0


def test_load_sample_workspace_appends_to_an_already_populated_scene():
"""Not a clear-then-populate: calling it on a scene that already has
content just adds the fixture's 3 nodes alongside whatever was there,
matching every other addXNode intent's own behavior (no implicit
scene-wide reset anywhere else in this app)."""
bus, document, recorder = make_bus()
_run(bus.dispatch_intent("scene", "addNode", [0, 0, "existing"]))
assert len(document.nodes) == 1

_run(bus.dispatch_intent("scene", "loadSampleWorkspace", []))

assert len(document.nodes) == 4
Loading