move pi-mono into companion-cloud as apps/companion-os

- Copy all pi-mono source into apps/companion-os/
- Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases
- Update deploy-staging.yml to build pi from source (bun compile) before Docker build
- Add apps/companion-os/** to path triggers
- No more cross-repo dispatch needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Harivansh Rathi 2026-03-07 09:22:50 -08:00
commit 0250f72976
579 changed files with 206942 additions and 0 deletions

3
packages/web-ui/example/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
.DS_Store

View file

@ -0,0 +1,61 @@
# Pi Web UI - Example
This is a minimal example showing how to use `@mariozechner/pi-web-ui` in a web application.
## Setup
```bash
npm install
```
## Development
```bash
npm run dev
```
Open [http://localhost:5173](http://localhost:5173) in your browser.
## What's Included
This example demonstrates:
- **ChatPanel** - The main chat interface component
- **System Prompt** - Custom configuration for the AI assistant
- **Tools** - JavaScript REPL and artifacts tool
## Configuration
### API Keys
The example uses **Direct Mode** by default, which means it calls AI provider APIs directly from the browser.
To use the chat:
1. Click the settings icon (⚙️) in the chat interface
2. Click "Manage API Keys"
3. Add your API key for your preferred provider:
- **Anthropic**: Get a key from [console.anthropic.com](https://console.anthropic.com/)
- **OpenAI**: Get a key from [platform.openai.com](https://platform.openai.com/)
- **Google**: Get a key from [makersuite.google.com](https://makersuite.google.com/)
API keys are stored in your browser's localStorage and never sent to any server except the AI provider's API.
## Project Structure
```
example/
├── src/
│ ├── main.ts # Main application entry point
│ └── app.css # Tailwind CSS configuration
├── index.html # HTML entry point
├── package.json # Dependencies
├── vite.config.ts # Vite configuration
└── tsconfig.json # TypeScript configuration
```
## Learn More
- [Pi Web UI Documentation](../README.md)
- [Pi AI Documentation](../../ai/README.md)
- [Mini Lit Documentation](https://github.com/badlogic/mini-lit)

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pi Web UI - Example</title>
<meta name="description" content="Example usage of @mariozechner/pi-web-ui - Reusable AI chat interface" />
</head>
<body class="bg-background">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,25 @@
{
"name": "pi-web-ui-example",
"version": "1.44.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "tsgo --noEmit",
"clean": "shx rm -rf dist"
},
"dependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"@mariozechner/pi-ai": "file:../../ai",
"@mariozechner/pi-web-ui": "file:../",
"@tailwindcss/vite": "^4.1.17",
"lit": "^3.3.1",
"lucide": "^0.544.0"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^7.1.6"
}
}

View file

@ -0,0 +1 @@
@import "../../dist/app.css";

View file

@ -0,0 +1,104 @@
import { Alert } from "@mariozechner/mini-lit/dist/Alert.js";
import type { Message } from "@mariozechner/pi-ai";
import type { AgentMessage, MessageRenderer } from "@mariozechner/pi-web-ui";
import {
defaultConvertToLlm,
registerMessageRenderer,
} from "@mariozechner/pi-web-ui";
import { html } from "lit";
// ============================================================================
// 1. EXTEND AppMessage TYPE VIA DECLARATION MERGING
// ============================================================================
// Define custom message types
export interface SystemNotificationMessage {
role: "system-notification";
message: string;
variant: "default" | "destructive";
timestamp: string;
}
// Extend CustomAgentMessages interface via declaration merging
// This must target pi-agent-core where CustomAgentMessages is defined
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
"system-notification": SystemNotificationMessage;
}
}
// ============================================================================
// 2. CREATE CUSTOM RENDERER (TYPED TO SystemNotificationMessage)
// ============================================================================
const systemNotificationRenderer: MessageRenderer<SystemNotificationMessage> = {
render: (notification) => {
// notification is fully typed as SystemNotificationMessage!
return html`
<div class="px-4">
${Alert({
variant: notification.variant,
children: html`
<div class="flex flex-col gap-1">
<div>${notification.message}</div>
<div class="text-xs opacity-70">
${new Date(notification.timestamp).toLocaleTimeString()}
</div>
</div>
`,
})}
</div>
`;
},
};
// ============================================================================
// 3. REGISTER RENDERER
// ============================================================================
export function registerCustomMessageRenderers() {
registerMessageRenderer("system-notification", systemNotificationRenderer);
}
// ============================================================================
// 4. HELPER TO CREATE CUSTOM MESSAGES
// ============================================================================
export function createSystemNotification(
message: string,
variant: "default" | "destructive" = "default",
): SystemNotificationMessage {
return {
role: "system-notification",
message,
variant,
timestamp: new Date().toISOString(),
};
}
// ============================================================================
// 5. CUSTOM MESSAGE TRANSFORMER
// ============================================================================
/**
* Custom message transformer that extends defaultConvertToLlm.
* Handles system-notification messages by converting them to user messages.
*/
export function customConvertToLlm(messages: AgentMessage[]): Message[] {
// First, handle our custom system-notification type
const processed = messages.map((m): AgentMessage => {
if (m.role === "system-notification") {
const notification = m as SystemNotificationMessage;
// Convert to user message with <system> tags
return {
role: "user",
content: `<system>${notification.message}</system>`,
timestamp: Date.now(),
};
}
return m;
});
// Then use defaultConvertToLlm for standard handling
return defaultConvertToLlm(processed);
}

View file

@ -0,0 +1,473 @@
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai";
import {
type AgentState,
ApiKeyPromptDialog,
AppStorage,
ChatPanel,
CustomProvidersStore,
createJavaScriptReplTool,
IndexedDBStorageBackend,
// PersistentStorageDialog, // TODO: Fix - currently broken
ProviderKeysStore,
ProvidersModelsTab,
ProxyTab,
SessionListDialog,
SessionsStore,
SettingsDialog,
SettingsStore,
setAppStorage,
} from "@mariozechner/pi-web-ui";
import { html, render } from "lit";
import { Bell, History, Plus, Settings } from "lucide";
import "./app.css";
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import {
createSystemNotification,
customConvertToLlm,
registerCustomMessageRenderers,
} from "./custom-messages.js";
// Register custom message renderers
registerCustomMessageRenderers();
// Create stores
const settings = new SettingsStore();
const providerKeys = new ProviderKeysStore();
const sessions = new SessionsStore();
const customProviders = new CustomProvidersStore();
// Gather configs
const configs = [
settings.getConfig(),
SessionsStore.getMetadataConfig(),
providerKeys.getConfig(),
customProviders.getConfig(),
sessions.getConfig(),
];
// Create backend
const backend = new IndexedDBStorageBackend({
dbName: "pi-web-ui-example",
version: 2, // Incremented for custom-providers store
stores: configs,
});
// Wire backend to stores
settings.setBackend(backend);
providerKeys.setBackend(backend);
customProviders.setBackend(backend);
sessions.setBackend(backend);
// Create and set app storage
const storage = new AppStorage(
settings,
providerKeys,
sessions,
customProviders,
backend,
);
setAppStorage(storage);
let currentSessionId: string | undefined;
let currentTitle = "";
let isEditingTitle = false;
let agent: Agent;
let chatPanel: ChatPanel;
let agentUnsubscribe: (() => void) | undefined;
const generateTitle = (messages: AgentMessage[]): string => {
const firstUserMsg = messages.find(
(m) => m.role === "user" || m.role === "user-with-attachments",
);
if (
!firstUserMsg ||
(firstUserMsg.role !== "user" &&
firstUserMsg.role !== "user-with-attachments")
)
return "";
let text = "";
const content = firstUserMsg.content;
if (typeof content === "string") {
text = content;
} else {
const textBlocks = content.filter((c: any) => c.type === "text");
text = textBlocks.map((c: any) => c.text || "").join(" ");
}
text = text.trim();
if (!text) return "";
const sentenceEnd = text.search(/[.!?]/);
if (sentenceEnd > 0 && sentenceEnd <= 50) {
return text.substring(0, sentenceEnd + 1);
}
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
};
const shouldSaveSession = (messages: AgentMessage[]): boolean => {
const hasUserMsg = messages.some(
(m: any) => m.role === "user" || m.role === "user-with-attachments",
);
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
return hasUserMsg && hasAssistantMsg;
};
const saveSession = async () => {
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
const state = agent.state;
if (!shouldSaveSession(state.messages)) return;
try {
// Create session data
const sessionData = {
id: currentSessionId,
title: currentTitle,
model: state.model!,
thinkingLevel: state.thinkingLevel,
messages: state.messages,
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
// Create session metadata
const metadata = {
id: currentSessionId,
title: currentTitle,
createdAt: sessionData.createdAt,
lastModified: sessionData.lastModified,
messageCount: state.messages.length,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
modelId: state.model?.id || null,
thinkingLevel: state.thinkingLevel,
preview: generateTitle(state.messages),
};
await storage.sessions.save(sessionData, metadata);
} catch (err) {
console.error("Failed to save session:", err);
}
};
const updateUrl = (sessionId: string) => {
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
window.history.replaceState({}, "", url);
};
const createAgent = async (initialState?: Partial<AgentState>) => {
if (agentUnsubscribe) {
agentUnsubscribe();
}
agent = new Agent({
initialState: initialState || {
systemPrompt: `You are a helpful AI assistant with access to various tools.
Available tools:
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
Feel free to use these tools when needed to provide accurate and helpful responses.`,
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
thinkingLevel: "off",
messages: [],
tools: [],
},
// Custom transformer: convert custom messages to LLM-compatible format
convertToLlm: customConvertToLlm,
});
agentUnsubscribe = agent.subscribe((event: any) => {
if (event.type === "state-update") {
const messages = event.state.messages;
// Generate title after first successful response
if (!currentTitle && shouldSaveSession(messages)) {
currentTitle = generateTitle(messages);
}
// Create session ID on first successful save
if (!currentSessionId && shouldSaveSession(messages)) {
currentSessionId = crypto.randomUUID();
updateUrl(currentSessionId);
}
// Auto-save
if (currentSessionId) {
saveSession();
}
renderApp();
}
});
await chatPanel.setAgent(agent, {
onApiKeyRequired: async (provider: string) => {
return await ApiKeyPromptDialog.prompt(provider);
},
toolsFactory: (
_agent,
_agentInterface,
_artifactsPanel,
runtimeProvidersFactory,
) => {
// Create javascript_repl tool with access to attachments + artifacts
const replTool = createJavaScriptReplTool();
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
return [replTool];
},
});
};
const loadSession = async (sessionId: string): Promise<boolean> => {
if (!storage.sessions) return false;
const sessionData = await storage.sessions.get(sessionId);
if (!sessionData) {
console.error("Session not found:", sessionId);
return false;
}
currentSessionId = sessionId;
const metadata = await storage.sessions.getMetadata(sessionId);
currentTitle = metadata?.title || "";
await createAgent({
model: sessionData.model,
thinkingLevel: sessionData.thinkingLevel,
messages: sessionData.messages,
tools: [],
});
updateUrl(sessionId);
renderApp();
return true;
};
const newSession = () => {
const url = new URL(window.location.href);
url.search = "";
window.location.href = url.toString();
};
// ============================================================================
// RENDER
// ============================================================================
const renderApp = () => {
const app = document.getElementById("app");
if (!app) return;
const appHtml = html`
<div
class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-border shrink-0"
>
<div class="flex items-center gap-2 px-4 py-">
${Button({
variant: "ghost",
size: "sm",
children: icon(History, "sm"),
onClick: () => {
SessionListDialog.open(
async (sessionId) => {
await loadSession(sessionId);
},
(deletedSessionId) => {
// Only reload if the current session was deleted
if (deletedSessionId === currentSessionId) {
newSession();
}
},
);
},
title: "Sessions",
})}
${Button({
variant: "ghost",
size: "sm",
children: icon(Plus, "sm"),
onClick: newSession,
title: "New Session",
})}
${currentTitle
? isEditingTitle
? html`<div class="flex items-center gap-2">
${Input({
type: "text",
value: currentTitle,
className: "text-sm w-64",
onChange: async (e: Event) => {
const newTitle = (
e.target as HTMLInputElement
).value.trim();
if (
newTitle &&
newTitle !== currentTitle &&
storage.sessions &&
currentSessionId
) {
await storage.sessions.updateTitle(
currentSessionId,
newTitle,
);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
},
onKeyDown: async (e: KeyboardEvent) => {
if (e.key === "Enter") {
const newTitle = (
e.target as HTMLInputElement
).value.trim();
if (
newTitle &&
newTitle !== currentTitle &&
storage.sessions &&
currentSessionId
) {
await storage.sessions.updateTitle(
currentSessionId,
newTitle,
);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
} else if (e.key === "Escape") {
isEditingTitle = false;
renderApp();
}
},
})}
</div>`
: html`<button
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
@click=${() => {
isEditingTitle = true;
renderApp();
requestAnimationFrame(() => {
const input = app?.querySelector(
'input[type="text"]',
) as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
});
}}
title="Click to edit title"
>
${currentTitle}
</button>`
: html`<span class="text-base font-semibold text-foreground"
>Pi Web UI Example</span
>`}
</div>
<div class="flex items-center gap-1 px-2">
${Button({
variant: "ghost",
size: "sm",
children: icon(Bell, "sm"),
onClick: () => {
// Demo: Inject custom message (will appear on next agent run)
if (agent) {
agent.steer(
createSystemNotification(
"This is a custom message! It appears in the UI but is never sent to the LLM.",
),
);
}
},
title: "Demo: Add Custom Notification",
})}
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "sm",
children: icon(Settings, "sm"),
onClick: () =>
SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
title: "Settings",
})}
</div>
</div>
<!-- Chat Panel -->
${chatPanel}
</div>
`;
render(appHtml, app);
};
// ============================================================================
// INIT
// ============================================================================
async function initApp() {
const app = document.getElementById("app");
if (!app) throw new Error("App container not found");
// Show loading
render(
html`
<div
class="w-full h-screen flex items-center justify-center bg-background text-foreground"
>
<div class="text-muted-foreground">Loading...</div>
</div>
`,
app,
);
// TODO: Fix PersistentStorageDialog - currently broken
// Request persistent storage
// if (storage.sessions) {
// await PersistentStorageDialog.request();
// }
// Create ChatPanel
chatPanel = new ChatPanel();
// Check for session in URL
const urlParams = new URLSearchParams(window.location.search);
const sessionIdFromUrl = urlParams.get("session");
if (sessionIdFromUrl) {
const loaded = await loadSession(sessionIdFromUrl);
if (!loaded) {
// Session doesn't exist, redirect to new session
newSession();
return;
}
} else {
await createAgent();
}
renderApp();
}
initApp();

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"paths": {
"*": ["./*"],
"@mariozechner/pi-agent-core": ["../../agent/dist/index.d.ts"],
"@mariozechner/pi-ai": ["../../ai/dist/index.d.ts"],
"@mariozechner/pi-tui": ["../../tui/dist/index.d.ts"],
"@mariozechner/pi-web-ui": ["../dist/index.d.ts"]
},
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src/**/*"],
"exclude": ["../src"]
}

View file

@ -0,0 +1,6 @@
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss()],
});