diff --git a/docs/assistant-ui/config-mcp.png b/docs/assistant-ui/config-mcp.png new file mode 100644 index 00000000..998c45d3 Binary files /dev/null and b/docs/assistant-ui/config-mcp.png differ diff --git a/docs/assistant-ui/config-skills.png b/docs/assistant-ui/config-skills.png new file mode 100644 index 00000000..4dbd73f9 Binary files /dev/null and b/docs/assistant-ui/config-skills.png differ diff --git a/docs/assistant-ui/surface.png b/docs/assistant-ui/surface.png new file mode 100644 index 00000000..d7d32d30 Binary files /dev/null and b/docs/assistant-ui/surface.png differ diff --git a/docs/assistant-ui/thread-tool-open.png b/docs/assistant-ui/thread-tool-open.png new file mode 100644 index 00000000..ce241b4c Binary files /dev/null and b/docs/assistant-ui/thread-tool-open.png differ diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index e8eb6f2e..565242cf 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -62,7 +62,8 @@ "@we/template-default": "workspace:*", "@we/template-shell": "workspace:*", "@we/editor": "workspace:*", - "@we/globe-widget": "workspace:*" + "@we/globe-widget": "workspace:*", + "@we/module-assistant": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/packages/app-shell/src/frameworks/solid/stores/DatasetStore.tsx b/packages/app-shell/src/frameworks/solid/stores/DatasetStore.tsx index 2afed234..9c5777a3 100644 --- a/packages/app-shell/src/frameworks/solid/stores/DatasetStore.tsx +++ b/packages/app-shell/src/frameworks/solid/stores/DatasetStore.tsx @@ -100,8 +100,17 @@ export function DatasetStoreProvider(props: ParentProps) { // The *global* uri, never the local uuid — a uuid is local per-agent, so a call id derived // from one would differ on every peer and each would join a call only they can see. datasetUri: () => currentDataset()?.sharedUrl ?? null, + // The personal root dataset, for modules with agent-scoped models (assistant config etc.). + rootDataset: () => rootDataset() ?? null, selfId: () => session.me()?.did ?? null, ephemeral: session.ephemeralPort, + // Backend connection details for modules that declare this backend and talk to its HTTP + // surface directly. Degrades to null like presence on hosts without one. + connection: () => { + const port = session.port(); + const token = session.token(); + return port !== undefined && token !== undefined ? { port, token, url: session.serverUrl() } : null; + }, }); // Converts null → undefined so that when JSON-serialised into an ORM WHERE clause, @@ -257,6 +266,9 @@ export function DatasetStoreProvider(props: ParentProps) { if (rootP) { // Ensure all models are registered (handles new models added after initial creation) await session.backendPorts()!.schemas.installRoot(rootP); + // Agent-scoped module models install to the personal root dataset at boot (space-scoped + // ones install per space switch) — see ModuleDefinition.agentModels. + await session.backendPorts()!.schemas.installModules(rootP, moduleRegistry.agentModels()); setRootDataset(rootP); const settings = await AgentSettings.findOne(rootP); @@ -296,6 +308,7 @@ export function DatasetStoreProvider(props: ParentProps) { console.log('DatasetStore: creating root dataset'); const perspective = (await lifecycle.create('we-root')).handle as DatasetProxy; await session.backendPorts()!.schemas.installRoot(perspective); + await session.backendPorts()!.schemas.installModules(perspective, moduleRegistry.agentModels()); const settings = await AgentSettings.create(perspective, { currentTemplateId: 'default', diff --git a/packages/app-shell/src/shared/registries/bundledModules.ts b/packages/app-shell/src/shared/registries/bundledModules.ts index 59f8b0d6..bdda0c84 100644 --- a/packages/app-shell/src/shared/registries/bundledModules.ts +++ b/packages/app-shell/src/shared/registries/bundledModules.ts @@ -9,6 +9,7 @@ * * Adding a module here plus an id in `we-seed.json` is the whole install story for now. */ +import { assistantModule } from '@we/module-assistant'; import { callModule } from '@we/module-call'; import { createGlobeModule } from '@we/module-globe'; import { notesModule } from '@we/module-notes'; @@ -33,6 +34,8 @@ export interface ActivationDeps extends BundledModuleDeps { export const bundledModules: Record = { globe: ({ components }) => createGlobeModule(components.CesiumGlobe), + // Ships its own Solid components (declared via `frameworks`), so it takes nothing from the host. + assistant: () => assistantModule, // Takes nothing from the host: every piece of its UI is a schema fragment, so it imports no // framework at all. notes: () => notesModule, diff --git a/packages/app-shell/src/shared/registries/moduleHostServices.ts b/packages/app-shell/src/shared/registries/moduleHostServices.ts index 23ebb688..63a9f98e 100644 --- a/packages/app-shell/src/shared/registries/moduleHostServices.ts +++ b/packages/app-shell/src/shared/registries/moduleHostServices.ts @@ -25,6 +25,8 @@ import type { ModuleStoreDeps } from '@we/module-shared'; export interface ModuleHostServices { dataset?: () => DatasetHandle | null; datasetUri?: () => string | null; + rootDataset?: () => DatasetHandle | null; + connection?: () => { url?: string; port?: number; token?: string } | null; selfId?: () => string | null; ephemeral?: EphemeralPort; presence?: { @@ -67,6 +69,8 @@ export function createModuleStoreDeps(framework: { dataset: () => services.dataset?.() ?? null, datasetUri: () => services.datasetUri?.() ?? null, + rootDataset: () => services.rootDataset?.() ?? null, + connection: () => services.connection?.() ?? null, selfId: () => services.selfId?.() ?? null, // A stable function that forwards, so a module capturing `deps.ephemeral` at construction still diff --git a/packages/app-shell/src/shared/registries/moduleRegistry.ts b/packages/app-shell/src/shared/registries/moduleRegistry.ts index a34fc4d4..28683db7 100644 --- a/packages/app-shell/src/shared/registries/moduleRegistry.ts +++ b/packages/app-shell/src/shared/registries/moduleRegistry.ts @@ -104,7 +104,7 @@ export const moduleRegistry = { // Predicates are how existing data is found, so minting one outside the module's own subtree is // not a bug to fix later — by the time it is noticed, data has been written under a name nobody // can adjudicate. Refused at registration for the same reason an incompatible backend is. - const badPredicates = (definition.models ?? []).flatMap((model) => + const badPredicates = [...(definition.models ?? []), ...(definition.agentModels ?? [])].flatMap((model) => modulePredicateViolations(definition.id, getModelPredicates(model as Parameters[0])), ); if (badPredicates.length) { @@ -136,7 +136,10 @@ export const moduleRegistry = { // different moment: SDNA install (in `installSpaceSdna`) puts the *shape* in the perspective, // while this puts the *class* where `model.create` / `$query` can resolve it by name. Without // this one the panel renders and only writing a note fails. - for (const model of (definition.models ?? []) as ModelClass[]) { + // Space-scoped and agent-scoped models both register as resolvable classes; the same entity may + // appear in both lists (installed into both kinds of dataset), so dedupe by class. + const allModels = [...new Set([...(definition.models ?? []), ...(definition.agentModels ?? [])])]; + for (const model of allModels as ModelClass[]) { registerModel((model as unknown as { className: string }).className, model); } @@ -156,7 +159,8 @@ export const moduleRegistry = { const entry = modules.get(id); if (!entry) return; for (const index of (entry.definition.slots ?? []).keys()) slotRegistry.remove(`${id}:${index}`); - for (const model of (entry.definition.models ?? []) as ModelClass[]) { + const allModels = [...new Set([...(entry.definition.models ?? []), ...(entry.definition.agentModels ?? [])])]; + for (const model of allModels as ModelClass[]) { unregisterModel((model as unknown as { className: string }).className); } delete moduleStores[id]; @@ -197,6 +201,11 @@ export const moduleRegistry = { return moduleRegistry.all().flatMap((m) => m.definition.models ?? []); }, + /** Entity types modules install into the agent's root dataset — see `ModuleDefinition.agentModels`. */ + agentModels(): unknown[] { + return moduleRegistry.all().flatMap((m) => m.definition.agentModels ?? []); + }, + /** * Every registered module that contributes an embedded application, in registration order. * diff --git a/packages/app-shell/vitest.config.ts b/packages/app-shell/vitest.config.ts index 336f759e..8d40a596 100644 --- a/packages/app-shell/vitest.config.ts +++ b/packages/app-shell/vitest.config.ts @@ -10,7 +10,8 @@ const alias = { '@shared': r('./src/shared'), '@solid': r('./src/frameworks/soli // Tests that mount real Solid components/providers — they need jsdom, browser resolve // conditions, and the solid transform. Everything else stays in the plain node project. -const SOLID_TESTS = ['tests/executorFreeBoot.test.tsx']; +// bundledModules imports the assistant module's Solid components through its definition. +const SOLID_TESTS = ['tests/executorFreeBoot.test.tsx', 'tests/bundledModules.test.ts']; export default defineConfig({ test: { diff --git a/packages/module-system/assistant/package.json b/packages/module-system/assistant/package.json new file mode 100644 index 00000000..600d3423 --- /dev/null +++ b/packages/module-system/assistant/package.json @@ -0,0 +1,34 @@ +{ + "name": "@we/module-assistant", + "version": "0.1.0", + "description": "AI-assistant feature module — threads/messages rendered from the dataset, replies written by the backend", + "type": "module", + "exports": { + ".": { "import": "./src/index.ts", "types": "./src/index.ts" } + }, + "scripts": { "test": "vitest run" }, + "peerDependencies": { + "@coasys/ad4m": "*", + "@we/components": "workspace:*", + "@we/design-utils": "workspace:*", + "@we/models": "workspace:*", + "@we/module-shared": "workspace:*", + "@we/schema-shared": "workspace:*", + "solid-js": "^1.9.5" + }, + "devDependencies": { + "@coasys/ad4m": "0.11.0", + "@solidjs/testing-library": "^0.8.10", + "@types/node": "^24.10.0", + "@we/components": "workspace:*", + "@we/design-utils": "workspace:*", + "@we/models": "workspace:*", + "@we/module-shared": "workspace:*", + "@we/primitives": "workspace:*", + "@we/schema-shared": "workspace:*", + "jsdom": "^27.2.0", + "solid-js": "^1.9.5", + "vite-plugin-solid": "^2.11.10", + "vitest": "^4.0.15" + } +} diff --git a/packages/module-system/assistant/src/components/AssistantConfigPanel.tsx b/packages/module-system/assistant/src/components/AssistantConfigPanel.tsx new file mode 100644 index 00000000..e0c96f42 --- /dev/null +++ b/packages/module-system/assistant/src/components/AssistantConfigPanel.tsx @@ -0,0 +1,602 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import { createEffect, createSignal, For, Show } from 'solid-js'; + +import type { Assistant } from '../models'; +import { parseIdList, useAssistantStore } from '../store'; + +type Tab = 'assistants' | 'personalities' | 'skills' | 'mcp'; + +/** + * Right pane of the AI-assistant surface: manage the personal assistant configuration + * (assistants + their model, system prompt and granted personalities/skills/MCP servers) + * and the reusable libraries (personalities, skills, MCP servers). All data lives in the + * personal we-root perspective. + */ +export function AssistantConfigPanel() { + const [tab, setTab] = createSignal('assistants'); + + const tabs: { id: Tab; label: string; icon: string }[] = [ + { id: 'assistants', label: 'Assistants', icon: 'sparkle' }, + { id: 'personalities', label: 'Personalities', icon: 'mask-happy' }, + { id: 'skills', label: 'Skills', icon: 'lightning' }, + { id: 'mcp', label: 'MCP', icon: 'plugs' }, + ]; + + return ( + + {/* Tab bar */} + + + {(t) => ( + setTab(t.id)} + styles={{ 'flex-shrink': '0' }} + > + + + {t.label} + + + )} + + + + + + + + + + + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- Assistants + +function AssistantsSection() { + const store = useAssistantStore(); + const [selectedId, setSelectedId] = createSignal(null); + + const selected = () => store.assistants().find((a) => a.id === selectedId()) ?? store.activeAssistant(); + + // Editable buffers, re-seeded when the selected assistant changes. + const [name, setName] = createSignal(''); + const [modelId, setModelId] = createSignal(''); + const [systemPrompt, setSystemPrompt] = createSignal(''); + createEffect(() => { + const a = selected(); + setName(a?.name ?? ''); + setModelId(a?.modelId ?? ''); + setSystemPrompt(a?.systemPrompt ?? ''); + }); + + async function addAssistant() { + const id = await store.createAssistant({ name: 'New assistant' }); + if (id) setSelectedId(id); + } + + async function save() { + const a = selected(); + if (!a) return; + await store.updateAssistant(a.id, { name: name(), modelId: modelId(), systemPrompt: systemPrompt() }); + } + + return ( + + + + Assistants + + + + New + + + + {/* Assistant picker */} + 0} + fallback={} + > + + + + {(a) => ( + + + setName(e.detail as string)} + /> + + + + setModelId(e.currentTarget.value)} + style={inputStyle()} + /> + + {(m) => + + + + + setSystemPrompt(e.detail as string)} + /> + + + + + Save changes + + void store.deleteAssistant(a().id)}> + + + Delete + + + + + {/* Grants */} + + + + + )} + + + + ); +} + +function GrantGroup(props: { + title: string; + field: 'personalityIds' | 'skillIds' | 'mcpServerIds'; + assistant: Assistant; + items: { id: string; name: string }[]; +}) { + const store = useAssistantStore(); + // Read reactively from the live assistant record so toggles reflect immediately. + const grantedSet = () => + new Set(parseIdList(store.assistants().find((a) => a.id === props.assistant.id)?.[props.field])); + + return ( + + + {props.title} + + 0} + fallback={{`No ${props.title.toLowerCase()} defined`}} + > + + + {(item) => { + const on = () => grantedSet().has(item.id); + return ( + void store.toggleGrant(props.assistant.id, props.field, item.id)} + > + + + {item.name || 'Untitled'} + + + ); + }} + + + + + ); +} + +// --------------------------------------------------------------------------- Personalities + +function PersonalitySection() { + const store = useAssistantStore(); + const [name, setName] = createSignal(''); + const [body, setBody] = createSignal(''); + const [editingId, setEditingId] = createSignal(null); + + function reset() { + setName(''); + setBody(''); + setEditingId(null); + } + + async function submit() { + if (!name().trim() && !body().trim()) return; + const id = editingId(); + if (id) await store.updatePersonality(id, { name: name(), body: body() }); + else await store.createPersonality({ name: name(), body: body() }); + reset(); + } + + return ( + + + Personalities + + + Reusable guidance blocks that can be granted to an assistant. + + + 0} fallback={}> + + + {(p) => ( + { + setEditingId(p.id); + setName(p.name); + setBody(p.body); + }} + onDelete={() => void store.deletePersonality(p.id)} + /> + )} + + + + + + setName(e.detail as string)} + /> + setBody(e.detail as string)} + /> + + + + ); +} + +// --------------------------------------------------------------------------- Skills + +function SkillSection() { + const store = useAssistantStore(); + const [name, setName] = createSignal(''); + const [description, setDescription] = createSignal(''); + const [body, setBody] = createSignal(''); + const [editingId, setEditingId] = createSignal(null); + + function reset() { + setName(''); + setDescription(''); + setBody(''); + setEditingId(null); + } + + async function submit() { + if (!name().trim()) return; + const id = editingId(); + if (id) await store.updateSkill(id, { name: name(), description: description(), body: body() }); + else await store.createSkill({ name: name(), description: description(), body: body() }); + reset(); + } + + return ( + + + Skills + + + Named capabilities the AD4M backend can equip an assistant with. + + + 0} fallback={}> + + + {(s) => ( + { + setEditingId(s.id); + setName(s.name); + setDescription(s.description); + setBody(s.body); + }} + onDelete={() => void store.deleteSkill(s.id)} + /> + )} + + + + + + setName(e.detail as string)} + /> + setDescription(e.detail as string)} + /> + setBody(e.detail as string)} + /> + + + + ); +} + +// --------------------------------------------------------------------------- MCP servers + +function McpSection() { + const store = useAssistantStore(); + const [name, setName] = createSignal(''); + const [transport, setTransport] = createSignal('stdio'); + const [url, setUrl] = createSignal(''); + const [command, setCommand] = createSignal(''); + const [auth, setAuth] = createSignal(''); + const [editingId, setEditingId] = createSignal(null); + + function reset() { + setName(''); + setTransport('stdio'); + setUrl(''); + setCommand(''); + setAuth(''); + setEditingId(null); + } + + async function submit() { + if (!name().trim()) return; + const data = { name: name(), transport: transport(), url: url(), command: command(), auth: auth() }; + const id = editingId(); + if (id) await store.updateMcpServer(id, data); + else await store.createMcpServer(data); + reset(); + } + + const isStdio = () => transport() === 'stdio'; + + return ( + + + MCP servers + + + Model Context Protocol servers the backend connects to on an assistant's behalf. + + + 0} fallback={}> + + + {(m) => ( + { + setEditingId(m.id); + setName(m.name); + setTransport(m.transport || 'stdio'); + setUrl(m.url); + setCommand(m.command); + setAuth(m.auth); + }} + onDelete={() => void store.deleteMcpServer(m.id)} + /> + )} + + + + + + setName(e.detail as string)} + /> + + + + setUrl(e.detail as string)} + /> + } + > + setCommand(e.detail as string)} + /> + + setAuth(e.detail as string)} + /> + + + + ); +} + +// --------------------------------------------------------------------------- Shared UI bits + +function Field(props: { label: string; children: unknown }) { + return ( + + + {props.label} + + {props.children as never} + + ); +} + +function ListRow(props: { title: string; subtitle?: string; onEdit: () => void; onDelete: () => void }) { + return ( + + + + {props.title || 'Untitled'} + + + + {props.subtitle} + + + + + + + + + + + ); +} + +function EditorCard(props: { title: string; children: unknown }) { + return ( + + + {props.title} + + {props.children as never} + + ); +} + +function EditorActions(props: { editing: boolean; onSubmit: () => void; onCancel: () => void }) { + return ( + + + {props.editing ? 'Save' : 'Add'} + + + + Cancel + + + + ); +} + +function Empty(props: { text: string }) { + return ( + + {props.text} + + ); +} + +function inputStyle(): Record { + return { + 'font-size': '13px', + padding: '6px 10px', + 'border-radius': '6px', + border: `1px solid ${tokenVar('color', 'ui-200')}`, + background: tokenVar('color', 'neutral-0'), + color: tokenVar('color', 'neutral-800'), + width: '100%', + }; +} diff --git a/packages/module-system/assistant/src/components/AssistantMessage.tsx b/packages/module-system/assistant/src/components/AssistantMessage.tsx new file mode 100644 index 00000000..4d6a8248 --- /dev/null +++ b/packages/module-system/assistant/src/components/AssistantMessage.tsx @@ -0,0 +1,177 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import { createSignal, For, Show } from 'solid-js'; + +import type { Message } from '../models'; +import { parseToolCalls, type ToolCall } from '../store'; + +/** + * Renders a single assistant-thread message. Handles the four roles + * (user / assistant / tool / system), markdown content, a live streaming + * indicator, and collapsible tool-call invocation/result blocks. + */ +export function AssistantMessage(props: { message: Message }) { + const role = () => props.message.role || 'assistant'; + const isUser = () => role() === 'user'; + const isTool = () => role() === 'tool'; + const isSystem = () => role() === 'system'; + const isStreaming = () => props.message.status === 'streaming'; + const toolCalls = () => parseToolCalls(props.message.toolCalls); + const hasContent = () => !!props.message.content?.trim(); + + return ( + + + {props.message.content} + + + } + > + + {/* Role label for assistant/tool turns */} + + + + + {isTool() ? 'Tool' : 'Assistant'} + + + + streaming… + + + + + + {/* Streaming placeholder before any tokens have arrived */} + + + Thinking… + + + + {/* Message body */} + + }> + + + + + {/* Tool calls */} + 0}> + + {(call) => } + + + + + + The assistant reported an error. + + + + + ); +} + +/** A collapsible tool invocation with its arguments and (once available) result. */ +function ToolCallBlock(props: { call: ToolCall }) { + const [open, setOpen] = createSignal(false); + const status = () => props.call.status ?? (props.call.result !== undefined ? 'complete' : 'pending'); + const statusColor = () => + status() === 'error' ? 'danger-500' : status() === 'complete' ? 'success-500' : 'warning-500'; + + return ( + + setOpen((v) => !v)} + > + + + + {props.call.name || 'tool'} + + + {status()} + + + + + + Arguments + + + + + Result + + + + + + + ); +} + +function CodeBlock(props: { text: string }) { + return ( +
+ {props.text} +
+ ); +} + +function formatJson(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/packages/module-system/assistant/src/components/AssistantThreadList.tsx b/packages/module-system/assistant/src/components/AssistantThreadList.tsx new file mode 100644 index 00000000..5cff793f --- /dev/null +++ b/packages/module-system/assistant/src/components/AssistantThreadList.tsx @@ -0,0 +1,113 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import { For, Show } from 'solid-js'; + +import { useAssistantStore } from '../store'; + +/** + * Left pane of the AI-assistant surface: the thread list for the current neighbourhood, + * plus a new-chat action. Many threads per neighbourhood; selecting one drives the + * centre thread view. + */ +export function AssistantThreadList() { + const store = useAssistantStore(); + + async function newChat() { + await store.createThread(); + } + + return ( + + {/* Header */} + + + Chats + + + + + + + + + {/* Thread list */} + + 0} + fallback={ + + + No chats yet + + + + New chat + + + } + > + + {(thread) => { + const active = () => thread.id === store.activeThreadId(); + return ( + store.selectThread(thread.id)} + > + + + {thread.title || 'Untitled'} + + { + e.stopPropagation(); + void store.deleteThread(thread.id); + }} + > + + + + ); + }} + + + + + ); +} diff --git a/packages/module-system/assistant/src/components/AssistantThreadView.tsx b/packages/module-system/assistant/src/components/AssistantThreadView.tsx new file mode 100644 index 00000000..fcea62e8 --- /dev/null +++ b/packages/module-system/assistant/src/components/AssistantThreadView.tsx @@ -0,0 +1,154 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import { createEffect, createSignal, For, Show } from 'solid-js'; + +import { useAssistantStore } from '../store'; +import { AssistantMessage } from './AssistantMessage'; + +/** + * Centre pane of the AI-assistant surface: the active thread's message list (with live + * streaming + tool-call rendering) and the composer. Sending writes a user Message into + * the thread's perspective; the assistant's reply arrives from the AD4M backend via the + * store's live subscription. + */ +export function AssistantThreadView() { + const store = useAssistantStore(); + const [input, setInput] = createSignal(''); + let endRef: HTMLDivElement | undefined; + + // Auto-scroll to the latest message / streaming update. + createEffect(() => { + void store.messages().length; + void store.messages().find((m) => m.status === 'streaming')?.content; + // Optional call — scrollIntoView is unavailable in some environments (e.g. jsdom under test). + requestAnimationFrame(() => endRef?.scrollIntoView?.({ behavior: 'smooth' })); + }); + + function handleSend() { + const text = input().trim(); + if (!text || !store.activeThreadId()) return; + void store.sendMessage(text); + setInput(''); + } + + return ( + + + + No conversation selected + + + Create a new chat to talk to an assistant. + + + } + > + {(thread) => ( + + {/* Header */} + + + + {thread().title || 'Untitled'} + + + {store.activeAssistant()?.name ?? 'No assistant'} + + + + {/* Per-thread model override */} + + + + + + + {/* Messages */} + + 0} + fallback={ + + + Send a message to begin. + + + Replies are produced by the AD4M assistant backend. + + + } + > + {(msg) => } + +
+ + + {/* Composer */} + + setInput(e.detail as string)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + styles={{ 'overflow-y': 'auto' }} + /> + + + + + + )} + + ); +} + +function selectStyle(): Record { + return { + 'font-size': '12px', + padding: '4px 8px', + 'border-radius': '6px', + border: `1px solid ${tokenVar('color', 'ui-200')}`, + background: tokenVar('color', 'neutral-0'), + color: tokenVar('color', 'neutral-700'), + 'max-width': '220px', + cursor: 'pointer', + }; +} diff --git a/packages/module-system/assistant/src/index.ts b/packages/module-system/assistant/src/index.ts new file mode 100644 index 00000000..2091cf99 --- /dev/null +++ b/packages/module-system/assistant/src/index.ts @@ -0,0 +1,53 @@ +import { defineModule } from '@we/module-shared'; + +import { AssistantConfigPanel } from './components/AssistantConfigPanel'; +import { AssistantThreadList } from './components/AssistantThreadList'; +import { AssistantThreadView } from './components/AssistantThreadView'; +import { Assistant, McpServer, Message, Personality, Skill, Thread } from './models'; +import { createAssistantStore } from './store'; +import { assistantSlot } from './surface'; + +export { + AssistantContext, + parseIdList, + parseToolCalls, + type AssistantStore, + type ToolCall, + useAssistantStore, +} from './store'; +export * from './models'; + +/** + * The AI-assistant module: threads and messages rendered from the dataset, replies written into it + * by the AD4M backend. The UI never calls a model — streaming is an assistant message's `content` + * growing under a live subscription. + * + * First module to ship its own Solid components (declared via `frameworks`), and first to use + * `agentModels`: assistant configuration is personal (root dataset), while conversations live in + * whichever space they were started in — Thread/Message appear in both lists so personal + * conversations work before any space is opened. + * + * `backends: ['ad4m']` for the documented reason (decorated model classes; no manifest→SDNA + * compiler yet) plus one of its own: the store reads `deps.connection` for `/v1/models` discovery + * against the executor's HTTP surface, degrading to referenced-model ids without it. + */ +export const assistantModule = defineModule({ + id: 'assistant', + name: 'AI Assistant', + description: 'Chat with AI assistants whose replies are produced by the backend', + icon: 'sparkle', + backends: ['ad4m'], + frameworks: ['solid'], + capabilities: ['storage', 'network:localhost', 'slot:overlay'], + + components: { AssistantConfigPanel, AssistantThreadList, AssistantThreadView }, + + models: [Thread, Message], + agentModels: [Assistant, Personality, Skill, McpServer, Thread, Message], + + slots: [{ anchor: 'overlay', node: assistantSlot, order: 20 }], + + launcher: { icon: 'sparkle', label: 'AI Assistant', action: 'toggle', activeWhen: 'open' }, + + createStore: createAssistantStore, +}); diff --git a/packages/module-system/assistant/src/models/Assistant.ts b/packages/module-system/assistant/src/models/Assistant.ts new file mode 100644 index 00000000..a19b11f6 --- /dev/null +++ b/packages/module-system/assistant/src/models/Assistant.ts @@ -0,0 +1,42 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; +import { WeNode } from '@we/models'; + +/** + * An AI assistant configuration. Lives in the personal (we-root) perspective and + * is referenced by `Thread.assistantId` when a conversation is opened with it. + * + * `personalityIds`, `skillIds` and `mcpServerIds` are JSON-encoded `string[]`s of + * the ids of the granted {@link Personality} / {@link Skill} / {@link McpServer} + * records (all in the same perspective). JSON arrays — rather than AD4M HasMany + * link relations — keep the grant set a single atomic property that any backend + * can read/write in one write, mirroring how `AgentSettings.perspectiveOrder` + * stores an id list. Use the empty string or `"[]"` for "none". + */ +@Model({ name: 'Assistant' }) +export class Assistant extends WeNode { + @Flag({ through: 'we://flag', value: 'we://module/assistant/assistant' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + /** Model identifier the assistant runs on (e.g. an id from the backend's /v1/models). */ + @Property({ through: 'we://module/assistant/model_id' }) + modelId: string = ''; + + /** Optional base system prompt, prepended ahead of any granted personalities. */ + @Property({ through: 'we://module/assistant/system_prompt' }) + systemPrompt: string = ''; + + /** JSON-encoded string[] of granted Personality ids. */ + @Property({ through: 'we://module/assistant/personality_ids' }) + personalityIds: string = ''; + + /** JSON-encoded string[] of granted Skill ids. */ + @Property({ through: 'we://module/assistant/skill_ids' }) + skillIds: string = ''; + + /** JSON-encoded string[] of granted McpServer ids. */ + @Property({ through: 'we://module/assistant/mcp_server_ids' }) + mcpServerIds: string = ''; +} diff --git a/packages/module-system/assistant/src/models/McpServer.ts b/packages/module-system/assistant/src/models/McpServer.ts new file mode 100644 index 00000000..5019d4a9 --- /dev/null +++ b/packages/module-system/assistant/src/models/McpServer.ts @@ -0,0 +1,34 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; +import { WeNode } from '@we/models'; + +/** + * An MCP (Model Context Protocol) server an AI assistant can be granted access + * to. The AD4M backend is responsible for connecting to it; this record only + * stores the connection descriptor. + * + * `transport` is one of 'stdio' | 'sse' | 'http' | 'websocket'. + * `url` is used for network transports (sse/http/websocket); `command` for + * 'stdio'. `auth` holds an optional JSON-encoded auth descriptor (e.g. a bearer + * token or header map) — kept as an opaque string so the shape stays open. + * Lives in the personal (we-root) perspective. + */ +@Model({ name: 'McpServer' }) +export class McpServer extends WeNode { + @Flag({ through: 'we://flag', value: 'we://module/assistant/mcp_server' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + @Property({ through: 'we://module/assistant/transport' }) + transport: string = 'stdio'; + + @Property({ through: 'we://url' }) + url: string = ''; + + @Property({ through: 'we://module/assistant/command' }) + command: string = ''; + + @Property({ through: 'we://module/assistant/auth' }) + auth: string = ''; +} diff --git a/packages/module-system/assistant/src/models/Message.ts b/packages/module-system/assistant/src/models/Message.ts new file mode 100644 index 00000000..7b8377fb --- /dev/null +++ b/packages/module-system/assistant/src/models/Message.ts @@ -0,0 +1,45 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; +import { WeNode } from '@we/models'; + +/** + * A single message in an assistant {@link Thread}. Belongs to the same + * (neighbourhood) perspective as its thread. + * + * `threadId` is the canonical join back to the owning Thread and is what the UI + * queries on — every message MUST carry it. The Thread also links messages via a + * HasMany relation for ORM navigation, but `threadId` is the source of truth for + * "which thread is this in", so a backend only needs to set this one scalar. + * + * `role` is 'user' | 'assistant' | 'tool' | 'system'. + * `toolCalls` is an optional JSON-encoded array describing tool invocations/results + * (see the AssistantStore doc for the shape the UI renders). + * `ts` is an ISO-8601 timestamp (lexicographically sortable → chronological order). + * `status` is '' | 'streaming' | 'complete' | 'error'. While an assistant reply is + * being produced the backend sets 'streaming' and appends to `content`; the UI + * subscription re-renders on each update, giving a live token stream. The backend + * flips it to 'complete' when done. + */ +@Model({ name: 'Message' }) +export class Message extends WeNode { + @Flag({ through: 'we://flag', value: 'we://module/assistant/message' }) + flag: string = ''; + + @Property({ through: 'we://module/assistant/thread_id' }) + threadId: string = ''; + + @Property({ through: 'we://role' }) + role: string = ''; + + @Property({ through: 'we://content' }) + content: string = ''; + + /** Optional JSON-encoded array of tool invocations/results. */ + @Property({ through: 'we://module/assistant/tool_calls' }) + toolCalls: string = ''; + + @Property({ through: 'we://module/assistant/ts' }) + ts: string = ''; + + @Property({ through: 'we://status' }) + status: string = ''; +} diff --git a/packages/module-system/assistant/src/models/Personality.ts b/packages/module-system/assistant/src/models/Personality.ts new file mode 100644 index 00000000..befef60c --- /dev/null +++ b/packages/module-system/assistant/src/models/Personality.ts @@ -0,0 +1,19 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; +import { WeNode } from '@we/models'; + +/** + * A reusable personality an AI assistant can adopt — a named block of guidance + * text merged into the assistant's system prompt. Lives in the personal + * (we-root) perspective and can be granted to any assistant. + */ +@Model({ name: 'Personality' }) +export class Personality extends WeNode { + @Flag({ through: 'we://flag', value: 'we://module/assistant/personality' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + @Property({ through: 'we://module/assistant/body' }) + body: string = ''; +} diff --git a/packages/module-system/assistant/src/models/Skill.ts b/packages/module-system/assistant/src/models/Skill.ts new file mode 100644 index 00000000..0fda7676 --- /dev/null +++ b/packages/module-system/assistant/src/models/Skill.ts @@ -0,0 +1,22 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; +import { WeNode } from '@we/models'; + +/** + * A named capability an AI assistant can be granted. `body` holds the skill's + * instructions/definition (interpreted by the AD4M backend); `description` is a + * short human summary. Lives in the personal (we-root) perspective. + */ +@Model({ name: 'Skill' }) +export class Skill extends WeNode { + @Flag({ through: 'we://flag', value: 'we://module/assistant/skill' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + @Property({ through: 'we://description' }) + description: string = ''; + + @Property({ through: 'we://module/assistant/body' }) + body: string = ''; +} diff --git a/packages/module-system/assistant/src/models/Thread.ts b/packages/module-system/assistant/src/models/Thread.ts new file mode 100644 index 00000000..cc2f1ef2 --- /dev/null +++ b/packages/module-system/assistant/src/models/Thread.ts @@ -0,0 +1,41 @@ +import { Flag, HasMany, HasManyMethods, Model, Property } from '@coasys/ad4m'; +import { WeNode } from '@we/models'; + +import { Message } from './Message'; + +/** + * An assistant conversation. Belongs to a neighbourhood perspective — the space + * it was created in — so a neighbourhood can hold many threads. `assistantId` + * references an {@link Assistant} in the personal (we-root) perspective; + * `modelId` is an optional per-thread model override (falls back to the + * assistant's `modelId` when empty). + * + * Messages are queried by `Message.threadId` (the canonical join); the HasMany + * relation here exists for ORM navigation and parent-linked creation. + */ +@Model({ name: 'Thread' }) +export class Thread extends WeNode { + @Flag({ through: 'we://flag', value: 'we://module/assistant/thread' }) + flag: string = ''; + + @Property({ through: 'we://title' }) + title: string = ''; + + @Property({ through: 'we://module/assistant/assistant_id' }) + assistantId: string = ''; + + /** Optional per-thread model override; empty → use the assistant's modelId. */ + @Property({ through: 'we://module/assistant/model_id' }) + modelId: string = ''; + + @Property({ through: 'we://module/assistant/created_at' }) + createdAt: string = ''; + + @Property({ through: 'we://module/assistant/updated_at' }) + updatedAt: string = ''; + + @HasMany(() => Message, { through: 'we://module/assistant/message' }) + messages: Message[] = []; +} + +export interface Thread extends HasManyMethods<'messages'> {} diff --git a/packages/module-system/assistant/src/models/index.ts b/packages/module-system/assistant/src/models/index.ts new file mode 100644 index 00000000..4c1da3e3 --- /dev/null +++ b/packages/module-system/assistant/src/models/index.ts @@ -0,0 +1,6 @@ +export { Assistant } from './Assistant'; +export { McpServer } from './McpServer'; +export { Message } from './Message'; +export { Personality } from './Personality'; +export { Skill } from './Skill'; +export { Thread } from './Thread'; diff --git a/packages/module-system/assistant/src/store.ts b/packages/module-system/assistant/src/store.ts new file mode 100644 index 00000000..ccfe1ad3 --- /dev/null +++ b/packages/module-system/assistant/src/store.ts @@ -0,0 +1,712 @@ +/** + * AssistantStore — state + actions for the AD4M AI-assistant surface. + * + * This store is the front end for AI assistants whose replies are produced by the + * AD4M backend, NOT by any in-browser model call. The flow is: + * + * 1. The user writes a `Message` (role 'user') into the active thread's perspective. + * 2. The AD4M backend observes the perspective, runs the assistant, and writes back + * an assistant `Message` — creating it with `status: 'streaming'` and appending to + * its `content` (and `toolCalls`) as tokens arrive, then flipping `status` to + * 'complete'. Tool results may arrive as additional `role: 'tool'` messages. + * 3. This store subscribes to the thread's messages via the subject-class ORM + * (`Model.query(p).subscribe(cb)`), so the UI re-renders on every write — the token + * stream is simply the assistant message's `content` growing under an open + * subscription. Nothing here calls an LLM. + * + * Data lives in two perspectives: + * - Personal config (we-root): Assistant, Personality, Skill, McpServer. + * - Neighbourhood (current space, falling back to we-root): Thread, Message. + */ +import type { PerspectiveProxy } from '@coasys/ad4m'; +import type { ModuleStoreDeps } from '@we/module-shared'; +import { + Accessor, + createContext, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + useContext, +} from 'solid-js'; + +import { Assistant, McpServer, Message, Personality, Skill, Thread } from './models'; + +/** Minimal structural view of an AD4M `ModelQueryBuilder` — the three methods this store uses. */ +interface LiveQueryBuilder { + subscribe(cb: (rows: T[]) => void): Promise; + get(): Promise; + dispose(): void; +} + +/** A parsed tool call, as rendered by the thread view. Serialised into `Message.toolCalls` (JSON). */ +export interface ToolCall { + id?: string; + name: string; + /** Tool input arguments (any JSON value). */ + input?: unknown; + /** Tool result once the backend has run it (any JSON value). */ + result?: unknown; + /** 'pending' while running, 'complete' when a result is in, 'error' on failure. */ + status?: 'pending' | 'complete' | 'error'; +} + +// A type alias, not an interface — the module contract types `createStore`'s return as +// `Record`, and only structural object types are assignable to it. +export type AssistantStore = { + // --- Threads (current neighbourhood) --- + threads: Accessor; + activeThreadId: Accessor; + activeThread: Accessor; + selectThread: (id: string) => void; + createThread: (title?: string, assistantId?: string) => Promise; + deleteThread: (id: string) => Promise; + renameThread: (id: string, title: string) => Promise; + setThreadModel: (id: string, modelId: string) => Promise; + + // --- Messages (active thread) --- + messages: Accessor; + streamingMessageId: Accessor; + sendMessage: (text: string) => Promise; + + // --- Assistants (personal config) --- + assistants: Accessor; + activeAssistant: Accessor; + createAssistant: (data: { name: string; modelId?: string; systemPrompt?: string }) => Promise; + updateAssistant: ( + id: string, + updates: Partial<{ name: string; modelId: string; systemPrompt: string }>, + ) => Promise; + deleteAssistant: (id: string) => Promise; + /** Toggle a Personality / Skill / McpServer grant on an assistant (rewrites the JSON id list). */ + toggleGrant: ( + assistantId: string, + field: 'personalityIds' | 'skillIds' | 'mcpServerIds', + itemId: string, + ) => Promise; + assistantHasGrant: ( + assistant: Assistant, + field: 'personalityIds' | 'skillIds' | 'mcpServerIds', + itemId: string, + ) => boolean; + + // --- Personalities --- + personalities: Accessor; + createPersonality: (data: { name: string; body: string }) => Promise; + updatePersonality: (id: string, updates: Partial<{ name: string; body: string }>) => Promise; + deletePersonality: (id: string) => Promise; + + // --- Skills --- + skills: Accessor; + createSkill: (data: { name: string; description: string; body: string }) => Promise; + updateSkill: (id: string, updates: Partial<{ name: string; description: string; body: string }>) => Promise; + deleteSkill: (id: string) => Promise; + + // --- MCP servers --- + mcpServers: Accessor; + createMcpServer: (data: { + name: string; + transport: string; + url?: string; + command?: string; + auth?: string; + }) => Promise; + updateMcpServer: ( + id: string, + updates: Partial<{ name: string; transport: string; url: string; command: string; auth: string }>, + ) => Promise; + deleteMcpServer: (id: string) => Promise; + + // --- Models --- + models: Accessor; + refreshModels: () => Promise; + + // --- Surface visibility --- + // The assistant opened via `templateStore.openShellView` in its pre-module form; as a module its + // surface is an overlay slot gated on this, and the module rail's launcher toggles it. + open: Accessor; + toggle: () => void; + close: () => void; +}; + +/** Parse a JSON-encoded id list (used for Assistant grant fields). Tolerant of '' and bad JSON. */ +export function parseIdList(json: string | undefined): string[] { + if (!json) return []; + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; + } catch { + return []; + } +} + +/** Parse a Message's toolCalls JSON into a ToolCall[] for rendering. Never throws. */ +export function parseToolCalls(json: string | undefined): ToolCall[] { + if (!json) return []; + try { + const parsed = JSON.parse(json); + if (Array.isArray(parsed)) return parsed as ToolCall[]; + return []; + } catch { + return []; + } +} + +/** + * Exported so tests and standalone harnesses can render assistant components with a mock + * store injected directly (``), without the full + * AD4M provider chain. + */ +export const AssistantContext = createContext(); + +export function createAssistantStore(deps: ModuleStoreDeps): AssistantStore { + // The handles are opaque `DatasetHandle`s at the contract; this module declares + // `backends: ['ad4m']` (its models are decorated classes), so narrowing them to the proxy type + // here is the declared coupling, not a leak. + // Threads + messages live in the active neighbourhood, falling back to the personal + // root perspective so assistants work before any space is opened. + const threadPerspective = () => (deps.dataset?.() ?? deps.rootDataset?.() ?? null) as PerspectiveProxy | null; + // Assistant configuration is personal — always the root dataset. + const configPerspective = () => (deps.rootDataset?.() ?? null) as PerspectiveProxy | null; + + const [threads, setThreads] = createSignal([]); + const [activeThreadId, setActiveThreadId] = createSignal(null); + const [messages, setMessages] = createSignal([]); + const [assistants, setAssistants] = createSignal([]); + const [personalities, setPersonalities] = createSignal([]); + const [skills, setSkills] = createSignal([]); + const [mcpServers, setMcpServers] = createSignal([]); + const [models, setModels] = createSignal([]); + const [open, setOpen] = createSignal(false); + + /** + * Open a live subscription. Returns a disposer. Falls back to a single `.get()` if the + * executor rejects the live subscription (e.g. live queries unavailable), so the UI still + * populates once even without push updates. + */ + function liveQuery(make: () => LiveQueryBuilder, onRows: (rows: T[]) => void): () => void { + let disposed = false; + const builder = make(); + builder + .subscribe((rows) => { + if (!disposed) onRows(rows); + }) + .catch((err) => { + console.warn('[AssistantStore] live subscribe failed; falling back to one-shot query', err); + if (disposed) return; + make() + .get() + .then((rows) => { + if (!disposed) onRows(rows); + }) + .catch((e) => console.warn('[AssistantStore] fallback query failed', e)); + }); + return () => { + disposed = true; + try { + builder.dispose(); + } catch { + /* dispose is best-effort */ + } + }; + } + + // Threads for the active neighbourhood, newest activity first. + createEffect(() => { + const p = threadPerspective(); + if (!p) { + setThreads([]); + return; + } + const dispose = liveQuery( + () => Thread.query(p).order({ updatedAt: 'DESC' }) as unknown as LiveQueryBuilder, + setThreads, + ); + onCleanup(dispose); + }); + + // Messages for the active thread, chronological. + createEffect(() => { + const p = threadPerspective(); + const tid = activeThreadId(); + if (!p || !tid) { + setMessages([]); + return; + } + const dispose = liveQuery( + () => Message.query(p).where({ threadId: tid }).order({ ts: 'ASC' }) as unknown as LiveQueryBuilder, + setMessages, + ); + onCleanup(dispose); + }); + + // Personal config: assistants, personalities, skills, MCP servers. + createEffect(() => { + const p = configPerspective(); + if (!p) { + setAssistants([]); + setPersonalities([]); + setSkills([]); + setMcpServers([]); + return; + } + const disposers = [ + liveQuery( + () => Assistant.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, + setAssistants, + ), + liveQuery( + () => Personality.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, + setPersonalities, + ), + liveQuery(() => Skill.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, setSkills), + liveQuery( + () => McpServer.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, + setMcpServers, + ), + ]; + onCleanup(() => disposers.forEach((d) => d())); + }); + + // Keep an active thread selected: adopt the newest when none is chosen, and recover if the + // active thread is deleted out from under us. + createEffect(() => { + const list = threads(); + const active = activeThreadId(); + if (!active && list.length > 0) { + setActiveThreadId(list[0].id); + } else if (active && !list.some((t) => t.id === active)) { + setActiveThreadId(list[0]?.id ?? null); + } + }); + + const activeThread = createMemo(() => threads().find((t) => t.id === activeThreadId()) ?? null); + + const activeAssistant = createMemo(() => { + const thread = activeThread(); + const list = assistants(); + if (thread?.assistantId) { + const match = list.find((a) => a.id === thread.assistantId); + if (match) return match; + } + return list[0] ?? null; + }); + + const streamingMessageId = createMemo(() => messages().find((m) => m.status === 'streaming')?.id ?? null); + + // ---------------------------------------------------------------- Thread actions + + function selectThread(id: string) { + setActiveThreadId(id); + } + + async function createThread(title?: string, assistantId?: string): Promise { + const p = threadPerspective(); + if (!p) return null; + const now = new Date().toISOString(); + const aId = assistantId ?? activeAssistant()?.id ?? assistants()[0]?.id ?? ''; + try { + const thread = await Thread.create(p, { + title: title?.trim() || 'New chat', + assistantId: aId, + modelId: '', + createdAt: now, + updatedAt: now, + }); + setActiveThreadId(thread.id); + return thread.id; + } catch (err) { + console.error('[AssistantStore] createThread failed', err); + return null; + } + } + + async function deleteThread(id: string): Promise { + const p = threadPerspective(); + if (!p) return; + try { + const msgs = await Message.findAll(p, { where: { threadId: id } }); + for (const m of msgs) await m.delete().catch((e) => console.warn('[AssistantStore] delete message failed', e)); + const thread = threads().find((t) => t.id === id); + if (thread) await thread.delete(); + if (activeThreadId() === id) setActiveThreadId(null); + } catch (err) { + console.error('[AssistantStore] deleteThread failed', err); + } + } + + async function renameThread(id: string, title: string): Promise { + const thread = threads().find((t) => t.id === id); + if (!thread) return; + try { + thread.title = title.trim() || thread.title; + thread.updatedAt = new Date().toISOString(); + await thread.save(); + } catch (err) { + console.error('[AssistantStore] renameThread failed', err); + } + } + + async function setThreadModel(id: string, modelId: string): Promise { + const thread = threads().find((t) => t.id === id); + if (!thread) return; + try { + thread.modelId = modelId; + await thread.save(); + } catch (err) { + console.error('[AssistantStore] setThreadModel failed', err); + } + } + + // ---------------------------------------------------------------- Messaging + + /** + * Write a user message into the active thread's perspective. This is the ONLY write the UI + * makes on send — the assistant's reply comes from the AD4M backend, which observes the + * perspective and writes the response back (picked up by the messages subscription). + */ + async function sendMessage(text: string): Promise { + const p = threadPerspective(); + const tid = activeThreadId(); + const content = text.trim(); + if (!p || !tid || !content) return; + const now = new Date().toISOString(); + try { + await Message.create( + p, + { threadId: tid, role: 'user', content, ts: now, status: 'complete', toolCalls: '' }, + { parent: { model: Thread, id: tid } }, + ); + // Bump the thread's updatedAt so it sorts to the top and the backend has a clear "last active". + const thread = threads().find((t) => t.id === tid); + if (thread) { + thread.updatedAt = now; + await thread.save().catch((e) => console.warn('[AssistantStore] thread bump failed', e)); + } + } catch (err) { + console.error('[AssistantStore] sendMessage failed', err); + } + } + + // ---------------------------------------------------------------- Assistant CRUD + + async function createAssistant(data: { + name: string; + modelId?: string; + systemPrompt?: string; + }): Promise { + const p = configPerspective(); + if (!p) return null; + try { + const assistant = await Assistant.create(p, { + name: data.name.trim() || 'Assistant', + modelId: data.modelId ?? models()[0] ?? '', + systemPrompt: data.systemPrompt ?? '', + personalityIds: '[]', + skillIds: '[]', + mcpServerIds: '[]', + }); + return assistant.id; + } catch (err) { + console.error('[AssistantStore] createAssistant failed', err); + return null; + } + } + + async function updateAssistant( + id: string, + updates: Partial<{ name: string; modelId: string; systemPrompt: string }>, + ): Promise { + const assistant = assistants().find((a) => a.id === id); + if (!assistant) return; + try { + Object.assign(assistant, updates); + await assistant.save(); + } catch (err) { + console.error('[AssistantStore] updateAssistant failed', err); + } + } + + async function deleteAssistant(id: string): Promise { + const assistant = assistants().find((a) => a.id === id); + if (!assistant) return; + try { + await assistant.delete(); + } catch (err) { + console.error('[AssistantStore] deleteAssistant failed', err); + } + } + + function assistantHasGrant( + assistant: Assistant, + field: 'personalityIds' | 'skillIds' | 'mcpServerIds', + itemId: string, + ): boolean { + return parseIdList(assistant[field]).includes(itemId); + } + + async function toggleGrant( + assistantId: string, + field: 'personalityIds' | 'skillIds' | 'mcpServerIds', + itemId: string, + ): Promise { + const assistant = assistants().find((a) => a.id === assistantId); + if (!assistant) return; + const current = parseIdList(assistant[field]); + const next = current.includes(itemId) ? current.filter((x) => x !== itemId) : [...current, itemId]; + try { + assistant[field] = JSON.stringify(next); + await assistant.save(); + } catch (err) { + console.error('[AssistantStore] toggleGrant failed', err); + } + } + + // ---------------------------------------------------------------- Personality CRUD + + async function createPersonality(data: { name: string; body: string }): Promise { + const p = configPerspective(); + if (!p) return; + try { + await Personality.create(p, { name: data.name.trim() || 'Personality', body: data.body }); + } catch (err) { + console.error('[AssistantStore] createPersonality failed', err); + } + } + + async function updatePersonality(id: string, updates: Partial<{ name: string; body: string }>): Promise { + const item = personalities().find((x) => x.id === id); + if (!item) return; + try { + Object.assign(item, updates); + await item.save(); + } catch (err) { + console.error('[AssistantStore] updatePersonality failed', err); + } + } + + async function deletePersonality(id: string): Promise { + const item = personalities().find((x) => x.id === id); + if (!item) return; + try { + await item.delete(); + } catch (err) { + console.error('[AssistantStore] deletePersonality failed', err); + } + } + + // ---------------------------------------------------------------- Skill CRUD + + async function createSkill(data: { name: string; description: string; body: string }): Promise { + const p = configPerspective(); + if (!p) return; + try { + await Skill.create(p, { + name: data.name.trim() || 'Skill', + description: data.description, + body: data.body, + }); + } catch (err) { + console.error('[AssistantStore] createSkill failed', err); + } + } + + async function updateSkill( + id: string, + updates: Partial<{ name: string; description: string; body: string }>, + ): Promise { + const item = skills().find((x) => x.id === id); + if (!item) return; + try { + Object.assign(item, updates); + await item.save(); + } catch (err) { + console.error('[AssistantStore] updateSkill failed', err); + } + } + + async function deleteSkill(id: string): Promise { + const item = skills().find((x) => x.id === id); + if (!item) return; + try { + await item.delete(); + } catch (err) { + console.error('[AssistantStore] deleteSkill failed', err); + } + } + + // ---------------------------------------------------------------- MCP server CRUD + + async function createMcpServer(data: { + name: string; + transport: string; + url?: string; + command?: string; + auth?: string; + }): Promise { + const p = configPerspective(); + if (!p) return; + try { + await McpServer.create(p, { + name: data.name.trim() || 'MCP server', + transport: data.transport || 'stdio', + url: data.url ?? '', + command: data.command ?? '', + auth: data.auth ?? '', + }); + } catch (err) { + console.error('[AssistantStore] createMcpServer failed', err); + } + } + + async function updateMcpServer( + id: string, + updates: Partial<{ name: string; transport: string; url: string; command: string; auth: string }>, + ): Promise { + const item = mcpServers().find((x) => x.id === id); + if (!item) return; + try { + Object.assign(item, updates); + await item.save(); + } catch (err) { + console.error('[AssistantStore] updateMcpServer failed', err); + } + } + + async function deleteMcpServer(id: string): Promise { + const item = mcpServers().find((x) => x.id === id); + if (!item) return; + try { + await item.delete(); + } catch (err) { + console.error('[AssistantStore] deleteMcpServer failed', err); + } + } + + // ---------------------------------------------------------------- Models + + /** + * Discover model ids from the AD4M backend's OpenAI-compatible `/v1/models` endpoint. + * Best-effort: on web the executor may not be directly reachable, so on any failure we fall + * back to the union of model ids already referenced by assistants. The UI additionally lets a + * user type a model id, so an empty list never blocks configuration. + */ + async function refreshModels(): Promise { + const conn = deps.connection?.() ?? null; + const port = conn?.port; + const token = conn?.token; + if (port) { + try { + const res = await fetch(`http://localhost:${port}/v1/models`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (res.ok) { + const json = (await res.json()) as { + data?: Array<{ id?: string }>; + models?: Array<{ id?: string; name?: string }>; + }; + const ids = Array.isArray(json?.data) + ? json.data.map((m) => m.id).filter((x): x is string => !!x) + : Array.isArray(json?.models) + ? json.models.map((m) => m.id ?? m.name).filter((x): x is string => !!x) + : []; + if (ids.length) { + setModels(ids); + return; + } + } + } catch (err) { + console.warn('[AssistantStore] /v1/models unavailable; using referenced models', err); + } + } + const used = Array.from( + new Set( + assistants() + .map((a) => a.modelId) + .filter((x): x is string => !!x), + ), + ); + setModels(used); + } + + let modelFallbackTried = false; + onMount(() => { + void refreshModels(); + }); + // Once assistants load, retry discovery a single time if we still have no models — handles + // the executor port not being ready at mount. Guarded so a persistently empty /v1/models + // (which re-sets an empty array each call) can't spin this into a refetch loop. + createEffect(() => { + if (!modelFallbackTried && models().length === 0 && assistants().length > 0) { + modelFallbackTried = true; + void refreshModels(); + } + }); + + const store: AssistantStore = { + threads, + activeThreadId, + activeThread, + selectThread, + createThread, + deleteThread, + renameThread, + setThreadModel, + + messages, + streamingMessageId, + sendMessage, + + assistants, + activeAssistant, + createAssistant, + updateAssistant, + deleteAssistant, + toggleGrant, + assistantHasGrant, + + personalities, + createPersonality, + updatePersonality, + deletePersonality, + + skills, + createSkill, + updateSkill, + deleteSkill, + + mcpServers, + createMcpServer, + updateMcpServer, + deleteMcpServer, + + models, + refreshModels, + + open, + toggle: () => setOpen((v) => !v), + close: () => setOpen(false), + }; + + instance = store; + return store; +} + +/** + * Module-scoped instance, set by `createAssistantStore` when the registry activates the module. + * + * Bundled modules are single-instance, so components reach their store through this rather than a + * context that would need a provider mounted somewhere in the host's tree. The context still exists + * (above) and takes precedence when present — that is how tests and harnesses inject a mock without + * activating the module. Hot-reload caveat: a stale instance survives an HMR of this file until the + * module re-registers, which the registry's idempotent re-registration handles on the next boot. + */ +let instance: AssistantStore | null = null; + +export function useAssistantStore(): AssistantStore { + const ctx = useContext(AssistantContext); + if (ctx) return ctx; + if (!instance) throw new Error('assistant module not activated — is it in the seed and registered?'); + return instance; +} diff --git a/packages/module-system/assistant/src/surface.ts b/packages/module-system/assistant/src/surface.ts new file mode 100644 index 00000000..c79acd0e --- /dev/null +++ b/packages/module-system/assistant/src/surface.ts @@ -0,0 +1,75 @@ +/** + * AI Assistant — the module's surface, mounted as an overlay slot. + * + * Composed declaratively from three registered components (all backed by the module store): + * - AssistantThreadList — threads in the current neighbourhood + create-thread + * - AssistantThreadView — active thread: messages, tool calls, live stream, composer + * - AssistantConfigPanel — assistants, model selector, personalities, skills, MCP servers + * + * Opened from the module rail (`launcher` → `modules.assistant.toggle`); in its pre-module form + * this was a shell view opened via `templateStore.openShellView`. Assistant replies are written + * into the perspective by the AD4M backend and surface here through the store's live + * subscriptions — this UI never calls a model itself. + */ +import type { SchemaNode } from '@we/schema-shared'; + +const surface: SchemaNode = { + type: 'Column', + props: { width: '100%', height: '100%', bg: 'neutral-0' }, + children: [ + // Header + { + type: 'Row', + props: { + ay: 'center', + gap: '200', + px: '400', + py: '300', + borderBottom: '1px solid neutral-200', + styles: { 'flex-shrink': '0' }, + }, + children: [ + { type: 'we-icon', props: { name: 'sparkle', size: 'md' } }, + { type: 'we-text', props: { variant: 'heading-sm', fontWeight: '600' }, children: ['AI Assistant'] }, + { type: 'Row', props: { flex: '1' } }, + // The shell view had the host's close chrome; as an overlay the surface carries its own. + { + type: 'we-button', + props: { variant: 'ghost', size: 'sm', onClick: { $action: 'modules.assistant.close' } }, + children: [{ type: 'we-icon', props: { name: 'x' } }], + }, + ], + }, + + // Body: thread list | thread view | config panel + { + type: 'Row', + props: { flex: '1', width: '100%', styles: { 'min-height': '0' } }, + children: [{ type: 'AssistantThreadList' }, { type: 'AssistantThreadView' }, { type: 'AssistantConfigPanel' }], + }, + ], +}; + +/** + * The slot node: full content-viewport overlay, visible while the store says so. The module + * registry additionally gates on `Space.enabledModules`, composed outside this node. + */ +export const assistantSlot: SchemaNode = { + type: '$if', + props: { + condition: { $store: 'modules.assistant.open' }, + then: { + type: 'Column', + props: { + position: 'fixed', + top: '0', + left: 'var(--we-sidebar-width, 80px)', + right: '0', + height: '100vh', + zIndex: 25, + bg: 'neutral-0', + }, + children: [surface], + }, + }, +}; diff --git a/packages/module-system/assistant/tests/AssistantConfigPanel.test.tsx b/packages/module-system/assistant/tests/AssistantConfigPanel.test.tsx new file mode 100644 index 00000000..4d52086a --- /dev/null +++ b/packages/module-system/assistant/tests/AssistantConfigPanel.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@solidjs/testing-library'; +import type { JSX } from 'solid-js'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AssistantConfigPanel } from '../src/components/AssistantConfigPanel'; +import { AssistantContext } from '../src/store'; +import { makeMockStore } from './mockStore'; + +afterEach(cleanup); + +function renderPanel(store = makeMockStore()) { + return render(() => ( + {() as JSX.Element} + )); +} + +describe('AssistantConfigPanel', () => { + it('renders the four config tabs', () => { + renderPanel(); + for (const id of ['assistants', 'personalities', 'skills', 'mcp']) { + expect(screen.getByTestId(`config-tab-${id}`)).toBeTruthy(); + } + }); + + it('shows the assistant editor with model + grant groups on the default tab', () => { + const { container } = renderPanel(); + // Assistant picker lists both assistants. + const options = Array.from(container.querySelectorAll('select option')).map((o) => o.textContent); + expect(options).toContain('Research Assistant'); + expect(options).toContain('Coding Assistant'); + // All three grant groups render, each listing its grantable items by name. + expect(screen.getByText('MCP servers')).toBeTruthy(); // unique heading (tab label is "MCP") + expect(screen.getByText('Friendly')).toBeTruthy(); // personality grant row + expect(screen.getByText('web-search')).toBeTruthy(); // skill grant row + expect(screen.getByText('filesystem')).toBeTruthy(); // mcp server grant row + }); + + it('toggles a personality grant on the active assistant', () => { + const store = makeMockStore(); + renderPanel(store); + // The Friendly personality appears as a grantable row in the assistant editor. + (screen.getByText('Friendly') as HTMLElement).click(); + expect(store.toggleGrant).toHaveBeenCalledWith('a1', 'personalityIds', 'p1'); + }); + + it('switches to the Personalities tab and lists personalities', () => { + renderPanel(); + (screen.getByTestId('config-tab-personalities') as HTMLElement).click(); + expect(screen.getByText('Terse')).toBeTruthy(); + // Body preview text from the fixture. + expect(screen.getByText('Minimal words. No filler.')).toBeTruthy(); + }); + + it('switches to the Skills tab and lists skills', () => { + renderPanel(); + (screen.getByTestId('config-tab-skills') as HTMLElement).click(); + expect(screen.getByText('web-search')).toBeTruthy(); + expect(screen.getByText('calculator')).toBeTruthy(); + }); + + it('switches to the MCP tab and lists servers with a transport control', () => { + const { container } = renderPanel(); + (screen.getByTestId('config-tab-mcp') as HTMLElement).click(); + expect(screen.getByText('filesystem')).toBeTruthy(); + // Transport is present in the header. + expect(container.querySelector('select')).toBeTruthy(); + }); + + it('lists the discovered models in the per-thread override selector', () => { + const { container } = renderView(); + const options = Array.from(container.querySelectorAll('select option')).map((o) => o.textContent); + expect(options.some((t) => t?.includes('llama-3.1-8b'))).toBe(true); + expect(options.some((t) => t?.includes('qwen2.5-coder'))).toBe(true); + }); + + it('writes a user message when the composer is submitted', () => { + const store = makeMockStore(); + renderView(store); + const input = screen.getByTestId('composer-input'); + input.dispatchEvent(new CustomEvent('input', { detail: 'What is the forecast?', bubbles: true })); + (screen.getByTestId('composer-send') as HTMLElement).click(); + expect(store.sendMessage).toHaveBeenCalledWith('What is the forecast?'); + }); + + it('shows a placeholder when no thread is selected', () => { + const store = makeMockStore({ activeThread: () => null, activeThreadId: () => null }); + renderView(store); + expect(screen.getByTestId('thread-view-empty')).toBeTruthy(); + expect(screen.getByText('No conversation selected')).toBeTruthy(); + }); + + it('renders a streaming assistant message with a live indicator', () => { + const streaming = [ + sampleMessages[0], + { ...sampleMessages[3], id: 'sm', content: 'Working on it', status: 'streaming' }, + ]; + const store = makeMockStore({ + messages: () => streaming as typeof sampleMessages, + streamingMessageId: () => 'sm', + }); + renderView(store); + expect(screen.getByText('streaming…')).toBeTruthy(); + }); +}); diff --git a/packages/module-system/assistant/tests/mockStore.ts b/packages/module-system/assistant/tests/mockStore.ts new file mode 100644 index 00000000..a314e1bd --- /dev/null +++ b/packages/module-system/assistant/tests/mockStore.ts @@ -0,0 +1,175 @@ +/** + * Shared mock AssistantStore + representative fixtures for the assistant-component tests + * and the screenshot harness. Plain objects cast to the model types — the components only + * read fields, so no real AD4M perspective/instances are needed. + */ +import { vi } from 'vitest'; + +import type { Assistant, McpServer, Message, Personality, Skill, Thread } from '../src/models'; +import type { AssistantStore } from '../src/store'; +import { parseIdList } from '../src/store'; + +function model(o: Record): T { + return o as unknown as T; +} + +export const sampleThreads: Thread[] = [ + model({ + id: 't1', + title: 'Weather in Melbourne', + assistantId: 'a1', + modelId: '', + createdAt: '2026-07-28T01:00:00.000Z', + updatedAt: '2026-07-28T02:00:00.000Z', + }), + model({ + id: 't2', + title: 'Refactor ideas', + assistantId: 'a2', + modelId: 'qwen2.5-coder', + createdAt: '2026-07-27T01:00:00.000Z', + updatedAt: '2026-07-27T05:00:00.000Z', + }), +]; + +export const toolCallsJson = JSON.stringify([ + { + id: 'call_1', + name: 'get_weather', + input: { city: 'Melbourne', units: 'metric' }, + result: { tempC: 14, sky: 'cloudy', wind: '12 km/h' }, + status: 'complete', + }, +]); + +export const sampleMessages: Message[] = [ + model({ + id: 'm1', + threadId: 't1', + role: 'user', + content: 'What is the weather in Melbourne right now?', + toolCalls: '', + ts: '2026-07-28T02:00:01.000Z', + status: 'complete', + }), + model({ + id: 'm2', + threadId: 't1', + role: 'assistant', + content: 'Let me check the current conditions for you.', + toolCalls: toolCallsJson, + ts: '2026-07-28T02:00:02.000Z', + status: 'complete', + }), + model({ + id: 'm3', + threadId: 't1', + role: 'tool', + content: '{\n "tempC": 14,\n "sky": "cloudy",\n "wind": "12 km/h"\n}', + toolCalls: '', + ts: '2026-07-28T02:00:03.000Z', + status: 'complete', + }), + model({ + id: 'm4', + threadId: 't1', + role: 'assistant', + content: 'It is currently **14°C and cloudy** in Melbourne, with wind around 12 km/h.', + toolCalls: '', + ts: '2026-07-28T02:00:04.000Z', + status: 'complete', + }), +]; + +export const sampleAssistants: Assistant[] = [ + model({ + id: 'a1', + name: 'Research Assistant', + modelId: 'llama-3.1-8b', + systemPrompt: 'Answer concisely and cite sources.', + personalityIds: JSON.stringify(['p1']), + skillIds: JSON.stringify(['s1']), + mcpServerIds: JSON.stringify(['mcp1']), + }), + model({ + id: 'a2', + name: 'Coding Assistant', + modelId: 'qwen2.5-coder', + systemPrompt: '', + personalityIds: '[]', + skillIds: '[]', + mcpServerIds: '[]', + }), +]; + +export const samplePersonalities: Personality[] = [ + model({ id: 'p1', name: 'Friendly', body: 'Warm, encouraging, plain language.' }), + model({ id: 'p2', name: 'Terse', body: 'Minimal words. No filler.' }), +]; + +export const sampleSkills: Skill[] = [ + model({ id: 's1', name: 'web-search', description: 'Search the web for current info', body: '...' }), + model({ id: 's2', name: 'calculator', description: 'Evaluate arithmetic', body: '...' }), +]; + +export const sampleMcpServers: McpServer[] = [ + model({ + id: 'mcp1', + name: 'filesystem', + transport: 'stdio', + url: '', + command: 'npx -y @modelcontextprotocol/server-filesystem', + auth: '', + }), +]; + +export const sampleModels = ['llama-3.1-8b', 'qwen2.5-coder', 'gpt-oss-20b']; + +/** Build a mock AssistantStore. All accessors return fixtures; all actions are spies. */ +export function makeMockStore(overrides: Partial = {}): AssistantStore { + const base: AssistantStore = { + threads: () => sampleThreads, + activeThreadId: () => 't1', + activeThread: () => sampleThreads[0], + selectThread: vi.fn(), + createThread: vi.fn(async () => 't-new'), + deleteThread: vi.fn(async () => {}), + renameThread: vi.fn(async () => {}), + setThreadModel: vi.fn(async () => {}), + + messages: () => sampleMessages, + streamingMessageId: () => null, + sendMessage: vi.fn(async () => {}), + + assistants: () => sampleAssistants, + activeAssistant: () => sampleAssistants[0], + createAssistant: vi.fn(async () => 'a-new'), + updateAssistant: vi.fn(async () => {}), + deleteAssistant: vi.fn(async () => {}), + toggleGrant: vi.fn(async () => {}), + assistantHasGrant: (assistant, field, itemId) => parseIdList(assistant[field]).includes(itemId), + + personalities: () => samplePersonalities, + createPersonality: vi.fn(async () => {}), + updatePersonality: vi.fn(async () => {}), + deletePersonality: vi.fn(async () => {}), + + skills: () => sampleSkills, + createSkill: vi.fn(async () => {}), + updateSkill: vi.fn(async () => {}), + deleteSkill: vi.fn(async () => {}), + + mcpServers: () => sampleMcpServers, + createMcpServer: vi.fn(async () => {}), + updateMcpServer: vi.fn(async () => {}), + deleteMcpServer: vi.fn(async () => {}), + + models: () => sampleModels, + refreshModels: vi.fn(async () => {}), + + open: () => true, + toggle: vi.fn(), + close: vi.fn(), + }; + return { ...base, ...overrides }; +} diff --git a/packages/module-system/assistant/tsconfig.json b/packages/module-system/assistant/tsconfig.json new file mode 100644 index 00000000..8e434595 --- /dev/null +++ b/packages/module-system/assistant/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": [ + "@we/primitives/solid", + "node" + ], + "experimentalDecorators": true + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/module-system/assistant/vitest.config.ts b/packages/module-system/assistant/vitest.config.ts new file mode 100644 index 00000000..7e57a700 --- /dev/null +++ b/packages/module-system/assistant/vitest.config.ts @@ -0,0 +1,35 @@ +import solidPlugin from 'vite-plugin-solid'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Two projects so the DOM/Solid setup needed to render components (browser resolve conditions, + // jsdom, the solid-js transform) is scoped to the component tests and never touches the plain + // logic tests. Ported from the pre-module layout, where the same split lived in app-framework's + // vitest config for the same reason. + projects: [ + { + test: { + name: 'logic', + globals: true, + include: ['tests/**/*.test.ts'], + }, + }, + { + plugins: [solidPlugin()], + resolve: { + conditions: ['development', 'browser'], + // A single solid-js instance across the test-transformed source and prebuilt dists — + // otherwise Solid event delegation and reactive context silently break. + dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'], + }, + test: { + name: 'components', + globals: true, + environment: 'jsdom', + include: ['tests/**/*.test.tsx'], + }, + }, + ], + }, +}); diff --git a/packages/module-system/shared/src/module.ts b/packages/module-system/shared/src/module.ts index e9d119aa..f08cf2bf 100644 --- a/packages/module-system/shared/src/module.ts +++ b/packages/module-system/shared/src/module.ts @@ -154,6 +154,20 @@ export interface ModuleDefinition { */ models?: unknown[]; + /** + * Entity types installed into the **agent's root dataset** rather than each space — personal + * configuration, not community content. + * + * A separate list because the two have different lifetimes and different homes: space models + * install on every switch into a space (a module can be enabled after a space exists), while + * agent models install once at boot into the dataset that holds the user's own settings. An + * entity that is both personal and shareable (the assistant's Thread/Message: personal + * conversations *and* neighbourhood ones) appears in both lists. + * + * Same predicate rule, same `unknown[]` reasoning as {@link ModuleDefinition.models}. + */ + agentModels?: unknown[]; + /** * A whole application embedded in an iframe, rather than components and fragments. * @@ -227,9 +241,26 @@ export interface ModuleStoreDeps { */ datasetUri?: () => string | null; + /** + * The agent's root dataset — where a module's `agentModels` live and where personal + * configuration reads and writes go. `null` before login. Distinct from {@link dataset}, which + * follows navigation; this one is the same for the whole session. + */ + rootDataset?: () => DatasetHandle | null; + /** This agent's id in the host's identity scheme (a DID on AD4M). `null` before login. */ selfId?: () => string | null; + /** + * Raw connection details for the host's backend runtime, for a module that talks to it over + * HTTP directly rather than through the data ports — model discovery, health checks. + * + * Backend-specific by nature, which is why it is optional and why a module reading it should + * declare the backend in `backends`: on a host without a reachable runtime this returns `null` + * and the module degrades, exactly like presence. + */ + connection?: () => { url?: string; port?: number; token?: string } | null; + /** * Peer-to-peer transport for modules that coordinate between agents rather than store data. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d5efbaa..5be1a767 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,6 +358,9 @@ importers: '@we/models': specifier: workspace:* version: link:../models + '@we/module-assistant': + specifier: workspace:* + version: link:../module-system/assistant '@we/module-call': specifier: workspace:* version: link:../module-system/call @@ -863,6 +866,48 @@ importers: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) + packages/module-system/assistant: + devDependencies: + '@coasys/ad4m': + specifier: 0.13.0-test-9 + version: 0.13.0-test-9 + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.14))(solid-js@1.9.14) + '@types/node': + specifier: ^24.10.0 + version: 24.13.2 + '@we/components': + specifier: workspace:* + version: link:../../design-system/4-components + '@we/design-utils': + specifier: workspace:* + version: link:../../design-system/utils + '@we/models': + specifier: workspace:* + version: link:../../models + '@we/module-shared': + specifier: workspace:* + version: link:../shared + '@we/primitives': + specifier: workspace:* + version: link:../../design-system/3-primitives + '@we/schema-shared': + specifier: workspace:* + version: link:../../schema-system/shared + jsdom: + specifier: ^27.2.0 + version: 27.4.0 + solid-js: + specifier: ^1.9.5 + version: 1.9.14 + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.12(solid-js@1.9.14)(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + vitest: + specifier: ^4.0.15 + version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/module-system/call: devDependencies: '@we/backend-shared': diff --git a/we-seed.json b/we-seed.json index f9dd39b7..bf2eab52 100644 --- a/we-seed.json +++ b/we-seed.json @@ -11,7 +11,8 @@ "modules": [ "globe", "notes", - "call" + "call", + "assistant" ], "ad4m": { "dataPath": "~/.we-native-app",