Add 'aiui/' from commit 'e30ac1d1069532fb6d652d87e2d4a2fe9d1b4773'

git-subtree-dir: aiui
git-subtree-mainline: 0c4826f8cc
git-subtree-split: e30ac1d106
This commit is contained in:
archipelago
2026-08-03 15:07:11 -04:00
384 changed files with 68967 additions and 0 deletions
@@ -0,0 +1,162 @@
import type {
AIProviderAdapter,
AIModel,
ChatMessage,
ChatOptions,
ChatChunk,
PluginContext,
} from '@aiui/core'
const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
async function* streamChat(
messages: ChatMessage[],
options: ChatOptions,
systemPrompt?: string,
): AsyncGenerator<ChatChunk> {
const body: Record<string, unknown> = {
model: options.model,
messages: messages.filter(m => m.role !== 'system').map(m => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : m.content.map(p => p.text ?? '').join(''),
})),
stream: options.stream ?? true,
}
// Extract system message from messages or use provided systemPrompt
const systemMsg = messages.find(m => m.role === 'system')
if (systemPrompt) {
body.system = systemPrompt
} else if (systemMsg) {
body.system = typeof systemMsg.content === 'string'
? systemMsg.content
: systemMsg.content.map(p => p.text ?? '').join('')
}
if (options.maxTokens) body.max_tokens = options.maxTokens
if (options.temperature !== undefined) body.temperature = options.temperature
const res = await fetch(CLAUDE_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const errBody = await res.text().catch(() => 'Could not read error body')
yield { type: 'error', error: `Claude proxy error ${res.status}: ${errBody}` }
return
}
const reader = res.body?.getReader()
if (!reader) {
yield { type: 'error', error: 'No response body' }
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const payload = trimmed.slice(6)
if (payload === '[DONE]') {
yield { type: 'done' }
return
}
try {
const parsed = JSON.parse(payload)
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
yield { type: 'text', text: parsed.delta.text }
} else if (parsed.type === 'message_stop') {
yield { type: 'done', usage: parsed.usage ? {
promptTokens: parsed.usage.input_tokens ?? 0,
completionTokens: parsed.usage.output_tokens ?? 0,
} : undefined }
} else if (parsed.type === 'error') {
yield { type: 'error', error: parsed.error?.message ?? 'Claude stream error' }
}
} catch {
// skip malformed chunks
}
}
}
} finally {
reader.cancel().catch(() => {})
}
yield { type: 'done' }
}
export const claudeProvider: AIProviderAdapter = {
id: 'claude',
name: 'Claude (Anthropic)',
version: '1.0.0',
type: 'ai-provider',
description: 'Anthropic Claude AI via proxy server',
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
async init(_context: PluginContext): Promise<void> {
// No initialization needed — uses proxy
},
async destroy(): Promise<void> {
// No cleanup needed
},
async isAvailable(): Promise<boolean> {
try {
const res = await fetch(CLAUDE_PATH, { method: 'OPTIONS' })
return res.ok || res.status === 405 // OPTIONS may not be supported but proxy is up
} catch {
return false
}
},
chat(messages: ChatMessage[], options: ChatOptions): AsyncIterable<ChatChunk> {
return streamChat(messages, options)
},
async models(): Promise<AIModel[]> {
return [
{
id: 'claude-haiku-4.5',
name: 'Claude 4.5 Haiku',
provider: 'claude',
supportsVision: true,
supportsTools: true,
contextWindow: 200000,
},
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
provider: 'claude',
supportsVision: true,
supportsTools: true,
contextWindow: 200000,
},
{
id: 'claude-opus-4',
name: 'Claude Opus 4',
provider: 'claude',
supportsVision: true,
supportsTools: true,
contextWindow: 200000,
},
]
},
}
+40
View File
@@ -0,0 +1,40 @@
import { registerPlugin, registerRenderer } from '@aiui/core'
export async function initializePlugins(): Promise<void> {
// Register built-in AI provider
const { claudeProvider } = await import('./claude-provider')
registerPlugin(claudeProvider)
await claudeProvider.init({
settings: {
get: () => undefined,
set: () => {},
},
events: {
emit: () => {},
on: () => () => {},
},
logger: {
info: console.info.bind(console, '[AIUI plugin]'),
warn: console.warn.bind(console, '[AIUI plugin]'),
error: console.error.bind(console, '[AIUI plugin]'),
},
})
// Register built-in content renderers
const { filmRenderer } = await import('./renderers/film-renderer')
const { songRenderer } = await import('./renderers/song-renderer')
registerRenderer(filmRenderer)
registerRenderer(songRenderer)
// Register built-in search plugins
const { wikipediaPlugin } = await import('./wikipedia')
const { openLibraryPlugin } = await import('./openlibrary')
registerPlugin(wikipediaPlugin)
registerPlugin(openLibraryPlugin)
if (import.meta.env.DEV) {
console.log('[AIUI] Plugins initialized:', claudeProvider.id)
console.log('[AIUI] Renderers registered: film, song')
console.log('[AIUI] Search plugins: wikipedia, openlibrary')
}
}
+183
View File
@@ -0,0 +1,183 @@
/**
* MCP (Model Context Protocol) server plugin.
* Exposes AIUI capabilities as MCP tools that Claude can call via tool_use.
*/
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
export interface MCPTool {
name: string
description: string
input_schema: {
type: 'object'
properties: Record<string, { type: string; description: string }>
required?: string[]
}
}
export interface MCPToolCall {
id: string
name: string
input: Record<string, unknown>
}
export interface MCPToolResult {
type: 'tool_result'
tool_use_id: string
content: string
}
/** Available MCP tools exposed by AIUI */
export const mcpTools: MCPTool[] = [
{
name: 'search_films',
description: 'Search the user\'s film library by title, director, or genre',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query (title, director, or genre)' },
},
required: ['query'],
},
},
{
name: 'search_songs',
description: 'Search the user\'s song library by title, artist, or genre',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query (title, artist, or genre)' },
},
required: ['query'],
},
},
{
name: 'search_podcasts',
description: 'Search the user\'s podcast library by title, host, or genre',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query (title, host, or genre)' },
},
required: ['query'],
},
},
{
name: 'get_library_stats',
description: 'Get statistics about the user\'s content library',
input_schema: {
type: 'object',
properties: {},
},
},
]
/** Route a tool call to the appropriate handler and return the result */
export function handleToolCall(call: MCPToolCall): MCPToolResult {
const handler = toolHandlers[call.name]
if (!handler) {
return {
type: 'tool_result',
tool_use_id: call.id,
content: JSON.stringify({ error: `Unknown tool: ${call.name}` }),
}
}
const result = handler(call.input)
return {
type: 'tool_result',
tool_use_id: call.id,
content: typeof result === 'string' ? result : JSON.stringify(result),
}
}
type ToolHandler = (input: Record<string, unknown>) => unknown
const toolHandlers: Record<string, ToolHandler> = {
search_films(input) {
const query = (input.query as string ?? '').toLowerCase()
const results = mockFilms.filter(f =>
f.title.toLowerCase().includes(query) ||
f.director.toLowerCase().includes(query) ||
f.genres.some(g => g.toLowerCase().includes(query)),
)
return {
count: results.length,
films: results.slice(0, 10).map(f => ({
id: f.id,
title: f.title,
year: f.year,
director: f.director,
genres: f.genres,
rating: f.rating,
})),
}
},
search_songs(input) {
const query = (input.query as string ?? '').toLowerCase()
const results = mockSongs.filter(s =>
s.title.toLowerCase().includes(query) ||
s.artist.toLowerCase().includes(query) ||
(s.genres ?? []).some(g => g.toLowerCase().includes(query)),
)
return {
count: results.length,
songs: results.slice(0, 10).map(s => ({
id: s.id,
title: s.title,
artist: s.artist,
album: s.album,
year: s.year,
genres: s.genres,
})),
}
},
search_podcasts(input) {
const query = (input.query as string ?? '').toLowerCase()
const results = mockPodcasts.filter(p =>
p.title.toLowerCase().includes(query) ||
(p.host ?? '').toLowerCase().includes(query) ||
(p.genres ?? []).some(g => g.toLowerCase().includes(query)),
)
return {
count: results.length,
podcasts: results.slice(0, 10).map(p => ({
id: p.id,
title: p.title,
host: p.host,
year: p.year,
genres: p.genres,
})),
}
},
get_library_stats() {
return {
films: mockFilms.length,
songs: mockSongs.length,
podcasts: mockPodcasts.length,
genres: {
film: [...new Set(mockFilms.flatMap(f => f.genres))].sort(),
song: [...new Set(mockSongs.flatMap(s => s.genres ?? []))].sort(),
podcast: [...new Set(mockPodcasts.flatMap(p => p.genres ?? []))].sort(),
},
}
},
}
/** Parse tool_use blocks from a Claude SSE response and check if tool calls are present */
export function parseToolUseBlocks(content: unknown[]): MCPToolCall[] {
if (!Array.isArray(content)) return []
return content
.filter((block): block is { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> } =>
typeof block === 'object' && block !== null && (block as { type: string }).type === 'tool_use',
)
.map(block => ({
id: block.id,
name: block.name,
input: block.input,
}))
}
@@ -0,0 +1,43 @@
import type { AIUIPlugin, PluginContext } from '@aiui/core/types/plugin'
export interface OpenLibraryBook {
title: string
author: string
year?: number
coverId?: number
key: string
}
export async function searchOpenLibrary(query: string): Promise<OpenLibraryBook[]> {
try {
const res = await fetch(
`https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=10&fields=title,author_name,first_publish_year,cover_i,key`
)
if (!res.ok) return []
const data = await res.json()
return (data.docs ?? []).map((doc: Record<string, unknown>) => ({
title: doc.title as string,
author: (doc.author_name as string[])?.[0] ?? 'Unknown',
year: doc.first_publish_year as number | undefined,
coverId: doc.cover_i as number | undefined,
key: doc.key as string,
}))
} catch {
return []
}
}
export function getOpenLibraryCoverUrl(coverId: number, size: 'S' | 'M' | 'L' = 'M'): string {
return `https://covers.openlibrary.org/b/id/${coverId}-${size}.jpg`
}
export const openLibraryPlugin: AIUIPlugin = {
id: 'openlibrary',
name: 'Open Library',
version: '1.0.0',
type: 'search',
description: 'Search books from Open Library',
async init(_context: PluginContext) {},
async destroy() {},
async isAvailable() { return true },
}
@@ -0,0 +1,12 @@
import { defineAsyncComponent } from 'vue'
import type { RendererDefinition } from '@aiui/core'
export const filmRenderer: RendererDefinition = {
id: 'film',
name: 'Film Renderer',
contentType: 'film',
surfaces: ['chat-preview', 'panel-preview', 'panel-play'],
chatPreview: defineAsyncComponent(() => import('@/components/content/FilmGrid.vue')),
panelPreview: defineAsyncComponent(() => import('@/components/content/FilmGrid.vue')),
panelPlay: defineAsyncComponent(() => import('@/components/content/FilmDetail.vue')),
}
@@ -0,0 +1,12 @@
import { defineAsyncComponent } from 'vue'
import type { RendererDefinition } from '@aiui/core'
export const songRenderer: RendererDefinition = {
id: 'song',
name: 'Song Renderer',
contentType: 'song',
surfaces: ['chat-preview', 'panel-preview', 'panel-play'],
chatPreview: defineAsyncComponent(() => import('@/components/content/SongGrid.vue')),
panelPreview: defineAsyncComponent(() => import('@/components/content/SongGrid.vue')),
panelPlay: defineAsyncComponent(() => import('@/components/content/SongDetail.vue')),
}
@@ -0,0 +1,56 @@
import type { AIUIPlugin, PluginContext } from '@aiui/core/types/plugin'
export interface WikipediaResult {
title: string
extract: string
thumbnail?: string
url: string
}
export async function searchWikipedia(query: string): Promise<WikipediaResult | null> {
try {
const searchUrl = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`
const res = await fetch(searchUrl)
if (!res.ok) {
// Try search API as fallback
const searchRes = await fetch(
`https://en.wikipedia.org/w/api.php?action=opensearch&search=${encodeURIComponent(query)}&limit=1&format=json&origin=*`
)
if (!searchRes.ok) return null
const data = await searchRes.json()
if (!data[1]?.[0]) return null
// Fetch the summary for the first result
const summaryRes = await fetch(
`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(data[1][0])}`
)
if (!summaryRes.ok) return null
const summary = await summaryRes.json()
return {
title: summary.title,
extract: summary.extract ?? '',
thumbnail: summary.thumbnail?.source,
url: summary.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(data[1][0])}`,
}
}
const data = await res.json()
return {
title: data.title,
extract: data.extract ?? '',
thumbnail: data.thumbnail?.source,
url: data.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(query)}`,
}
} catch {
return null
}
}
export const wikipediaPlugin: AIUIPlugin = {
id: 'wikipedia',
name: 'Wikipedia',
version: '1.0.0',
type: 'search',
description: 'Search Wikipedia articles with /wiki command',
async init(_context: PluginContext) {},
async destroy() {},
async isAvailable() { return true },
}