Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,160 @@
<template>
<div class="space-y-4">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
API Keys
</h3>
<!-- Configured providers -->
<div class="space-y-2">
<div
v-for="provider in providers"
:key="provider.id"
class="flex items-center gap-3 p-3 rounded-xl"
:class="isDark
? 'bg-white/[0.03] border border-white/5'
: 'bg-black/[0.02] border border-black/5'"
>
<div class="flex-1 min-w-0">
<div
class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ provider.name }}
</div>
<div
class="text-xs font-mono mt-0.5"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
{{ provider.masked ?? 'Not configured' }}
</div>
</div>
<button
v-if="provider.hasKey"
class="text-xs px-2 py-1 rounded-md transition-colors"
:class="isDark
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
@click="removeKey(provider.id)"
>
Remove
</button>
</div>
</div>
<!-- Add new key -->
<div class="space-y-2">
<select
v-model="selectedProvider"
class="w-full px-3 py-2 rounded-lg text-xs outline-none"
:class="isDark
? 'bg-white/5 text-white/80 border border-white/10'
: 'bg-gray-50 text-gray-800 border border-gray-200'"
>
<option value="">Select provider...</option>
<option value="claude">Claude (Anthropic)</option>
<option value="openrouter">OpenRouter</option>
</select>
<div v-if="selectedProvider" class="flex gap-2">
<input
v-model="newKey"
type="password"
placeholder="Paste API key..."
class="flex-1 px-3 py-2 rounded-lg text-xs outline-none"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 border border-white/10'
: 'bg-gray-50 text-gray-800 placeholder:text-gray-400 border border-gray-200'"
style="font-size: 16px"
@keydown.enter="saveKey"
/>
<button
:disabled="!newKey.trim()"
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors"
:class="newKey.trim()
? 'bg-accent text-white hover:bg-accent/90'
: isDark
? 'bg-white/5 text-white/20 cursor-not-allowed'
: 'bg-gray-100 text-gray-300 cursor-not-allowed'"
@click="saveKey"
>
Save
</button>
</div>
<p
v-if="statusMessage"
class="text-xs"
:class="statusError ? 'text-red-400' : isDark ? 'text-green-400' : 'text-green-600'"
>
{{ statusMessage }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
const { isDark } = useTheme()
interface ProviderInfo {
id: string
name: string
hasKey: boolean
masked: string | null
}
const providers = ref<ProviderInfo[]>([])
const selectedProvider = ref('')
const newKey = ref('')
const statusMessage = ref('')
const statusError = ref(false)
const PROVIDER_NAMES: Record<string, string> = {
claude: 'Claude (Anthropic)',
openrouter: 'OpenRouter',
}
async function loadProviders() {
const configured = await listProviders()
const allProviders = ['claude', 'openrouter']
providers.value = await Promise.all(
allProviders.map(async (id) => {
const key = await getApiKey(id)
return {
id,
name: PROVIDER_NAMES[id] ?? id,
hasKey: !!key,
masked: key ? maskApiKey(key) : null,
}
})
)
}
async function saveKey() {
if (!selectedProvider.value || !newKey.value.trim()) return
try {
await storeApiKey(selectedProvider.value, newKey.value.trim())
statusMessage.value = `${PROVIDER_NAMES[selectedProvider.value] ?? selectedProvider.value} key saved`
statusError.value = false
newKey.value = ''
selectedProvider.value = ''
await loadProviders()
} catch (err) {
statusMessage.value = err instanceof Error ? err.message : 'Failed to store key — encryption required'
statusError.value = true
}
}
async function removeKey(provider: string) {
await deleteApiKey(provider)
statusMessage.value = `${PROVIDER_NAMES[provider] ?? provider} key removed`
statusError.value = false
await loadProviders()
}
onMounted(loadProviders)
</script>
@@ -0,0 +1,120 @@
<template>
<div class="space-y-3">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
Nostr Identity
</h3>
<!-- Logged in state -->
<div
v-if="isLoggedIn"
class="flex items-center gap-3 p-3 rounded-xl"
:class="isDark
? 'bg-white/[0.03] border border-white/5'
: 'bg-black/[0.02] border border-black/5'"
>
<div class="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
<svg class="w-4 h-4 text-purple-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div
class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
Connected
</div>
<button
class="text-xs font-mono mt-0.5 hover:underline"
:class="isDark ? 'text-purple-400/60' : 'text-purple-600/60'"
@click="copyNpub"
>
{{ copied ? 'Copied!' : truncatedNpub }}
</button>
</div>
<button
class="text-xs px-2 py-1 rounded-md transition-colors"
:class="isDark
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
@click="logout"
>
Logout
</button>
</div>
<!-- Logged out state -->
<template v-else>
<button
v-if="isAvailable"
:disabled="isLoading"
class="w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-all active:scale-[0.98]"
:class="isLoading
? isDark
? 'bg-purple-500/10 text-purple-400/40 cursor-wait'
: 'bg-purple-50 text-purple-300 cursor-wait'
: 'bg-purple-500/10 text-purple-400 hover:bg-purple-500/20'"
@click="login"
>
{{ isLoading ? 'Connecting...' : 'Login with Nostr' }}
</button>
<div
v-else
class="p-3 rounded-xl text-xs"
:class="isDark
? 'bg-white/[0.03] border border-white/5 text-white/40'
: 'bg-black/[0.02] border border-black/5 text-gray-500'"
>
No Nostr extension detected. Install
<a
href="https://github.com/nicolgit/nos2x"
target="_blank"
rel="noopener noreferrer"
class="text-purple-400 hover:underline"
>nos2x</a>,
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
class="text-purple-400 hover:underline"
>Alby</a>, or another NIP-07 browser extension.
</div>
</template>
<!-- Error -->
<p
v-if="error"
class="text-xs text-red-400"
>
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
const { isDark } = useTheme()
const {
isAvailable,
isLoggedIn,
isLoading,
error,
npub,
truncatedNpub,
login,
logout,
} = useNostrIdentity()
const copied = ref(false)
async function copyNpub() {
if (!npub.value) return
await navigator.clipboard.writeText(npub.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script>
@@ -0,0 +1,363 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-white/90">Plugins</h3>
<div class="flex items-center gap-2">
<span v-if="store.hasUpdates" class="text-xs px-1.5 py-0.5 rounded-full bg-accent/20 text-accent/80">
Updates
</span>
</div>
</div>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="tab in tabs"
:key="tab.id"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeTab === tab.id
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-3">
<!-- Discover -->
<template v-if="activeTab === 'discover'">
<div v-if="store.isLoadingRegistry" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Loading registry...</p>
</div>
<div v-else-if="store.registryError && store.registryPlugins.length === 0" class="py-8 text-center">
<p class="text-xs text-red-400/60">{{ store.registryError }}</p>
<button class="mt-2 text-xs text-accent/60 hover:text-accent/80" @click="store.fetchRegistry()">Retry</button>
</div>
<div
v-for="plugin in store.registryPlugins"
:key="plugin.id"
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-white/80">{{ plugin.name }}</p>
<p class="text-xs text-white/30">{{ plugin.author }} · v{{ plugin.version }}</p>
</div>
<button
v-if="!store.isInstalled(plugin.id)"
class="text-xs px-3 py-1.5 rounded-lg bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors shrink-0"
@click="showPermissionsDialog(plugin)"
>
Install
</button>
<span v-else class="text-xs text-emerald-400/60 shrink-0">Installed</span>
</div>
<p class="text-xs text-white/50">{{ plugin.description }}</p>
<div class="flex items-center gap-2">
<span class="text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/30">{{ plugin.type }}</span>
<div class="flex items-center gap-0.5">
<span v-for="i in 5" :key="i" class="text-xs" :class="i <= plugin.rating ? 'text-accent/60' : 'text-white/10'"></span>
</div>
</div>
</div>
</template>
<!-- Installed -->
<template v-else-if="activeTab === 'installed'">
<div v-if="store.installedPlugins.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p class="text-xs text-white/30">No plugins installed</p>
</div>
<div
v-for="plugin in store.installedPlugins"
:key="plugin.id"
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-white/80">{{ plugin.name }}</p>
<p class="text-xs text-white/30">v{{ plugin.version }} · {{ plugin.author }}</p>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<button
v-if="store.updatesAvailable.has(plugin.id)"
class="text-xs px-2 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="store.updatePlugin(plugin.id)"
>
Update to v{{ store.updatesAvailable.get(plugin.id) }}
</button>
<button
class="text-xs min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/5 transition-colors"
@click="editingPlugin = editingPlugin === plugin.id ? null : plugin.id"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
class="text-xs px-2 py-1 rounded text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
@click="store.uninstallPlugin(plugin.id)"
>
Remove
</button>
</div>
</div>
<!-- Permissions -->
<div class="flex flex-wrap gap-1">
<span
v-for="perm in plugin.permissions"
:key="perm"
class="text-xs px-1.5 py-0.5 rounded"
:class="plugin.grantedPermissions.includes(perm)
? 'bg-emerald-400/15 text-emerald-400/60'
: 'bg-red-400/15 text-red-400/60'"
>
{{ permissionLabel(perm) }}
</span>
</div>
<!-- Settings panel (inline) -->
<PluginSettingsForm
v-if="editingPlugin === plugin.id"
:plugin-id="plugin.id"
:settings="plugin.settings"
:permissions="plugin.permissions"
:granted-permissions="plugin.grantedPermissions"
@update-settings="(s) => store.updatePluginSettings(plugin.id, s)"
@update-permissions="(p) => store.updatePluginPermissions(plugin.id, p)"
/>
</div>
<!-- Update all -->
<button
v-if="store.hasUpdates"
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors mt-2"
@click="store.updateAllPlugins()"
>
Update all plugins
</button>
</template>
<!-- Import -->
<template v-else-if="activeTab === 'import'">
<div class="space-y-3">
<p class="text-xs text-white/40">
Paste a GitHub raw URL or IPFS CID to the plugin's manifest (aiui-plugin.json).
</p>
<input
v-model="importUrl"
type="text"
placeholder="https://raw.githubusercontent.com/.../aiui-plugin.json"
class="w-full px-3 py-2.5 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
/>
<button
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!importUrl.trim() || isImporting"
@click="importPlugin"
>
{{ isImporting ? 'Fetching manifest...' : 'Import Plugin' }}
</button>
<p v-if="importError" class="text-xs text-red-400/60">{{ importError }}</p>
<!-- Imported plugin preview -->
<div v-if="importedManifest" class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2">
<p class="text-xs font-semibold text-white/80">{{ importedManifest.name }}</p>
<p class="text-xs text-white/50">{{ importedManifest.description }}</p>
<p class="text-xs text-white/30">{{ importedManifest.author }} · v{{ importedManifest.version }}</p>
<button
class="w-full py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="showPermissionsDialog(importedManifest)"
>
Install
</button>
</div>
</div>
</template>
<!-- Dev Mode -->
<template v-else-if="activeTab === 'dev'">
<div v-if="!isDevMode" class="flex flex-col items-center justify-center py-12 gap-2">
<p class="text-xs text-white/30">Dev mode not enabled</p>
<p class="text-xs text-white/20">Set VITE_PLUGIN_DEV=true to enable</p>
</div>
<template v-else>
<div class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Plugin Dev Mode</p>
<p class="text-xs text-white/40">Hot-reload from src/plugins/dev/</p>
<div v-if="devErrors.length > 0" class="space-y-1 mt-2">
<p class="text-xs text-red-400/60 uppercase tracking-wider font-bold">Errors</p>
<div
v-for="(err, i) in devErrors"
:key="i"
class="text-xs text-red-400/50 font-mono bg-red-400/5 rounded p-2"
>
{{ err }}
</div>
</div>
<div v-if="devTimings.length > 0" class="space-y-1 mt-2">
<p class="text-xs text-white/30 uppercase tracking-wider font-bold">Init Timing</p>
<div
v-for="t in devTimings"
:key="t.id"
class="flex items-center justify-between text-xs"
>
<span class="text-white/50">{{ t.id }}</span>
<span class="text-white/30 tabular-nums">{{ t.ms }}ms</span>
</div>
</div>
</div>
</template>
</template>
</div>
<!-- Permissions dialog -->
<Teleport to="body">
<div v-if="pendingInstall" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div class="w-full max-w-sm mx-4 rounded-2xl bg-[#0a0a0a] border border-white/10 p-5 space-y-4">
<h4 class="text-sm font-bold text-white/90">Plugin Permissions</h4>
<p class="text-xs text-white/40">
"{{ pendingInstall.name }}" requests the following permissions:
</p>
<div class="space-y-2">
<label
v-for="perm in pendingInstall.permissions"
:key="perm"
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer"
>
<input
type="checkbox"
:checked="pendingPermissions.includes(perm)"
class="rounded accent-[#F7931A]"
@change="togglePermission(perm)"
/>
<div>
<p class="text-xs text-white/70">{{ permissionLabel(perm) }}</p>
<p class="text-xs text-white/30">{{ permissionDescription(perm) }}</p>
</div>
</label>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-2 rounded-lg text-xs text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
@click="pendingInstall = null"
>
Cancel
</button>
<button
class="flex-1 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="confirmInstall"
>
Install
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usePluginMarketplaceStore, type RegistryPlugin, type PluginPermission } from '@/stores/pluginMarketplace'
import PluginSettingsForm from './PluginSettingsForm.vue'
type Tab = 'discover' | 'installed' | 'import' | 'dev'
const tabs: { id: Tab; label: string }[] = [
{ id: 'discover', label: 'Discover' },
{ id: 'installed', label: 'Installed' },
{ id: 'import', label: 'Import' },
{ id: 'dev', label: 'Dev' },
]
const activeTab = ref<Tab>('discover')
const store = usePluginMarketplaceStore()
const editingPlugin = ref<string | null>(null)
// Import
const importUrl = ref('')
const isImporting = ref(false)
const importError = ref('')
const importedManifest = ref<RegistryPlugin | null>(null)
// Permissions dialog
const pendingInstall = ref<RegistryPlugin | null>(null)
const pendingPermissions = ref<PluginPermission[]>([])
// Dev mode
const isDevMode = !!import.meta.env.VITE_PLUGIN_DEV
const devErrors = ref<string[]>([])
const devTimings = ref<{ id: string; ms: number }[]>([])
function showPermissionsDialog(plugin: RegistryPlugin) {
pendingInstall.value = plugin
pendingPermissions.value = [...plugin.permissions]
}
function togglePermission(perm: PluginPermission) {
const idx = pendingPermissions.value.indexOf(perm)
if (idx >= 0) pendingPermissions.value.splice(idx, 1)
else pendingPermissions.value.push(perm)
}
function confirmInstall() {
if (!pendingInstall.value) return
store.installPlugin(pendingInstall.value, pendingPermissions.value)
pendingInstall.value = null
importedManifest.value = null
}
async function importPlugin() {
isImporting.value = true
importError.value = ''
importedManifest.value = null
const manifest = await store.importFromUrl(importUrl.value.trim())
if (manifest) {
importedManifest.value = manifest
} else {
importError.value = 'Invalid manifest or failed to fetch'
}
isImporting.value = false
}
function permissionLabel(perm: PluginPermission): string {
const labels: Record<PluginPermission, string> = {
'chat-messages': 'Chat Messages',
'network': 'Network Access',
'favorites': 'Favorites',
'storage': 'Local Storage',
'nostr': 'Nostr Identity',
'wallet': 'Wallet',
}
return labels[perm] ?? perm
}
function permissionDescription(perm: PluginPermission): string {
const descs: Record<PluginPermission, string> = {
'chat-messages': 'Read and inject content into chat messages',
'network': 'Make network requests to external APIs',
'favorites': 'Read and modify your favorites list',
'storage': 'Store data in local storage',
'nostr': 'Access your Nostr identity for signing',
'wallet': 'Interact with your connected wallet',
}
return descs[perm] ?? ''
}
onMounted(() => {
store.fetchRegistry().then(() => store.checkForUpdates())
})
</script>
@@ -0,0 +1,111 @@
<template>
<div class="mt-2 pt-2 border-t border-white/[0.05] space-y-3">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Settings</p>
<!-- Generic key-value settings editor -->
<div class="space-y-2">
<div v-for="(value, key) in localSettings" :key="key" class="flex items-center gap-2">
<span class="text-xs text-white/40 min-w-[60px]">{{ key }}</span>
<input
:value="String(value ?? '')"
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 outline-none focus:bg-white/10 transition-colors"
@input="updateSetting(key as string, ($event.target as HTMLInputElement).value)"
/>
</div>
<!-- Add new setting -->
<div class="flex gap-2">
<input
v-model="newSettingKey"
type="text"
placeholder="Key"
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors"
/>
<input
v-model="newSettingValue"
type="text"
placeholder="Value"
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors"
/>
<button
class="text-xs px-2 py-1.5 rounded-md bg-white/5 text-white/40 hover:text-white/70 hover:bg-white/10 transition-colors disabled:opacity-30"
:disabled="!newSettingKey.trim()"
@click="addSetting"
>
Add
</button>
</div>
</div>
<!-- Permissions toggles -->
<div v-if="permissions.length > 0">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">Permissions</p>
<div class="space-y-1">
<label
v-for="perm in permissions"
:key="perm"
class="flex items-center gap-2 text-xs cursor-pointer"
>
<input
type="checkbox"
:checked="localGranted.includes(perm)"
class="rounded accent-[#F7931A]"
@change="togglePermission(perm)"
/>
<span class="text-white/50">{{ perm }}</span>
</label>
</div>
</div>
<button
class="w-full py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="save"
>
Save Settings
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { PluginPermission } from '@/stores/pluginMarketplace'
const props = defineProps<{
pluginId: string
settings: Record<string, unknown>
permissions: PluginPermission[]
grantedPermissions: PluginPermission[]
}>()
const emit = defineEmits<{
updateSettings: [settings: Record<string, unknown>]
updatePermissions: [permissions: PluginPermission[]]
}>()
const localSettings = ref<Record<string, unknown>>({ ...props.settings })
const localGranted = ref<PluginPermission[]>([...props.grantedPermissions])
const newSettingKey = ref('')
const newSettingValue = ref('')
function updateSetting(key: string, value: string) {
localSettings.value[key] = value
}
function addSetting() {
if (!newSettingKey.value.trim()) return
localSettings.value[newSettingKey.value.trim()] = newSettingValue.value
newSettingKey.value = ''
newSettingValue.value = ''
}
function togglePermission(perm: PluginPermission) {
const idx = localGranted.value.indexOf(perm)
if (idx >= 0) localGranted.value.splice(idx, 1)
else localGranted.value.push(perm)
}
function save() {
emit('updateSettings', { ...localSettings.value })
emit('updatePermissions', [...localGranted.value])
}
</script>
@@ -0,0 +1,597 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="$emit('close')">
<div class="w-full max-w-lg mx-4 max-h-[80vh] rounded-2xl bg-[#0a0a0a] border border-white/10 flex flex-col overflow-hidden">
<!-- Header -->
<div class="p-4 border-b border-white/[0.08] flex items-center justify-between shrink-0">
<h2 class="text-sm font-bold text-white/90">Settings</h2>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
@click="$emit('close')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tabs -->
<div class="flex gap-1.5 px-4 pt-3 pb-1 shrink-0 overflow-x-auto scrollbar-hide">
<button
v-for="tab in tabs"
:key="tab.id"
class="text-xs px-3 min-h-[44px] flex items-center justify-center rounded-md whitespace-nowrap transition-all duration-150"
:class="activeTab === tab.id
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-6 space-y-4">
<!-- Appearance -->
<template v-if="activeTab === 'appearance'">
<!-- M15.1 Accent colour -->
<div class="space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Accent Colour</p>
<div class="flex gap-2 flex-wrap">
<button
v-for="color in accentPresets"
:key="color.value"
class="w-8 h-8 rounded-lg border-2 transition-all"
:style="{ backgroundColor: color.value }"
:class="store.accentColor === color.value ? 'border-white/60 scale-110' : 'border-white/10'"
:title="color.label"
@click="store.accentColor = color.value"
/>
<label class="w-8 h-8 rounded-lg border-2 border-white/10 flex items-center justify-center cursor-pointer overflow-hidden relative">
<span class="text-xs text-white/30">+</span>
<input
type="color"
:value="store.accentColor"
class="absolute inset-0 opacity-0 cursor-pointer"
@input="store.accentColor = ($event.target as HTMLInputElement).value"
/>
</label>
</div>
</div>
<!-- M15.2 Glass intensity -->
<div class="space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Glass Intensity</p>
<div class="flex gap-2">
<button
v-for="opt in glassOptions"
:key="opt.value"
class="flex-1 py-2 rounded-lg text-xs transition-all"
:class="store.glassIntensity === opt.value
? 'bg-accent/15 text-accent/80'
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
@click="store.glassIntensity = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- M15.3 Font size -->
<div class="space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Font Size</p>
<div class="flex gap-2">
<button
v-for="opt in fontOptions"
:key="opt.value"
class="flex-1 py-2 rounded-lg text-xs transition-all"
:class="store.fontSize === opt.value
? 'bg-accent/15 text-accent/80'
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
@click="store.fontSize = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
</template>
<!-- Content -->
<template v-else-if="activeTab === 'content'">
<!-- M15.4 Content type visibility -->
<div class="space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Content Types</p>
<p class="text-xs text-white/30">Hidden types still extract but don't show in the panel</p>
<div class="space-y-1">
<label
v-for="ct in contentTypes"
:key="ct.tab"
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer"
>
<input
type="checkbox"
:checked="store.isTabVisible(ct.tab)"
class="rounded accent-[#F7931A]"
@change="store.toggleTabVisibility(ct.tab)"
/>
<span class="text-xs text-white/70">{{ ct.label }}</span>
</label>
</div>
</div>
</template>
<!-- Shortcuts -->
<template v-else-if="activeTab === 'shortcuts'">
<!-- M15.5 Keyboard shortcuts -->
<div class="space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Keyboard Shortcuts</p>
<div class="space-y-1">
<div
v-for="(binding, action) in store.settings.shortcuts"
:key="action"
class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] border border-white/5"
>
<span class="text-xs text-white/60">{{ formatAction(action as string) }}</span>
<button
class="text-xs px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/70 font-mono transition-colors"
:class="recordingAction === action ? 'bg-accent/15 text-accent/80' : ''"
@click="startRecording(action as string)"
>
{{ recordingAction === action ? 'Press key...' : binding }}
</button>
</div>
</div>
</div>
</template>
<!-- Notifications -->
<template v-else-if="activeTab === 'notifications'">
<!-- M15.6 Push notifications -->
<div class="space-y-3">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Notifications</p>
<label class="flex items-center gap-3 p-3 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
<input
type="checkbox"
:checked="store.notificationsEnabled"
class="rounded accent-[#F7931A]"
@change="toggleNotifications"
/>
<div>
<p class="text-xs text-white/70">Generation complete</p>
<p class="text-xs text-white/30">Notify when AI finishes while tab is backgrounded</p>
</div>
</label>
<p v-if="notificationStatus" class="text-xs" :class="notificationOk ? 'text-emerald-400/60' : 'text-yellow-400/60'">
{{ notificationStatus }}
</p>
</div>
</template>
<!-- Storage -->
<template v-else-if="activeTab === 'storage'">
<!-- M15.7 Auto-archive -->
<div class="space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Auto-Archive</p>
<p class="text-xs text-white/30">Archive conversations older than:</p>
<div class="flex gap-2">
<button
v-for="opt in archiveOptions"
:key="opt.value"
class="flex-1 py-2 rounded-lg text-xs transition-all"
:class="store.autoArchiveDays === opt.value
? 'bg-accent/15 text-accent/80'
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
@click="store.autoArchiveDays = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- M15.8 Full data export -->
<div class="space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Data Export</p>
<button
class="w-full py-2.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="exportData"
>
{{ isExporting ? 'Exporting...' : 'Export All Data' }}
</button>
<p class="text-xs text-white/20">Exports conversations, favorites, settings, tags, and collections as JSON</p>
</div>
<!-- M15.9 Data wipe -->
<div class="space-y-2">
<p class="text-xs text-red-400/60 uppercase tracking-wider font-bold">Danger Zone</p>
<button
v-if="!confirmWipe"
class="w-full py-2.5 rounded-lg text-xs bg-red-400/10 text-red-400/60 hover:bg-red-400/20 hover:text-red-400/80 transition-colors"
@click="confirmWipe = true"
>
Wipe All Data
</button>
<div v-else class="space-y-2 p-3 rounded-lg bg-red-400/5 border border-red-400/20">
<p class="text-xs text-red-400/80 font-semibold">Are you sure?</p>
<p class="text-xs text-red-400/50">This will clear all conversations, favorites, settings, and cached data.</p>
<label class="flex items-center gap-2 text-xs text-red-400/60 cursor-pointer">
<input v-model="wipeApiKeys" type="checkbox" class="rounded" />
Also clear API key vault
</label>
<div class="flex gap-2">
<button
class="flex-1 py-2 rounded-lg text-xs text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
@click="confirmWipe = false"
>
Cancel
</button>
<button
class="flex-1 py-2 rounded-lg text-xs bg-red-400/20 text-red-400/80 hover:bg-red-400/30 transition-colors"
@click="wipeData"
>
Confirm Wipe
</button>
</div>
</div>
</div>
</template>
<!-- Chat defaults -->
<template v-else-if="activeTab === 'chat'">
<!-- Claude API Key Management -->
<div class="space-y-3">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Claude API Authentication</p>
<!-- Status indicator -->
<div class="flex items-center gap-2 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<div
class="w-2 h-2 rounded-full shrink-0"
:class="store.settings.useOwnApiKey && store.claudeApiKey
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
: 'bg-amber-400 shadow-[0_0_6px_rgba(251,191,36,0.4)]'"
/>
<span class="text-xs text-white/60">
{{ store.settings.useOwnApiKey && store.claudeApiKey
? 'Using your API key'
: 'Using server authentication (OAuth)' }}
</span>
</div>
<!-- Toggle -->
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
<input
v-model="store.settings.useOwnApiKey"
type="checkbox"
class="rounded accent-[#F7931A]"
/>
<div>
<span class="text-xs text-white/70">Use my own API key</span>
<p class="text-xs text-white/25 mt-0.5">Override server OAuth with your personal Claude API key</p>
</div>
</label>
<!-- API Key input (shown when toggle is on) -->
<div v-if="store.settings.useOwnApiKey" class="space-y-2">
<div class="flex gap-2">
<input
:value="apiKeyDisplay"
:type="showApiKey ? 'text' : 'password'"
placeholder="sk-ant-api03-..."
class="flex-1 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors font-mono"
style="font-size: 16px"
@input="onApiKeyInput"
@focus="onApiKeyFocus"
@blur="onApiKeyBlur"
/>
<button
class="px-2 py-2 rounded-lg text-white/30 hover:text-white/60 hover:bg-white/5 transition-colors"
:title="showApiKey ? 'Hide' : 'Show'"
@click="showApiKey = !showApiKey"
>
<svg v-if="!showApiKey" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.542-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.542 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
</button>
</div>
<button
v-if="store.claudeApiKey"
class="text-xs px-2 py-1 rounded-md text-red-400/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
@click="store.setClaudeApiKey(''); apiKeyStatus = 'Key removed'"
>
Remove key
</button>
<p v-if="apiKeyStatus" class="text-xs" :class="apiKeyStatusOk ? 'text-emerald-400/70' : 'text-amber-400/70'">
{{ apiKeyStatus }}
</p>
</div>
<!-- Help text -->
<div class="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1.5">
<p class="text-xs text-white/40 font-medium">How to get a Claude API key</p>
<ol class="text-xs text-white/25 space-y-1 list-decimal pl-4">
<li>Go to console.anthropic.com and create an account</li>
<li>Navigate to API Keys in your dashboard</li>
<li>Create a new key (starts with sk-ant-api03-)</li>
<li>Paste it above and enable "Use my own API key"</li>
</ol>
<p class="text-xs text-white/20 mt-2">Your key is stored encrypted on this device (AES-256-GCM) once you set an AIUI passphrase; without one it is held in memory for this session only — never written to disk unencrypted. Without a key, the server's OAuth authentication is used.</p>
</div>
</div>
<!-- Default conversation settings -->
<div class="space-y-3 mt-6 pt-4 border-t border-white/5">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Default Conversation Settings</p>
<p class="text-xs text-white/30">Applied to all new conversations</p>
<div class="space-y-2">
<label class="block">
<span class="text-xs text-white/40">Default Model</span>
<input
v-model="store.settings.defaultModel"
type="text"
placeholder="e.g. claude-sonnet-4-6"
class="w-full mt-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors font-mono"
/>
</label>
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
<input
v-model="store.settings.defaultWebSearch"
type="checkbox"
class="rounded accent-[#F7931A]"
/>
<span class="text-xs text-white/60">Enable web search by default</span>
</label>
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
<input
v-model="store.settings.defaultShowTokens"
type="checkbox"
class="rounded accent-[#F7931A]"
/>
<span class="text-xs text-white/60">Show token counts by default</span>
</label>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import type { ContentTab } from '@/composables/contentFiltering'
defineEmits<{ close: [] }>()
type Tab = 'appearance' | 'content' | 'shortcuts' | 'notifications' | 'storage' | 'chat'
const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: 'Appearance' },
{ id: 'content', label: 'Content' },
{ id: 'shortcuts', label: 'Shortcuts' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'storage', label: 'Storage' },
{ id: 'chat', label: 'Chat' },
]
const activeTab = ref<Tab>('appearance')
const store = useSettingsStore()
// Populate the key from the encrypted vault when a passphrase session is
// already active (standalone). Never touches localStorage (S2).
onMounted(() => { store.initClaudeKey() })
// M15.1 Accent presets
const accentPresets = [
{ value: '#F7931A', label: 'Bitcoin Orange' },
{ value: '#8B5CF6', label: 'Purple' },
{ value: '#3B82F6', label: 'Blue' },
{ value: '#10B981', label: 'Emerald' },
{ value: '#EF4444', label: 'Red' },
{ value: '#EC4899', label: 'Pink' },
{ value: '#F59E0B', label: 'Amber' },
{ value: '#06B6D4', label: 'Cyan' },
]
// M15.2 Glass options
const glassOptions: { value: 'subtle' | 'default' | 'strong'; label: string }[] = [
{ value: 'subtle', label: 'Subtle' },
{ value: 'default', label: 'Default' },
{ value: 'strong', label: 'Strong' },
]
// M15.3 Font options
const fontOptions: { value: 'compact' | 'default' | 'large'; label: string }[] = [
{ value: 'compact', label: 'Compact (13px)' },
{ value: 'default', label: 'Default (15px)' },
{ value: 'large', label: 'Large (17px)' },
]
// M15.4 Content types
const contentTypes: { tab: ContentTab; label: string }[] = [
{ tab: 'film', label: 'Films' },
{ tab: 'song', label: 'Music' },
{ tab: 'podcast', label: 'Podcasts' },
{ tab: 'book', label: 'Books' },
{ tab: 'tvshow', label: 'TV Series' },
{ tab: 'image', label: 'Images' },
{ tab: 'place', label: 'Places' },
{ tab: 'news', label: 'News' },
{ tab: 'websites', label: 'Websites' },
{ tab: 'magazine', label: 'Brief' },
{ tab: 'nostr', label: 'Nostr' },
]
// M15.5 Shortcuts
const recordingAction = ref<string | null>(null)
function formatAction(action: string): string {
return action.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
function startRecording(action: string) {
recordingAction.value = action
const handler = (e: KeyboardEvent) => {
e.preventDefault()
const parts: string[] = []
if (e.metaKey || e.ctrlKey) parts.push('Cmd')
if (e.shiftKey) parts.push('Shift')
if (e.altKey) parts.push('Alt')
if (e.key !== 'Meta' && e.key !== 'Control' && e.key !== 'Shift' && e.key !== 'Alt') {
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key)
}
if (parts.length > 0) {
store.setShortcut(action, parts.join('+'))
}
recordingAction.value = null
document.removeEventListener('keydown', handler)
}
document.addEventListener('keydown', handler)
}
// M15.6 Notifications
const notificationStatus = ref('')
const notificationOk = ref(false)
async function toggleNotifications() {
if (!store.notificationsEnabled) {
// Enable
if (!('Notification' in window)) {
notificationStatus.value = 'Notifications not supported in this browser'
notificationOk.value = false
return
}
const permission = await Notification.requestPermission()
if (permission === 'granted') {
store.notificationsEnabled = true
notificationStatus.value = 'Notifications enabled'
notificationOk.value = true
} else {
notificationStatus.value = 'Permission denied'
notificationOk.value = false
}
} else {
store.notificationsEnabled = false
notificationStatus.value = ''
}
}
// M15.7 Archive options
const archiveOptions = [
{ value: 0, label: 'Never' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
{ value: 90, label: '90 days' },
]
// M15.8 Export
const isExporting = ref(false)
async function exportData() {
isExporting.value = true
try {
const data: Record<string, unknown> = {}
// Gather all localStorage items
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith('aiui-')) {
try { data[key] = JSON.parse(localStorage.getItem(key) ?? '') }
catch { data[key] = localStorage.getItem(key) }
}
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `aiui-export-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
} finally {
isExporting.value = false
}
}
// API key management
const showApiKey = ref(false)
const apiKeyEditing = ref(false)
const apiKeyStatus = ref('')
const apiKeyStatusOk = ref(true)
const apiKeyDisplay = computed(() => {
if (apiKeyEditing.value) return store.claudeApiKey
if (!store.claudeApiKey) return ''
if (showApiKey.value) return store.claudeApiKey
const k = store.claudeApiKey
return k.length > 8 ? k.slice(0, 10) + '...' + k.slice(-4) : '****'
})
async function onApiKeyInput(e: Event) {
const val = (e.target as HTMLInputElement).value
const held = await store.setClaudeApiKey(val)
if (val && val.startsWith('sk-ant-api')) {
apiKeyStatus.value = held === 'encrypted'
? 'Key saved — stored encrypted'
: 'Key saved for this session only — set a passphrase to store it encrypted'
apiKeyStatusOk.value = true
} else if (val && !val.startsWith('sk-ant-')) {
apiKeyStatus.value = 'Key should start with sk-ant-api03-'
apiKeyStatusOk.value = false
} else {
apiKeyStatus.value = ''
}
}
function onApiKeyFocus() {
apiKeyEditing.value = true
}
function onApiKeyBlur() {
apiKeyEditing.value = false
}
// M15.9 Data wipe
const confirmWipe = ref(false)
const wipeApiKeys = ref(false)
async function wipeData() {
// Clear localStorage (except API keys if unchecked)
const keysToKeep: string[] = []
if (!wipeApiKeys.value) {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.includes('api-key') || key?.includes('vault')) {
keysToKeep.push(key)
}
}
}
const preserved = new Map<string, string>()
for (const key of keysToKeep) {
preserved.set(key, localStorage.getItem(key) ?? '')
}
localStorage.clear()
for (const [key, value] of preserved) {
localStorage.setItem(key, value)
}
// Clear IDB
const dbs = await indexedDB.databases()
for (const db of dbs) {
if (db.name) indexedDB.deleteDatabase(db.name)
}
// Clear SW cache
if ('caches' in window) {
const names = await caches.keys()
for (const name of names) {
await caches.delete(name)
}
}
window.location.reload()
}
</script>