Add 'aiui/' from commit 'e30ac1d1069532fb6d652d87e2d4a2fe9d1b4773'
git-subtree-dir: aiui git-subtree-mainline:0c4826f8ccgit-subtree-split:e30ac1d106
This commit is contained in:
@@ -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,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
Reference in New Issue
Block a user