Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 25d938cd1a
2315 changed files with 510085 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: ['../src/**/__stories__/*.stories.ts'],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
addons: ['@storybook/addon-essentials'],
viteFinal(config) {
config.resolve ??= {}
config.resolve.alias ??= {}
// Match app aliases
const alias = config.resolve.alias as Record<string, string>
alias['@'] = new URL('../src', import.meta.url).pathname
alias['@aiui/core'] = new URL('../../core/src', import.meta.url).pathname
return config
},
}
export default config
+16
View File
@@ -0,0 +1,16 @@
import type { Preview } from '@storybook/vue3'
import '../src/styles/main.css'
const preview: Preview = {
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
],
},
layout: 'centered',
},
}
export default preview
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+124
View File
@@ -0,0 +1,124 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-f97094b3'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.rat89nkoims"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/api\.anthropic\.com\/.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/^https:\/\/openrouter\.ai\/.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/web-search\?.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/rss-articles\?.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/tmdb\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "tmdb-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 86400
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/image\.tmdb\.org\/.*/i, new workbox.CacheFirst({
"cacheName": "tmdb-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 604800
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/upload\.wikimedia\.org\/.*/i, new workbox.CacheFirst({
"cacheName": "wiki-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 604800
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/d12wklypp119aj\.cloudfront\.net\/image\/.*/i, new workbox.CacheFirst({
"cacheName": "wavlake-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 300,
maxAgeSeconds: 604800
})]
}), 'GET');
}));
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
import { test, expect } from '@playwright/test'
test.describe('Content surfaces', () => {
test('empty state shows when no conversation selected', async ({ page }) => {
await page.goto('/')
const main = page.locator('main.path-glass-card')
await expect(main).toBeVisible()
})
test('chat can receive input', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const input = page.getByPlaceholder(/Message AIUI/)
await input.click()
await input.pressSequentially('Recommend some films')
await expect(input).toHaveValue('Recommend some films', { timeout: 3000 })
})
test('films surface: films conversation loads and shows film cards', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
await page.getByRole('button', { name: /View all \d+ films/i }).click()
await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
})
test('films surface: clicking assistant bubble opens panel', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click()
await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
})
test('magazine surface: BIP brief shows sections', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
await page.getByRole('button', { name: 'BIP 110 brief' }).click()
await expect(page.getByText(/BIP 110|Pro camp|Summary/i).first()).toBeVisible({ timeout: 8000 })
await page.getByRole('button', { name: 'View brief' }).click()
await expect(page.getByText(/AI Brief|Summary|Pro camp/i).first()).toBeVisible({ timeout: 5000 })
})
test('songs surface: songs conversation shows song cards', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
await page.getByRole('button', { name: 'Music recommendations' }).click()
await expect(page.getByText('Never Meant').first()).toBeVisible({ timeout: 8000 })
await page.getByRole('button', { name: /View all \d+ songs/i }).click()
await expect(page.locator('main').getByText('Never Meant').first()).toBeVisible({ timeout: 5000 })
})
test('podcasts surface: podcasts conversation shows podcast cards', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
await page.getByRole('button', { name: 'Bitcoin podcasts' }).click()
await expect(page.getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 8000 })
await page.getByRole('button', { name: /View all \d+ podcasts/i }).click()
await expect(page.locator('main').getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 5000 })
})
test('websites surface: websites tab shows link cards', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
await page.getByRole('button', { name: 'Bitcoin resources' }).click()
await expect(page.getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 8000 })
await page.getByRole('button', { name: /View all \d+ websites/i }).click()
await expect(page.locator('main').getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 5000 })
})
test('news surface: news conversation shows articles', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
await page.getByRole('button', { name: 'Latest Bitcoin news' }).click()
await expect(page.getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 8000 })
await page.getByRole('button', { name: /View all \d+ articles/i }).click()
await expect(page.locator('main').getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 5000 })
})
})
test.describe('Chat interactions', () => {
test('sends a message and receives streaming response', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await page.waitForLoadState('networkidle')
const input = page.getByPlaceholder(/Message AIUI/)
await input.click()
await input.fill('Hello')
await input.press('Enter')
// User message should appear in chat
await expect(page.getByText('Hello').first()).toBeVisible({ timeout: 5000 })
})
test('content panel shows film cards when AI mentions films', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
// Film cards should be visible inline in assistant message
await expect(page.getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
// Open panel via "View all" button
await page.getByRole('button', { name: /View all \d+ films/i }).click()
await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
})
test('clicking a film card opens detail view', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
// Open panel
await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click()
const filmCard = page.locator('main').getByText('Blade Runner 2049').first()
await expect(filmCard).toBeVisible({ timeout: 5000 })
// Click film card to open detail
await filmCard.click()
// Detail view should show film metadata
await expect(page.getByText(/Denis Villeneuve|2017|Sci-Fi/i).first()).toBeVisible({ timeout: 5000 })
})
test('mobile viewport shows full-screen overlay for content', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 })
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
// Open panel on mobile
await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click()
// Content should be visible as overlay on mobile
await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
})
test('stop button halts generation', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// When not streaming, stop button should not be visible
const stopButton = page.getByRole('button', { name: 'Stop generation' })
await expect(stopButton).toBeHidden({ timeout: 3000 })
// Chat input should be available instead
const input = page.getByPlaceholder(/Message AIUI/)
await expect(input).toBeVisible({ timeout: 3000 })
})
test('web search toggle works', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const toggle = page.getByRole('button', { name: 'Toggle web search' })
await expect(toggle).toBeVisible({ timeout: 5000 })
// Click to toggle web search on
await toggle.click()
// The button styling should change (it gains accent color when active)
await expect(toggle).toBeVisible()
// Click again to toggle off
await toggle.click()
await expect(toggle).toBeVisible()
})
test('new conversation clears messages', async ({ page }) => {
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
// Click "New conversation" button
await page.getByRole('button', { name: 'New conversation' }).click()
// Previous messages should be cleared
await expect(page.getByText('Recommend some sci-fi films')).toBeHidden({ timeout: 5000 })
})
test('panel side toggle switches layout', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 })
await Promise.all([
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
page.goto('/'),
])
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
// Open the panel
await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click()
await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
// Both chat and panel sections should be visible on desktop
const chatSection = page.locator('section').first()
await expect(chatSection).toBeVisible()
})
})
@@ -0,0 +1,148 @@
import type { Conversation } from '@aiui/core/types/message'
const now = Date.now()
/** Films: user asks for films, assistant responds with [[film:f1]] etc */
export const filmsConversation: Conversation = {
id: 'e2e-films',
title: 'Film recommendations',
messages: [
{
id: 'm1',
role: 'user',
content: 'Recommend some sci-fi films',
timestamp: now - 60000,
},
{
id: 'm2',
role: 'assistant',
content: `Here are some great sci-fi films:\n\n- [[film:f1]] - Blade Runner 2049\n- [[film:f2]] - Arrival\n- [[film:f3]] - Dune\n\nAll from Denis Villeneuve.`,
timestamp: now - 30000,
},
],
createdAt: now - 120000,
updatedAt: now,
}
/** Magazine: news-like query + bullet sections (BIP/debate context) */
export const magazineConversation: Conversation = {
id: 'e2e-magazine',
title: 'BIP 110 brief',
messages: [
{
id: 'm1',
role: 'user',
content: "What's the latest on BIP 110? What are people saying?",
timestamp: now - 60000,
},
{
id: 'm2',
role: 'assistant',
content: `## Summary\n\nBIP 110 is being debated. Macro sentiment is bearish. BTC holding.\n\n- **Pro camp** — Technical improvement, faster.\n- **Anti camp** — Too risky, prefer status quo.\n\n**Henrik Zeberg** (analyst) says this could be bullish long-term.\n\nFor deeper analysis: check **Bitcoin Mailing List** (gnusha.org).`,
timestamp: now - 30000,
},
],
createdAt: now - 120000,
updatedAt: now,
}
/** Websites: user asks for resources, assistant gives markdown links */
export const websitesConversation: Conversation = {
id: 'e2e-websites',
title: 'Bitcoin resources',
messages: [
{
id: 'm1',
role: 'user',
content: 'Best websites to check for Bitcoin news?',
timestamp: now - 60000,
},
{
id: 'm2',
role: 'assistant',
content: `Here are the best places to check:\n\n- [Bitcoin Magazine](https://bitcoinmagazine.com)\n- [Bitcoin.org](https://bitcoin.org)\n- [Mempool.space](https://mempool.space)`,
timestamp: now - 30000,
},
],
createdAt: now - 120000,
updatedAt: now,
}
/** News: web search results + news-like response */
export const newsConversation: Conversation = {
id: 'e2e-news',
title: 'Latest Bitcoin news',
messages: [
{
id: 'm1',
role: 'user',
content: "What's the latest Bitcoin news?",
timestamp: now - 60000,
},
{
id: 'm2',
role: 'assistant',
content: `Here's what's happening. For the latest news check these sources:\n\n- [Bitcoin hits new high](https://example.com/btc-high)\n- [ETF inflows surge](https://example.com/etf-inflows)`,
timestamp: now - 30000,
webResults: [
{ title: 'Bitcoin hits new high', url: 'https://example.com/btc-high', content: 'BTC reached...' },
{ title: 'ETF inflows surge', url: 'https://example.com/etf-inflows', content: 'Spot ETF...' },
],
},
],
createdAt: now - 120000,
updatedAt: now,
}
/** Songs: user asks for music, assistant responds with [[song:s1]] */
export const songsConversation: Conversation = {
id: 'e2e-songs',
title: 'Music recommendations',
messages: [
{
id: 'm1',
role: 'user',
content: 'Recommend some math rock',
timestamp: now - 60000,
},
{
id: 'm2',
role: 'assistant',
content: `Here are great math rock tracks:\n\n- [[song:s1]] Never Meant by American Football\n- [[song:s2]] The Kill by Toe`,
timestamp: now - 30000,
},
],
createdAt: now - 120000,
updatedAt: now,
}
/** Podcasts */
export const podcastsConversation: Conversation = {
id: 'e2e-podcasts',
title: 'Bitcoin podcasts',
messages: [
{
id: 'm1',
role: 'user',
content: 'Best Bitcoin podcasts?',
timestamp: now - 60000,
},
{
id: 'm2',
role: 'assistant',
content: `Check these:\n\n- [[podcast:p1]] What Bitcoin Did\n- [[podcast:p2]] The Audacity to Podcast`,
timestamp: now - 30000,
},
],
createdAt: now - 120000,
updatedAt: now,
}
export const allTestConversations = {
[filmsConversation.id]: filmsConversation,
[magazineConversation.id]: magazineConversation,
[websitesConversation.id]: websitesConversation,
[newsConversation.id]: newsConversation,
[songsConversation.id]: songsConversation,
[podcastsConversation.id]: podcastsConversation,
}
+27
View File
@@ -0,0 +1,27 @@
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
import { allTestConversations } from './fixtures/test-chats'
const CHATS_PATH = resolve(process.cwd(), '.dev', 'chats.json')
export default async function globalSetup() {
const dir = resolve(process.cwd(), '.dev')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
// Backup existing chats if present (for local dev)
let backup: string | null = null
if (existsSync(CHATS_PATH)) {
backup = readFileSync(CHATS_PATH, 'utf-8')
}
const payload = {
conversations: allTestConversations,
activeConversationId: 'e2e-films',
}
writeFileSync(CHATS_PATH, JSON.stringify(payload, null, 2), 'utf-8')
// Store backup path for teardown (we pass via env since globalSetup/Teardown don't share scope easily)
if (backup) {
process.env.AIUI_E2E_CHATS_BACKUP = backup
}
}
+11
View File
@@ -0,0 +1,11 @@
import { writeFileSync } from 'fs'
import { resolve } from 'path'
const CHATS_PATH = resolve(process.cwd(), '.dev', 'chats.json')
export default async function globalTeardown() {
const backup = process.env.AIUI_E2E_CHATS_BACKUP
if (backup) {
writeFileSync(CHATS_PATH, backup, 'utf-8')
}
}
+20
View File
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test'
test.describe('AIUI smoke tests', () => {
test('app loads and shows chat interface', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/AIUI/)
})
test('chat input is visible and focusable', async ({ page }) => {
await page.goto('/')
const input = page.getByPlaceholder(/Message AIUI|Waiting for/)
await expect(input).toBeVisible()
})
test('content panel area exists', async ({ page }) => {
await page.goto('/')
const main = page.locator('main.path-glass-card')
await expect(main).toBeVisible()
})
})
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test'
test.describe('Visual Regression', () => {
test('ChatPage renders correctly', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('chat-page.png', {
maxDiffPixelRatio: 0.005,
fullPage: true,
})
})
test('ContentPanel renders correctly', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Open content panel by clicking a content tab
const filmTab = page.getByRole('button', { name: /film/i }).first()
if (await filmTab.isVisible()) {
await filmTab.click()
await page.waitForTimeout(500)
await expect(page).toHaveScreenshot('content-panel-films.png', {
maxDiffPixelRatio: 0.005,
})
}
})
test('PassphraseDialog renders correctly', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// PassphraseDialog shows on first load if crypto is enabled
const dialog = page.locator('.glass-card').filter({ hasText: 'Unlock AIUI' })
if (await dialog.isVisible()) {
await expect(dialog).toHaveScreenshot('passphrase-dialog.png', {
maxDiffPixelRatio: 0.005,
})
}
})
test('BottomSheet renders correctly on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('mobile-view.png', {
maxDiffPixelRatio: 0.005,
fullPage: true,
})
})
})
+46
View File
@@ -0,0 +1,46 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
import globals from 'globals'
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['src/**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: ['.vue'],
sourceType: 'module',
},
},
},
{
files: ['src/**/*.{ts,vue}'],
languageOptions: {
globals: {
...globals.browser,
},
},
rules: {
// TypeScript
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
// Vue
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
'vue/no-v-html': 'warn',
// General
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
{
ignores: ['dist/', 'node_modules/', 'e2e/', 'server/'],
},
)
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" class="h-full overflow-hidden">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="AIUI" />
<meta name="description" content="AI chat interface with rich content surfaces" />
<!-- CSP set via HTTP headers in production nginx — not in HTML meta to avoid breaking Vite HMR -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<title>AIUI</title>
</head>
<body class="antialiased h-full overflow-hidden fixed w-full">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"numberOfRuns": 3,
"settings": {
"preset": "desktop"
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.7 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 3000 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.15 }],
"categories:accessibility": ["warn", { "minScore": 0.8 }],
"categories:best-practices": ["warn", { "minScore": 0.8 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@aiui/app",
"version": "0.1.0",
"private": true,
"description": "AIUI reference application",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "bash scripts/dev.sh",
"dev:vite": "vite",
"dev:proxy": "tsx server/claude-proxy.ts",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint src/",
"typecheck": "vue-tsc --noEmit",
"clean": "rm -rf dist",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@aiui/core": "workspace:*",
"@tanstack/vue-virtual": "^3.13.19",
"hls.js": "^1.6.15",
"katex": "^0.16.33",
"leaflet": "^1.9.4",
"markdown-it": "^14.1.1",
"mermaid": "^11.12.3",
"pdfjs-dist": "^5.5.207",
"pinia": "^3.0.4",
"plyr": "^3.8.4",
"vue": "^3.5.29",
"vue-router": "^5.0.3",
"wavesurfer.js": "^7.12.1",
"dompurify": "^3.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.49.0",
"@storybook/addon-essentials": "^8.6.0",
"@storybook/vue3": "^8.6.0",
"@storybook/vue3-vite": "^8.6.0",
"@tailwindcss/vite": "^4.2.1",
"@types/leaflet": "^1.9.21",
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-basic-ssl": "^2.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"duck-duck-scrape": "^2.2.7",
"eslint": "^10.0.2",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"happy-dom": "^20.8.3",
"rss-parser": "^3.13.0",
"storybook": "^8.6.0",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "~5.8.0",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.18",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.5"
}
}
+49
View File
@@ -0,0 +1,49 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
reporter: 'html',
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}',
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.005 },
},
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
// Desktop browsers
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
// Mobile viewports
{
name: 'iphone14',
use: {
...devices['iPhone 14'],
viewport: { width: 390, height: 844 },
},
},
{
name: 'galaxy-s21',
use: {
userAgent: 'Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36',
viewport: { width: 360, height: 800 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
},
},
],
webServer: {
command: 'pnpm run dev:vite',
url: 'http://localhost:5173',
reuseExistingServer: true,
},
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 83.44 122.88">
<path fill="currentColor" d="M45.04,95.45v24.11c0,1.83-1.49,3.32-3.32,3.32c-1.83,0-3.32-1.49-3.32-3.32V95.45c-10.16-0.81-19.32-5.3-26.14-12.12C4.69,75.77,0,65.34,0,53.87c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,9.64,3.95,18.41,10.31,24.77c6.36,6.36,15.13,10.31,24.77,10.31h0c9.64,0,18.41-3.95,24.77-10.31c6.36-6.36,10.31-15.13,10.31-24.77c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,11.48-4.69,21.91-12.25,29.47C64.36,90.16,55.2,94.64,45.04,95.45z M41.94,0c6.38,0,12.18,2.61,16.38,6.81c4.2,4.2,6.81,10,6.81,16.38v30c0,6.38-2.61,12.18-6.81,16.38c-4.2,4.2-10,6.81-16.38,6.81s-12.18-2.61-16.38-6.81c-4.2-4.2-6.81-10-6.81-16.38v-30c0-6.38,2.61-12.18,6.81-16.38C29.76,2.61,35.56,0,41.94,0z M53.62,11.51c-3-3-7.14-4.86-11.68-4.86c-4.55,0-8.68,1.86-11.68,4.86c-3,3-4.86,7.14-4.86,11.68v30c0,4.55,1.86,8.68,4.86,11.68c3,3,7.14,4.86,11.68,4.86c4.55,0,8.68-1.86,11.68-4.86c3-3,4.86-7.14,4.86-11.68v-30C58.49,18.64,56.62,14.51,53.62,11.51z"/>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#111111"/>
<path d="M 16,5.5 Q 16,16 26.5,16 Q 16,16 16,26.5 Q 16,16 5.5,16 Q 16,16 16,5.5 Z" fill="#fafafa"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1a1a"/>
<stop offset="100%" stop-color="#0a0a0a"/>
</linearGradient>
<linearGradient id="border" x1="0" y1="0" x2="0" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="rgba(255,255,255,0.18)"/>
<stop offset="100%" stop-color="rgba(255,255,255,0.04)"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="108" fill="url(#bg)"/>
<rect x="2" y="2" width="508" height="508" rx="106" fill="none" stroke="url(#border)" stroke-width="2"/>
<circle cx="256" cy="256" r="120" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="2.5"/>
<path d="M 256,106 Q 256,256 406,256 Q 256,256 256,406 Q 256,256 106,256 Q 256,256 256,106 Z" fill="#fafafa"/>
</svg>

After

Width:  |  Height:  |  Size: 922 B

@@ -0,0 +1,24 @@
<svg width="200" height="300" viewBox="0 0 200 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="clip-poster">
<rect width="200" height="300" rx="12" fill="white"/>
</clipPath>
</defs>
<g clip-path="url(#clip-poster)">
<rect width="200" height="300" fill="currentColor" fill-opacity="0.15"/>
<g fill="currentColor" fill-opacity="0.2">
<rect x="8" y="12" width="16" height="24" rx="2"/>
<rect x="40" y="12" width="16" height="24" rx="2"/>
<rect x="72" y="12" width="16" height="24" rx="2"/>
<rect x="104" y="12" width="16" height="24" rx="2"/>
<rect x="136" y="12" width="16" height="24" rx="2"/>
<rect x="168" y="12" width="16" height="24" rx="2"/>
<rect x="8" y="48" width="16" height="24" rx="2"/>
<rect x="40" y="48" width="16" height="24" rx="2"/>
<rect x="72" y="48" width="16" height="24" rx="2"/>
<rect x="104" y="48" width="16" height="24" rx="2"/>
<rect x="136" y="48" width="16" height="24" rx="2"/>
<rect x="168" y="48" width="16" height="24" rx="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Start Claude proxy and Vite dev server together.
# Both are killed when either exits or when this script receives SIGINT/SIGTERM.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$APP_DIR"
# Generate a dev API token if not already set
if [ -z "${VITE_DEV_API_TOKEN:-}" ]; then
export VITE_DEV_API_TOKEN=$(openssl rand -hex 16)
fi
cleanup() {
kill 0 2>/dev/null || true
wait 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Use local node_modules binaries
BIN="$APP_DIR/node_modules/.bin"
# Kill stale proxy from previous session (it has a different auth token)
if lsof -ti:3141 > /dev/null 2>&1; then
echo " Killing stale Claude proxy on :3141"
kill $(lsof -ti:3141) 2>/dev/null || true
sleep 0.3
fi
"$BIN/tsx" server/claude-proxy.ts &
sleep 0.3
# Start Vite dev server in background (host binding controlled by vite.config.ts / VITE_HOST)
"$BIN/vite" &
# Wait for all background jobs (compatible with bash 3.2 on macOS)
wait
@@ -0,0 +1,20 @@
/**
* Generate .dev/chats.json from the seed prompt index.
* Run: pnpm -C packages/app exec tsx scripts/generate-seed-chats.ts
*/
import { seedPromptsToConversation } from '../src/__tests__/fixtures/seedPrompts'
import { writeFileSync, mkdirSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
const outPath = resolve(dirname(new URL(import.meta.url).pathname), '../.dev/chats.json')
const dir = dirname(outPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const conv = seedPromptsToConversation()
const data = {
conversations: { [conv.id]: conv },
activeConversationId: conv.id,
}
writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf-8')
console.log(`Wrote seed conversation (${conv.messages.length / 2} prompts) to ${outPath}`)
+579
View File
@@ -0,0 +1,579 @@
import { spawn, execSync } from 'child_process'
import { createServer } from 'http'
import { readFileSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { validateDevAuth, handleCorsOptions, checkRateLimit } from './dev-auth.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
// Load .env.local from workspace root (monorepo) or cwd
function loadEnv() {
for (const base of [resolve(__dirname, '../../..'), process.cwd()]) {
const path = resolve(base, '.env.local')
if (existsSync(path)) {
try {
const buf = readFileSync(path, 'utf8')
for (const line of buf.split('\n')) {
const m = line.match(/^([^#=]+)=(.*)$/)
if (m) {
const key = m[1].trim()
const val = m[2].trim().replace(/^["']|["']$/g, '')
if (!process.env[key]) process.env[key] = val
}
}
break
} catch {
/* ignore */
}
}
}
}
loadEnv()
const PORT = 3141
/**
* Resolve the `claude` CLI binary. The old hardcoded `~/.local/bin/claude`
* broke on any machine/install where the CLI lives elsewhere (e.g. an nvm
* Node install's own bin dir) — ENOENT on spawn with no obvious fix short of
* a manual symlink. Prefer a real PATH lookup, matching how a user would
* actually run `claude` themselves; fall back to the historical path for
* anyone relying on it, then to the bare command name so spawn() still gets
* a chance to resolve it via PATH at process-start time even if neither
* check above found it (e.g. PATH changes after this proxy boots).
*/
function resolveClaudeBin(): string {
if (process.env.CLAUDE_BIN && existsSync(process.env.CLAUDE_BIN)) {
return process.env.CLAUDE_BIN
}
try {
const found = execSync('command -v claude', { encoding: 'utf8' }).trim()
if (found) return found
} catch {
/* not resolvable via PATH right now — fall through */
}
const legacy = resolve(process.env.HOME ?? '', '.local/bin/claude')
if (existsSync(legacy)) return legacy
return 'claude'
}
const CLAUDE_BIN = resolveClaudeBin()
const APP_URL = process.env.APP_URL ?? 'http://localhost:5173'
/** API key (sk-ant-api03-...) or OAuth token (sk-ant-oat...) from Max subscription */
function getAnthropicCredential(): string | undefined {
const fromEnv = process.env.ANTHROPIC_API_KEY
?? process.env.VITE_ANTHROPIC_API_KEY
?? process.env.ANTHROPIC_TOKEN
?? process.env.VITE_ANTHROPIC_TOKEN
if (fromEnv) return fromEnv
const home = process.env.HOME ?? ''
const settingsPath = resolve(home, '.claude/settings.json')
if (home && existsSync(settingsPath)) {
try {
const json = JSON.parse(readFileSync(settingsPath, 'utf8'))
const env = json?.env
if (env && typeof env === 'object') {
const t = env.ANTHROPIC_TOKEN ?? env.VITE_ANTHROPIC_TOKEN ?? env.ANTHROPIC_API_KEY ?? env.VITE_ANTHROPIC_API_KEY
if (typeof t === 'string') return t
}
} catch { /* ignore */ }
}
// macOS keychain: Claude Code stores OAuth credentials here
if (process.platform === 'darwin') {
try {
const raw = execSync(
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
{ encoding: 'utf8', timeout: 3000 },
).trim()
const creds = JSON.parse(raw)
const oauthToken = creds?.claudeAiOauth?.accessToken
if (typeof oauthToken === 'string' && oauthToken.startsWith('sk-ant-')) {
console.log('[proxy] Found Claude OAuth token in macOS keychain')
return oauthToken
}
} catch { /* keychain not available or no entry */ }
}
return undefined
}
const ANTHROPIC_CREDENTIAL = getAnthropicCredential()
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? process.env.VITE_OPENROUTER_API_KEY ?? ''
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
const SEARCH_WEB_TOOL = {
name: 'search_web',
description: 'Search the web for current information. Use this when the user asks for news, recent events, facts you are unsure about, or any information that may have changed. Perform one search per distinct topic. Returns titles, URLs, and snippets.',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (e.g. "Bitcoin price March 2025", "latest news AI regulation")',
},
},
required: ['query'],
},
}
function mapModelToApi(model: string): string {
if (model?.includes('opus')) return 'claude-opus-4-20250514'
if (model?.includes('haiku')) return 'claude-haiku-4-5-20251001'
return 'claude-sonnet-4-20250514'
}
async function runSearchWeb(query: string): Promise<string> {
const url = `${APP_URL.replace(/\/$/, '')}/api/web-search?${new URLSearchParams({ q: query })}`
try {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!res.ok) return `Search failed: ${res.status}`
const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] }
const results = data.results ?? []
if (results.length === 0) return 'No results found.'
return results
.map((r, i) => `${i + 1}. [${r.title ?? 'Unknown'}](${r.url ?? ''})${r.content ? `${r.content.slice(0, 150)}${r.content.length > 150 ? '…' : ''}` : ''}`)
.join('\n')
} catch (err) {
return `Search error: ${err instanceof Error ? err.message : String(err)}`
}
}
async function streamViaAnthropicApi(
model: string,
system: string | undefined,
messages: { role: string; content: unknown }[],
res: import('http').ServerResponse,
useTools: boolean,
credential: string,
maxTokens?: number,
): Promise<void> {
const apiModel = mapModelToApi(model)
const apiMessages = messages.map((m) => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: typeof m.content === 'string' ? m.content : m.content,
}))
let clientDisconnected = false
res.on('close', () => { clientDisconnected = true })
const sendDelta = (text: string) => {
if (!clientDisconnected) {
res.write(`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`)
}
}
const sendError = (msg: string) => {
if (!clientDisconnected) {
res.write(`data: ${JSON.stringify({ type: 'error', error: { message: msg } })}\n\n`)
}
}
const buildHeaders = (): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
}
if (isOAuthToken(credential)) {
headers['Authorization'] = `Bearer ${credential}`
headers['anthropic-beta'] = 'oauth-2025-04-20'
} else {
headers['x-api-key'] = credential
}
return headers
}
// Tool use loop (non-streaming — needs to collect tool calls)
if (useTools) {
let turnMessages = [...apiMessages]
const maxToolRounds = 5
let rounds = 0
while (rounds < maxToolRounds) {
rounds++
const body: Record<string, unknown> = {
model: apiModel,
max_tokens: maxTokens ?? 4096,
system,
messages: turnMessages,
tools: [SEARCH_WEB_TOOL],
}
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(120000),
})
if (!apiRes.ok) {
const errBody = await apiRes.text()
sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`)
break
}
const data = (await apiRes.json()) as {
content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[]
stop_reason?: string
}
const content = data.content ?? []
const toolUses = content.filter((b) => b.type === 'tool_use')
const textBlocks = content.filter((b) => b.type === 'text')
if (data.stop_reason === 'tool_use' && toolUses.length > 0) {
const toolResults: { type: string; tool_use_id: string; content: string }[] = []
for (const tu of toolUses) {
if (tu.name === 'search_web' && tu.id && tu.input?.query) {
console.log('[proxy] tool search_web:', tu.input.query)
const result = await runSearchWeb(tu.input.query)
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result })
}
}
turnMessages = [
...turnMessages,
{ role: 'assistant' as const, content },
{ role: 'user' as const, content: toolResults },
]
continue
}
for (const block of textBlocks) {
if (block.text) sendDelta(block.text)
}
break
}
if (!clientDisconnected) {
res.write('data: [DONE]\n\n')
res.end()
}
return
}
// Streaming path (no tools)
const body: Record<string, unknown> = {
model: apiModel,
max_tokens: maxTokens ?? 4096,
stream: true,
messages: apiMessages,
}
if (system) body.system = system
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(120000),
})
if (!apiRes.ok) {
const errBody = await apiRes.text()
sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`)
if (!clientDisconnected) {
res.write('data: [DONE]\n\n')
res.end()
}
return
}
// Pipe SSE stream from Anthropic to client
const reader = apiRes.body?.getReader()
if (!reader) {
sendError('No response body from API')
if (!clientDisconnected) {
res.write('data: [DONE]\n\n')
res.end()
}
return
}
const decoder = new TextDecoder()
try {
while (true) {
if (clientDisconnected) break
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
res.write(chunk)
}
} catch (err) {
if (!clientDisconnected) {
sendError(`Stream error: ${err instanceof Error ? err.message : String(err)}`)
}
} finally {
reader.cancel().catch(() => {})
if (!clientDisconnected) {
res.end()
}
}
}
async function streamOpenRouterProxy(
reqBody: string,
res: import('http').ServerResponse,
): Promise<void> {
if (!OPENROUTER_API_KEY) {
res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:5173' })
res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured on server' }))
return
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': 'http://localhost:5173',
'X-Accel-Buffering': 'no',
})
let clientDisconnected = false
res.on('close', () => { clientDisconnected = true })
try {
const apiRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'HTTP-Referer': APP_URL,
'X-Title': 'AIUI',
},
body: reqBody,
signal: AbortSignal.timeout(120000),
})
if (!apiRes.ok) {
const errBody = await apiRes.text()
if (!clientDisconnected) {
res.write(`data: ${JSON.stringify({ error: `OpenRouter API ${apiRes.status}: ${errBody.slice(0, 200)}` })}\n\n`)
res.write('data: [DONE]\n\n')
res.end()
}
return
}
const reader = apiRes.body?.getReader()
if (!reader) {
if (!clientDisconnected) {
res.write('data: [DONE]\n\n')
res.end()
}
return
}
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done || clientDisconnected) break
const chunk = decoder.decode(value, { stream: true })
res.write(chunk)
}
if (!clientDisconnected) {
res.end()
}
} catch (err) {
console.error('[proxy] OpenRouter error:', err)
if (!clientDisconnected) {
res.write(`data: ${JSON.stringify({ error: `OpenRouter proxy error: ${err instanceof Error ? err.message : String(err)}` })}\n\n`)
res.write('data: [DONE]\n\n')
res.end()
}
}
}
const server = createServer((req, res) => {
if (req.method === 'OPTIONS') {
handleCorsOptions(res)
return
}
if (req.method !== 'POST' || (req.url !== '/v1/messages' && req.url !== '/v1/openrouter')) {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
return
}
if (!validateDevAuth(req, res)) return
if (!checkRateLimit(req, res, true)) return
const MAX_BODY_SIZE = 1 * 1024 * 1024 // 1 MB
let body = ''
let aborted = false
req.on('data', (chunk) => {
body += chunk
if (body.length > MAX_BODY_SIZE) {
aborted = true
res.writeHead(413, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Request body too large (max 1MB)' }))
req.destroy()
}
})
req.on('end', () => {
if (aborted) return
if (req.url === '/v1/openrouter') {
console.log('[proxy] → OpenRouter proxy')
streamOpenRouterProxy(body, res)
return
}
try {
const payload = JSON.parse(body)
const { model, messages, system, webSearch, max_tokens } = payload
// Use client-provided API key if present, otherwise fall back to server credential
const clientKey = req.headers['x-api-key'] as string | undefined
const credential = clientKey || ANTHROPIC_CREDENTIAL
if (credential) {
// Direct API — fast streaming
const useTools = webSearch === true
const apiModel = mapModelToApi(model)
console.log(`[proxy] → Anthropic API ${apiModel}${useTools ? ' [tools]' : ' [stream]'}`)
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
})
streamViaAnthropicApi(model, system, messages ?? [], res, useTools, credential, max_tokens)
return
}
// CLI fallback — no API credential available
const modelFlag = model?.includes('opus') ? 'opus'
: model?.includes('haiku') ? 'haiku'
: 'sonnet'
const history = (messages ?? []) as { role: string; content: string }[]
const userMessages = history.filter((m) => m.role === 'user')
const lastUserMsg = userMessages[userMessages.length - 1]?.content ?? ''
const contextParts: string[] = []
if (system) contextParts.push(system)
const prior = history.slice(0, -1)
if (prior.length > 0) {
contextParts.push(
'Conversation so far:\n' +
prior.map((m) => `${m.role}: ${m.content}`).join('\n')
)
}
const systemPrompt = contextParts.length > 0 ? contextParts.join('\n\n') : undefined
const args = ['-p', '--model', modelFlag]
if (systemPrompt) args.push('--system-prompt', systemPrompt)
if (webSearch === true) {
args.push('--allowed-tools', 'WebSearch', 'WebFetch')
args.push('--permission-mode', 'dontAsk')
}
args.push('--', lastUserMsg)
console.log(`[proxy] → claude CLI --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`)
const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' }
delete procEnv.CLAUDECODE
delete procEnv.CLAUDE_CODE
delete procEnv.ANTHROPIC_CLAUDE_CODE
delete procEnv.CLAUDE_CODE_ENTRYPOINT
if (webSearch === true) {
delete procEnv.DISALLOWED_TOOLS
}
const proc = spawn(CLAUDE_BIN, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: procEnv,
detached: false,
})
proc.stdin.end('')
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
})
let fullOutput = ''
let clientDisconnected = false
proc.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString()
fullOutput += text
if (!clientDisconnected) {
const sseData = {
type: 'content_block_delta',
delta: { type: 'text_delta', text },
}
res.write(`data: ${JSON.stringify(sseData)}\n\n`)
}
})
proc.stderr.on('data', (chunk: Buffer) => {
const msg = chunk.toString().trim()
if (msg) console.error('[proxy] stderr:', msg)
})
proc.on('error', (err: NodeJS.ErrnoException) => {
console.error('[proxy] spawn error:', err)
if (!clientDisconnected) {
const message = err.code === 'ENOENT'
? `Claude CLI not found (tried "${CLAUDE_BIN}"). Install it (npm i -g @anthropic-ai/claude-code), ` +
`make sure it's on PATH, or set CLAUDE_BIN to its full path in .env.local. ` +
`Alternatively, configure ANTHROPIC_API_KEY or ANTHROPIC_TOKEN in .env.local to skip the CLI entirely.`
: `Spawn error: ${err.message}`
const errData = {
type: 'error',
error: { message },
}
res.write(`data: ${JSON.stringify(errData)}\n\n`)
res.write('data: [DONE]\n\n')
res.end()
}
})
proc.on('close', (code, signal) => {
console.log(`[proxy] ← exit code=${code} signal=${signal} output=${fullOutput.length}b`)
if (!clientDisconnected) {
res.write('data: [DONE]\n\n')
res.end()
}
})
res.on('close', () => {
clientDisconnected = true
if (proc.exitCode === null && !proc.killed) {
console.log('[proxy] Client disconnected, killing process')
proc.kill('SIGTERM')
}
})
} catch (err) {
console.error('[proxy] Parse error:', err)
res.writeHead(400, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'http://localhost:5173',
})
res.end(JSON.stringify({ error: String(err) }))
}
})
})
server.listen(PORT, () => {
console.log(`\n Claude proxy → http://localhost:${PORT}`)
console.log(` Binary: ${CLAUDE_BIN}`)
if (ANTHROPIC_CREDENTIAL) {
const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key'
console.log(` Tool use (search_web): enabled (${mode})`)
} else {
console.log(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local`)
}
console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}\n`)
})
+84
View File
@@ -0,0 +1,84 @@
/**
* Shared dev server authentication and rate limiting middleware.
* Validates Bearer token on all /api/* requests.
* Token is auto-generated in scripts/dev.sh and injected via VITE_DEV_API_TOKEN.
*/
import type { IncomingMessage, ServerResponse } from 'http'
/** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */
export function validateDevAuth(req: IncomingMessage, res: ServerResponse): boolean {
const token = process.env.VITE_DEV_API_TOKEN ?? ''
if (!token) return true // No token configured, skip auth
const auth = req.headers.authorization
if (auth === `Bearer ${token}`) return true
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Unauthorized' }))
return false
}
const ALLOWED_ORIGIN = 'http://localhost:5173'
/** Set CORS headers with explicit localhost origin instead of wildcard. */
export function setCorsHeaders(res: ServerResponse): void {
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN)
}
/** Write CORS preflight response. */
export function handleCorsOptions(res: ServerResponse): void {
res.writeHead(204, {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
})
res.end()
}
// ─── Rate Limiting ──────────────────────────────────────────
const WINDOW_MS = 60_000 // 1 minute window
const READ_LIMIT = 60 // 60 requests per minute for reads
const WRITE_LIMIT = 10 // 10 requests per minute for writes
interface RateBucket {
count: number
resetAt: number
}
const rateBuckets = new Map<string, RateBucket>()
// Clean up stale buckets every 5 minutes
setInterval(() => {
const now = Date.now()
for (const [key, bucket] of rateBuckets) {
if (now > bucket.resetAt) rateBuckets.delete(key)
}
}, 5 * 60_000)
function getClientIp(req: IncomingMessage): string {
return req.socket.remoteAddress ?? 'unknown'
}
/**
* Check rate limit for a request. Returns true if allowed, false if rejected (response already sent).
* @param isWrite - Set to true for write operations (POST/PUT/DELETE) which have a lower limit.
*/
export function checkRateLimit(req: IncomingMessage, res: ServerResponse, isWrite = false): boolean {
const ip = getClientIp(req)
const limit = isWrite ? WRITE_LIMIT : READ_LIMIT
const key = `${ip}:${isWrite ? 'w' : 'r'}`
const now = Date.now()
let bucket = rateBuckets.get(key)
if (!bucket || now > bucket.resetAt) {
bucket = { count: 0, resetAt: now + WINDOW_MS }
rateBuckets.set(key, bucket)
}
bucket.count++
if (bucket.count > limit) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' })
res.end(JSON.stringify({ error: 'Too many requests' }))
return false
}
return true
}
+61
View File
@@ -0,0 +1,61 @@
# AIUI nginx config for Archipelago deployment
# Include this in your Archy nginx server block.
#
# Prerequisites:
# - AIUI built and placed in /opt/archipelago/web-ui/aiui/
# - Set $anthropic_api_key in nginx or via env (see below)
#
# Usage in nginx.conf:
# include /opt/archipelago/web-ui/aiui/nginx-archy.conf;
# Serve AIUI SPA
location /aiui/ {
alias /opt/archipelago/web-ui/aiui/;
try_files $uri $uri/ /aiui/index.html;
# Cache static assets aggressively
location ~* /aiui/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Proxy Claude API requests from AIUI iframe
# AIUI fetches /api/claude/v1/messages → proxied to Anthropic API
location /aiui/api/claude/ {
# Rewrite: strip /aiui/api/claude prefix, forward to Anthropic
rewrite ^/aiui/api/claude/(.*)$ /$1 break;
proxy_pass https://api.anthropic.com;
proxy_ssl_server_name on;
proxy_set_header Host api.anthropic.com;
proxy_set_header x-api-key $anthropic_api_key;
proxy_set_header anthropic-version "2023-06-01";
proxy_set_header Content-Type "application/json";
# SSE streaming support
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Security: only allow from same origin (AIUI iframe)
# The iframe has sandbox="allow-same-origin" so requests come from Archy's origin
}
# Proxy OpenRouter API requests (optional, for multi-model support)
location /aiui/api/openrouter/ {
rewrite ^/aiui/api/openrouter/(.*)$ /api/v1/chat/completions break;
proxy_pass https://openrouter.ai;
proxy_ssl_server_name on;
proxy_set_header Host openrouter.ai;
proxy_set_header Content-Type "application/json";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "aiui"
version = "0.1.0"
description = "AIUI Desktop Application"
authors = ["AIUI"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [
"tray-icon",
"global-shortcut",
] }
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[features]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+50
View File
@@ -0,0 +1,50 @@
// Prevents additional console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tauri::{
tray::TrayIconBuilder,
Manager,
};
fn main() {
tauri::Builder::default()
.setup(|app| {
// Create system tray
let _tray = TrayIconBuilder::new()
.tooltip("AIUI")
.on_tray_icon_event(|tray, event| {
use tauri::tray::TrayIconEvent;
if let TrayIconEvent::Click { .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
// Register global shortcut (Cmd+Shift+A / Ctrl+Shift+A)
#[cfg(target_os = "macos")]
let shortcut = "CommandOrControl+Shift+A";
#[cfg(not(target_os = "macos"))]
let shortcut = "Ctrl+Shift+A";
let app_handle = app.handle().clone();
app.global_shortcut().on_shortcut(shortcut, move |_app, _shortcut, _event| {
if let Some(window) = app_handle.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
})?;
Ok(())
})
.plugin(tauri_plugin_updater::Builder::new().build())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@@ -0,0 +1,56 @@
{
"$schema": "https://raw.githubusercontent.com/nicolgit/tauri-docs/v2/tooling/cli/schema.json",
"productName": "AIUI",
"version": "0.1.0",
"identifier": "com.aiui.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "pnpm dev:app",
"beforeBuildCommand": "pnpm build:app"
},
"app": {
"windows": [
{
"title": "AIUI",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"decorations": false,
"transparent": true,
"resizable": true,
"center": true
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https: wss:"
},
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"minimumSystemVersion": "10.15"
}
},
"plugins": {
"updater": {
"active": true,
"endpoints": [],
"pubkey": ""
},
"global-shortcut": {}
}
}
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="h-dvh flex flex-col" :class="currentTheme" :style="rootStyle">
<RouterView />
<ArticleOverlay />
<VideoPlayerOverlay />
<PlayerBar v-if="!isMobile" />
<PassphraseDialog
:visible="showPassphrase"
:is-creating="isCreatingPassphrase"
:error="passphraseError"
@submit="handlePassphraseSubmit"
@skip="showPassphrase = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
import { useArchy } from '@/composables/useArchy'
import { useVisualViewport } from '@/composables/useVisualViewport'
import ArticleOverlay from '@/components/content/ArticleOverlay.vue'
import VideoPlayerOverlay from '@/components/player/VideoPlayerOverlay.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import PassphraseDialog from '@/components/ui/PassphraseDialog.vue'
import {
isCryptoEnabled,
deriveKey,
generateSalt,
setSessionKey,
} from '@/utils/crypto'
const { currentTheme, initTheme, setTheme } = useTheme()
const archy = useArchy()
// Captured before mount (main.ts), independent of the archyBridge handshake —
// forcing dark theme must not wait on any postMessage round trip completing.
const isEmbeddedFlag = !!(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const { viewportHeight, isKeyboardOpen } = useVisualViewport()
function onResize() { windowWidth.value = window.innerWidth }
// On mobile, always bind height to visualViewport so the container
// respects the actual visible area (dvh doesn't reliably exclude
// Safari's bottom toolbar when body is position:fixed)
const rootStyle = computed(() => {
if (isMobile.value && viewportHeight.value > 0) {
return { height: `${viewportHeight.value}px`, overflow: 'hidden' }
}
return {}
})
const showPassphrase = ref(false)
const isCreatingPassphrase = ref(false)
const SALT_KEY = 'aiui-crypto-salt'
const passphraseError = ref('')
async function handlePassphraseSubmit(passphrase: string) {
passphraseError.value = ''
try {
let saltHex = localStorage.getItem(SALT_KEY)
let salt: Uint8Array
if (saltHex) {
salt = new Uint8Array(saltHex.match(/.{2}/g)!.map(b => parseInt(b, 16)))
} else {
salt = await generateSalt()
saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('')
localStorage.setItem(SALT_KEY, saltHex)
}
const key = await deriveKey(passphrase, salt)
setSessionKey(key, salt)
showPassphrase.value = false
} catch (err) {
console.error('[AIUI] Passphrase error:', err)
passphraseError.value = 'Encryption failed — check your passphrase or try skipping'
}
}
onMounted(() => {
initTheme()
// Archy is always dark-themed and AIUI's embedded chat is meant to match it
// exactly — never the browser/OS light-mode default or a stale
// localStorage('aiui-theme', 'light') from a prior standalone visit. This
// must not depend on the archyBridge 'ready'/theme handshake completing
// (that round trip can be slow or fail outright), so it runs unconditionally
// right after initTheme(), overriding whatever it just decided.
if (isEmbeddedFlag) setTheme('dark')
window.addEventListener('resize', onResize)
// Initialize Archy bridge when running embedded in Archipelago
archy.init()
// Skip encryption prompt when embedded in Archy — Archy handles auth
if (isCryptoEnabled() && !archy.isEmbedded.value) {
const hasSalt = !!localStorage.getItem(SALT_KEY)
isCreatingPassphrase.value = !hasSalt
showPassphrase.value = true
}
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
archy.destroy()
})
</script>
@@ -0,0 +1,285 @@
/**
* Archy Integration Tests
*
* Tests archyBridge message handling, useArchy composable,
* and ArchyAppsGrid component behavior.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ═══════════════════════════════════════════════════════════════════
// archyBridge — postMessage handling
// ═══════════════════════════════════════════════════════════════════
describe('archyBridge: postMessage protocol', () => {
let originalParent: Window
let messageHandler: ((event: MessageEvent) => void) | null = null
beforeEach(() => {
originalParent = window.parent
// Mock window.parent to simulate being in an iframe
Object.defineProperty(window, 'parent', {
value: {
postMessage: vi.fn(),
},
writable: true,
configurable: true,
})
// Capture addEventListener calls to grab the message handler
const originalAddEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: any) => {
if (type === 'message') {
messageHandler = handler
}
return originalAddEventListener(type, handler)
})
})
afterEach(() => {
Object.defineProperty(window, 'parent', {
value: originalParent,
writable: true,
configurable: true,
})
vi.restoreAllMocks()
messageHandler = null
})
it('isInArchy returns true when window.parent !== window', async () => {
// Dynamic import to get fresh module state
const { archyBridge } = await import('@/services/archyBridge')
expect(archyBridge.isInArchy()).toBe(true)
})
it('init sends ready message to parent', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
expect(window.parent.postMessage).toHaveBeenCalledWith(
{ type: 'ready' },
window.location.origin,
)
archyBridge.destroy()
})
it('requestContext sends context:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
// Fire and forget — just verify the message shape
archyBridge.requestContext('apps').catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'context:request',
category: 'apps',
}),
window.location.origin,
)
archyBridge.destroy()
})
it('requestAction sends action:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
// Fire and forget
archyBridge.requestAction('open-app', { appId: 'mempool' }).catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'action:request',
action: 'open-app',
params: { appId: 'mempool' },
}),
window.location.origin,
)
archyBridge.destroy()
})
it('requestTheme sends theme:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
archyBridge.requestTheme()
expect(window.parent.postMessage).toHaveBeenCalledWith(
{ type: 'theme:request' },
window.location.origin,
)
archyBridge.destroy()
})
it('onPermissionsUpdate registers and fires callback', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const callback = vi.fn()
const unsubscribe = archyBridge.onPermissionsUpdate(callback)
// Unsubscribe should be a function
expect(typeof unsubscribe).toBe('function')
unsubscribe()
})
it('onThemeUpdate registers and fires callback', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const callback = vi.fn()
const unsubscribe = archyBridge.onThemeUpdate(callback)
expect(typeof unsubscribe).toBe('function')
unsubscribe()
})
it('getPermissions returns empty array initially', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const perms = archyBridge.getPermissions()
expect(Array.isArray(perms)).toBe(true)
})
it('getTheme returns null initially', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const theme = archyBridge.getTheme()
// May be null or have been set by a previous test
expect(theme === null || typeof theme === 'object').toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════
// useArchy — composable behavior
// ═══════════════════════════════════════════════════════════════════
describe('useArchy: composable', () => {
it('exports expected API shape', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
expect(archy).toHaveProperty('isEmbedded')
expect(archy).toHaveProperty('isInitialized')
expect(archy).toHaveProperty('permissions')
expect(archy).toHaveProperty('accentColor')
expect(archy).toHaveProperty('installedApps')
expect(archy).toHaveProperty('systemInfo')
expect(archy).toHaveProperty('networkInfo')
expect(archy).toHaveProperty('walletInfo')
expect(archy).toHaveProperty('fileList')
expect(archy).toHaveProperty('init')
expect(archy).toHaveProperty('destroy')
expect(archy).toHaveProperty('refreshContext')
expect(archy).toHaveProperty('requestAction')
expect(archy).toHaveProperty('buildArchyContext')
})
it('buildArchyContext returns empty string when not initialized', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Not initialized, should return empty
const ctx = archy.buildArchyContext()
expect(typeof ctx).toBe('string')
})
it('requestAction returns failure when not initialized', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
const result = await archy.requestAction('open-app', { appId: 'test' })
expect(result).toEqual({ success: false, error: 'Not initialized' })
})
it('isEmbedded and isInitialized are readonly refs', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Should be refs (have .value)
expect(typeof archy.isEmbedded.value).toBe('boolean')
expect(typeof archy.isInitialized.value).toBe('boolean')
})
})
// ═══════════════════════════════════════════════════════════════════
// useArchy: buildArchyContext output format
// ═══════════════════════════════════════════════════════════════════
describe('useArchy: buildArchyContext format', () => {
it('context string includes wallet info when available', async () => {
// We can't easily set internal state without init, so we test the format expectations
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Context should be a string
const ctx = archy.buildArchyContext()
expect(typeof ctx).toBe('string')
})
})
// ═══════════════════════════════════════════════════════════════════
// archy-apps.ts — data file
// ═══════════════════════════════════════════════════════════════════
describe('archy-apps: data integrity', () => {
it('exports ARCHY_APPS array with all major services', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
expect(Array.isArray(ARCHY_APPS)).toBe(true)
expect(ARCHY_APPS.length).toBeGreaterThanOrEqual(15)
// Verify all required services are present
const ids = ARCHY_APPS.map((a) => a.id)
expect(ids).toContain('bitcoin-core')
expect(ids).toContain('lnd')
expect(ids).toContain('btcpay-server')
expect(ids).toContain('mempool')
expect(ids).toContain('nextcloud')
expect(ids).toContain('immich')
expect(ids).toContain('nostr-rs-relay')
expect(ids).toContain('home-assistant')
expect(ids).toContain('grafana')
expect(ids).toContain('searxng')
expect(ids).toContain('ollama')
expect(ids).toContain('penpot')
expect(ids).toContain('onlyoffice')
expect(ids).toContain('fedimint')
expect(ids).toContain('meshtastic')
})
it('each app has required fields', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
for (const app of ARCHY_APPS) {
expect(app.id).toBeTruthy()
expect(app.name).toBeTruthy()
expect(app.description).toBeTruthy()
expect(app.icon).toBeTruthy()
expect(app.category).toBeTruthy()
expect(app.deepLink).toBeTruthy()
expect(app.deepLink.startsWith('/app/')).toBe(true)
}
})
it('getArchyApp looks up apps by ID', async () => {
const { getArchyApp } = await import('@/data/archy-apps')
const btc = getArchyApp('bitcoin-core')
expect(btc).toBeDefined()
expect(btc!.name).toBe('Bitcoin Core')
const missing = getArchyApp('nonexistent-app')
expect(missing).toBeUndefined()
})
it('app IDs are unique', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
const ids = ARCHY_APPS.map((a) => a.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('deep links follow /app/{id} pattern', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
for (const app of ARCHY_APPS) {
expect(app.deepLink).toBe(`/app/${app.id}`)
}
})
})
// ═══════════════════════════════════════════════════════════════════
// Base-aware API paths
// ═══════════════════════════════════════════════════════════════════
describe('Base-aware API paths', () => {
it('import.meta.env.BASE_URL is defined', () => {
// In Vite test environment, BASE_URL defaults to '/'
expect(typeof import.meta.env.BASE_URL).toBe('string')
expect(import.meta.env.BASE_URL).toBeTruthy()
})
})
@@ -0,0 +1,435 @@
import { describe, it, expect } from 'vitest'
import {
extractAllFilms,
extractAllSongs,
extractAllPodcasts,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractMagazineSections,
extractMagazineHeroImage,
extractBoldDomainLinks,
extractMarkdownLinks,
mergeNewsResults,
stripContentTags,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripBookTags,
stripTVTags,
stripPlaceTags,
extractFilmIds,
extractSongIds,
extractPodcastIds,
} from '@/composables/contentExtraction'
// ─── Films ────────────────────────────────────────────────────────
describe('extractAllFilms', () => {
it('extracts film_ext tags with title, year, director', () => {
const text = 'Check out [[film_ext:Inception|2010|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
expect(films[0].year).toBe(2010)
expect(films[0].director).toBe('Christopher Nolan')
})
it('extracts film:id tags and looks up from library', () => {
const text = 'You should watch [[film:f1]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].id).toBe('f1')
})
it('returns empty array for text with no film tags', () => {
const text = 'Just some text about movies without any tags'
const films = extractAllFilms(text)
expect(films).toHaveLength(0)
})
it('handles multiple films in one message', () => {
const text = '[[film_ext:Inception|2010|Christopher Nolan]] and [[film_ext:Interstellar|2014|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(2)
expect(films[0].title).toBe('Inception')
expect(films[1].title).toBe('Interstellar')
})
it('handles malformed tags gracefully', () => {
const text = '[[film_ext:]] [[film_ext:Incomplete]] [[film:]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(0)
})
it('deduplicates films by title and year', () => {
const text = '[[film_ext:Inception|2010|Nolan]] and again [[film_ext:Inception|2010|Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
})
it('normalizes film IDs with or without f prefix', () => {
const ids1 = extractFilmIds('[[film:1]] [[film:f2]]')
expect(ids1).toEqual(['f1', 'f2'])
})
})
// ─── Songs ────────────────────────────────────────────────────────
describe('extractAllSongs', () => {
it('extracts song_ext tags with title, artist, year', () => {
const text = '[[song_ext:Bohemian Rhapsody|Queen|1975]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Bohemian Rhapsody')
expect(songs[0].artist).toBe('Queen')
expect(songs[0].year).toBe(1975)
})
it('extracts song_ext without year', () => {
const text = '[[song_ext:Paranoid Android|Radiohead]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Paranoid Android')
expect(songs[0].artist).toBe('Radiohead')
expect(songs[0].year).toBeUndefined()
})
it('extracts song:id tags from library', () => {
const text = 'Listen to [[song:s1]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].id).toBe('s1')
})
it('deduplicates songs by title+artist', () => {
const text = '[[song_ext:Creep|Radiohead]] again [[song_ext:Creep|Radiohead]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
})
it('returns empty for text with film tags (not music)', () => {
const text = '[[film_ext:Inception|2010|Nolan]] great movie'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(0)
})
it('filters out non-song content using looksLikeSong', () => {
const text = '[[song_ext:Latest News|Web Search]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(0)
})
it('normalizes song IDs with or without s prefix', () => {
const ids = extractSongIds('[[song:1]] [[song:s2]]')
expect(ids).toEqual(['s1', 's2'])
})
})
// ─── Podcasts ─────────────────────────────────────────────────────
describe('extractAllPodcasts', () => {
it('extracts podcast_ext tags', () => {
const text = '[[podcast_ext:Bitcoin Audible|Guy Swann|2018]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(1)
expect(podcasts[0].title).toBe('Bitcoin Audible')
expect(podcasts[0].host).toBe('Guy Swann')
expect(podcasts[0].year).toBe(2018)
})
it('extracts podcast:id from library', () => {
const text = '[[podcast:p1]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(1)
expect(podcasts[0].id).toBe('p1')
})
it('returns empty for no podcast tags', () => {
const text = 'No podcasts here'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(0)
})
it('filters out non-podcast titles', () => {
const text = '[[podcast_ext:Bitcoin Mailing List|GitHub]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(0)
})
it('normalizes podcast IDs', () => {
const ids = extractPodcastIds('[[podcast:1]] [[podcast:p2]]')
expect(ids).toEqual(['p1', 'p2'])
})
})
// ─── Books ────────────────────────────────────────────────────────
describe('extractAllBooks', () => {
it('extracts book_ext tags with title, author, year', () => {
const text = '[[book_ext:The Bitcoin Standard|Saifedean Ammous|2018]]'
const books = extractAllBooks(text, 'best bitcoin books')
expect(books).toHaveLength(1)
expect(books[0].title).toBe('The Bitcoin Standard')
expect(books[0].author).toBe('Saifedean Ammous')
expect(books[0].year).toBe(2018)
})
it('handles optional year field', () => {
const text = '[[book_ext:Mastering Bitcoin|Andreas Antonopoulos]]'
const books = extractAllBooks(text, 'bitcoin books')
expect(books).toHaveLength(1)
expect(books[0].year).toBeUndefined()
})
it('returns empty when no book tags and not book query', () => {
const text = 'Just talking about tech'
const books = extractAllBooks(text, 'what is javascript')
expect(books).toHaveLength(0)
})
it('extracts multiple books', () => {
const text = '[[book_ext:Book One|Author A|2020]] and [[book_ext:Book Two|Author B|2021]]'
const books = extractAllBooks(text, 'books')
expect(books).toHaveLength(2)
})
})
// ─── TV Series ────────────────────────────────────────────────────
describe('extractAllTVSeries', () => {
it('extracts tv_ext tags', () => {
const text = '[[tv_ext:Breaking Bad|Vince Gilligan|2008]]'
const series = extractAllTVSeries(text, 'best tv shows')
expect(series).toHaveLength(1)
expect(series[0].title).toBe('Breaking Bad')
expect(series[0].creator).toBe('Vince Gilligan')
expect(series[0].year).toBe(2008)
})
it('parses creator field', () => {
const text = '[[tv_ext:The Wire|David Simon|2002]]'
const series = extractAllTVSeries(text, 'tv shows')
expect(series).toHaveLength(1)
expect(series[0].creator).toBe('David Simon')
})
it('returns empty when no TV tags and not TV query', () => {
const text = 'Some random text'
const series = extractAllTVSeries(text, 'cooking recipe')
expect(series).toHaveLength(0)
})
it('handles tv_ext without year', () => {
const text = '[[tv_ext:The Sopranos|David Chase]]'
const series = extractAllTVSeries(text, 'tv')
expect(series).toHaveLength(1)
expect(series[0].year).toBeUndefined()
})
})
// ─── Places ───────────────────────────────────────────────────────
describe('extractAllPlaces', () => {
it('extracts place_ext tags with all fields', () => {
const text = '[[place_ext:Joe\'s Pizza|Pizza|New York|4.5|2|123 Main St]]'
const places = extractAllPlaces(text, 'pizza near me')
expect(places).toHaveLength(1)
expect(places[0].name).toBe("Joe's Pizza")
expect(places[0].cuisine).toBe('Pizza')
expect(places[0].city).toBe('New York')
expect(places[0].rating).toBe(4.5)
expect(places[0].priceLevel).toBe(2)
expect(places[0].address).toBe('123 Main St')
})
it('handles missing optional fields (rating, price)', () => {
const text = '[[place_ext:The Diner|American]]'
const places = extractAllPlaces(text, 'restaurants')
expect(places).toHaveLength(1)
expect(places[0].name).toBe('The Diner')
expect(places[0].cuisine).toBe('American')
expect(places[0].rating).toBeUndefined()
expect(places[0].priceLevel).toBeUndefined()
})
it('returns empty for non-place queries without tags', () => {
const text = 'Nothing about restaurants here'
const places = extractAllPlaces(text, 'what is bitcoin')
expect(places).toHaveLength(0)
})
})
// ─── Images ───────────────────────────────────────────────────────
describe('extractAllImages', () => {
it('extracts markdown image syntax', () => {
const text = '![alt text](https://example.com/image.jpg)'
const images = extractAllImages(text, 'show me images')
expect(images).toHaveLength(1)
expect(images[0].url).toBe('https://example.com/image.jpg')
expect(images[0].alt).toBe('alt text')
})
it('extracts bare image URLs', () => {
const text = 'Check this: https://example.com/photo.png and https://example.com/pic.webp'
const images = extractAllImages(text, 'images')
expect(images).toHaveLength(2)
})
it('returns empty when not an image query and only one image', () => {
const text = 'https://example.com/photo.jpg'
const images = extractAllImages(text, 'what is bitcoin')
expect(images).toHaveLength(0)
})
})
// ─── Magazine Sections ────────────────────────────────────────────
describe('extractMagazineSections', () => {
it('extracts sections from markdown headings', () => {
const text = `## First Section
This is the content of the first section with enough text to pass the minimum length filter.
## Second Section
This is the content of the second section, also with enough detail to be meaningful.`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(2)
const titles = sections.map(s => s.title)
expect(titles).toContain('First Section')
expect(titles).toContain('Second Section')
})
it('captures content between headings', () => {
const text = `## Market Update
Bitcoin surged to new highs today as institutional demand increased significantly and retail sentiment improved.
## Analysis
Analysts believe the trend will continue through the end of the quarter as macro conditions stabilize.`
const sections = extractMagazineSections(text)
const marketSection = sections.find(s => s.title === 'Market Update')
expect(marketSection).toBeDefined()
expect(marketSection!.content).toContain('Bitcoin surged')
})
it('extracts hero images', () => {
const text = 'Some text with ![hero](https://example.com/hero.jpg) embedded'
const hero = extractMagazineHeroImage(text)
expect(hero).toBe('https://example.com/hero.jpg')
})
it('returns undefined for no images', () => {
const hero = extractMagazineHeroImage('No images here')
expect(hero).toBeUndefined()
})
it('extracts sections from numbered lists with bold titles', () => {
const text = `Here are the key developments:
1. **Strong price recovery** — Bitcoin climbed back above $60,000 as market confidence returned.
2. **Institutional adoption grows** — Major banks announced new crypto custody services for their clients.
3. **Regulatory clarity emerges** — New framework provides guidelines for digital asset companies.`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(3)
const titles = sections.map(s => s.title)
expect(titles).toContain('Strong price recovery')
})
it('handles empty or short text', () => {
const sections = extractMagazineSections('')
expect(sections).toHaveLength(0)
})
})
// ─── Tag Stripping ────────────────────────────────────────────────
describe('stripContentTags', () => {
it('removes all tag types from text', () => {
const text = 'Watch [[film:f1]] and listen to [[song:s1]] and read [[book_ext:Title|Author|2020]]'
const stripped = stripContentTags(text)
expect(stripped).not.toContain('[[film:')
expect(stripped).not.toContain('[[song:')
expect(stripped).not.toContain('[[book_ext:')
})
it('preserves non-tag content', () => {
const text = 'Watch this great movie [[film:f1]] and enjoy'
const stripped = stripContentTags(text)
expect(stripped).toContain('Watch this great movie')
expect(stripped).toContain('and enjoy')
})
it('handles adjacent tags', () => {
const text = '[[film:f1]][[song:s1]][[podcast:p1]]'
const stripped = stripContentTags(text)
expect(stripped).toBe('')
})
it('strips all specific tag types individually', () => {
expect(stripFilmTags('[[film:f1]] [[film_ext:Title|2020|Dir]]')).toBe('')
expect(stripSongTags('[[song:s1]] [[song_ext:Title|Artist]]')).toBe('')
expect(stripPodcastTags('[[podcast:p1]] [[podcast_ext:Title|Host]]')).toBe('')
expect(stripBookTags('[[book_ext:Title|Author|2020]]')).toBe('')
expect(stripTVTags('[[tv_ext:Title|Creator|2020]]')).toBe('')
expect(stripPlaceTags('[[place_ext:Name|Cuisine]]')).toBe('')
})
})
// ─── Links Extraction ─────────────────────────────────────────────
describe('extractBoldDomainLinks', () => {
it('extracts **domain.com** patterns with URLs', () => {
const text = '**CoinDesk** (coindesk.com) — the best source'
const links = extractBoldDomainLinks(text)
expect(links).toHaveLength(1)
expect(links[0].title).toBe('CoinDesk')
expect(links[0].url).toBe('https://coindesk.com')
})
it('deduplicates URLs', () => {
const text = '**CoinDesk** (coindesk.com) and **CoinDesk News** (coindesk.com)'
const links = extractBoldDomainLinks(text)
expect(links).toHaveLength(1)
})
})
describe('extractMarkdownLinks', () => {
it('extracts markdown links', () => {
const text = 'Check out [Bitcoin](https://bitcoin.org) for more'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(1)
expect(links[0].title).toBe('Bitcoin')
expect(links[0].url).toBe('https://bitcoin.org')
})
it('handles multiple links', () => {
const text = '[Link 1](https://example.com) and [Link 2](https://example.org/page)'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(2)
})
it('skips invalid URLs', () => {
const text = '[Bad Link](not-a-url)'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(0)
})
})
describe('mergeNewsResults', () => {
it('merges web results with text-extracted results', () => {
const web = [{ title: 'Web A', url: 'https://a.com', content: 'content a' }]
const fromText = [
{ title: 'Text B', url: 'https://b.com', content: undefined },
{ title: 'Text A Dup', url: 'https://a.com', content: undefined },
]
const merged = mergeNewsResults(web, fromText)
expect(merged).toHaveLength(2)
// Web result takes priority for same URL
const aResult = merged.find(r => r.url.includes('a.com'))
expect(aResult!.title).toBe('Web A')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
/**
* AIUI Guide — pre-loaded as a chat conversation so users can
* read it right in the chat window.
*/
export function guideToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772496000000
const guideContent = `# AIUI Guide — Your Node Assistant
AIUI is your AI assistant running directly on your Archipelago node. It can see your installed apps, read your files, check Bitcoin and Lightning status, and help you manage everything — all privately, with no data leaving your node.
---
## Node Awareness
AIUI automatically knows about your node setup. Just ask naturally:
- *"What apps do I have installed?"*
- *"Is my node connected to the network?"*
- *"What version of Archipelago am I running?"*
---
## File Browsing & Reading
AIUI can browse and read text files stored in your Nextcloud. Supported formats: \`.txt\`, \`.md\`, \`.json\`, \`.csv\`, \`.log\`, \`.yaml\`, \`.conf\`, \`.toml\`, \`.xml\`, \`.html\`, \`.css\`, \`.js\`, \`.ts\`, \`.py\`, \`.sh\`, and more.
- *"What files do I have?"*
- *"Read my config.yaml file"*
- *"Show me the contents of notes.md"*
- *"Summarize my todo.txt"*
> Files are read up to 100KB. Larger files are truncated. Binary files (images, videos) cannot be read as text.
---
## Bitcoin Node Status
If you have Bitcoin Core running, AIUI can check sync status, block height, and mempool info in real-time.
- *"How's my Bitcoin node doing?"*
- *"What block height am I on?"*
- *"Is my node fully synced?"*
- *"How many transactions are in the mempool?"*
---
## Lightning Network (LND)
AIUI can query your LND node for channels, peers, balances, and sync status. Private keys and macaroons are never exposed.
- *"What's my Lightning balance?"*
- *"How many channels do I have open?"*
- *"How many peers is my node connected to?"*
- *"Is my Lightning node synced?"*
---
## App Logs
When an app isn't working right, AIUI can pull recent log output to help diagnose issues.
- *"Why is Mempool not working?"*
- *"Show me the Bitcoin Core logs"*
- *"What errors is Nextcloud showing?"*
- *"Show me the last 100 lines of LND logs"*
---
## App Management
AIUI can help you navigate your node, open apps, and install new ones.
- *"Open Mempool"*
- *"Install BTCPay Server"*
- *"Take me to the Settings page"*
- *"What apps are available to install?"*
---
## Chat Features
- **Conversation History** — All chats saved locally. Use the history panel to switch between them.
- **Edit Messages** — Click any sent message to edit and re-send.
- **Branch Conversations** — Fork at any point to explore a different direction.
- **Web Search** — When enabled, AIUI searches the web for current info.
- **Image Support** — Attach images for visual questions.
---
## Privacy & Permissions
AIUI only accesses what you allow. Node data categories (apps, files, wallet, bitcoin, network, system) are permission-gated through the Archy permissions panel. All processing goes through your node's Claude proxy — your conversations and data never touch third-party servers beyond the AI API. Private keys, seeds, and macaroons are never exposed.
---
## Tips
- Be specific — *"Read my bitcoin.conf"* works better than *"show me config files"*
- AIUI remembers context within a conversation, so ask follow-ups
- If something seems wrong with an app, ask AIUI to check the logs first
- You can ask AIUI to explain what a config file does after reading it
- Use the history panel to return to previous conversations at any time
---
Try asking me something! For example: *"What apps do I have installed?"* or *"How's my Bitcoin node?"*`
return {
id: 'aiui-guide',
title: 'AIUI Guide',
messages: [
{
id: 'guide-q',
role: 'user',
content: 'How do I use AIUI?',
timestamp: baseTime,
},
{
id: 'guide-a',
role: 'assistant',
content: guideContent,
timestamp: baseTime + 2000,
},
],
createdAt: baseTime,
updatedAt: baseTime + 2000,
}
}
@@ -0,0 +1,269 @@
/**
* Node Demo Prompts — pre-populated conversations demonstrating
* AIUI's local node search and management capabilities.
*/
export interface NodeDemoPrompt {
id: string
userQuery: string
assistantResponse: string
}
export const nodeDemoPrompts: NodeDemoPrompt[] = [
{
id: 'node-apps',
userQuery: 'What apps do I have installed on my node?',
assistantResponse: `You have the following apps installed on your Archipelago node:
- **Bitcoin Core** (running) — Your Bitcoin full node, fully synced to the blockchain
- **LND** (running) — Lightning Network daemon for fast, cheap payments
- **Mempool** (running) — Visual Bitcoin blockchain explorer and mempool monitor
- **Nextcloud** (running) — Your private cloud storage for files, photos, and documents
- **BTCPay Server** (running) — Self-hosted payment processor for accepting Bitcoin
- **Tor** (running) — Anonymous network routing for privacy
All 6 apps are running normally. Would you like me to check the logs for any of them, or open one up?`,
},
{
id: 'node-bitcoin',
userQuery: "How's my Bitcoin node doing?",
assistantResponse: `Your Bitcoin Core node is looking healthy:
**Bitcoin Node Status**
- **Block Height:** 886,421
- **Sync Progress:** 100.00% (fully synced)
- **Chain:** mainnet
- **Mempool:** 42,318 transactions (68.2 MB)
Your node is fully caught up with the Bitcoin network. The mempool is moderately full — transaction fees are around 12-25 sat/vB for next-block confirmation. This is a typical level of activity.
Would you like me to check your Lightning node status as well?`,
},
{
id: 'node-lightning',
userQuery: "Yes, what's my Lightning balance?",
assistantResponse: `Here's your Lightning Network status:
**Lightning (LND)**
- **Node Alias:** ArchyNode-228
- **Channels:** 5 active channels
- **Peers:** 8 connected peers
- **Channel Balance:** 3,150,000 sats (in channels)
- **On-chain Balance:** 1,245,000 sats
- **Pending Open:** 0 sats
- **Sync:** Fully synced to chain
Your total Lightning capacity is about 3.15M sats across 5 channels, plus 1.24M sats on-chain. Everything looks healthy — all channels are active and your node is fully synced.
Your total Bitcoin holdings visible to the node: **4,395,000 sats** (~$4,395 at current rates).`,
},
{
id: 'node-files',
userQuery: 'What files do I have stored on my node?',
assistantResponse: `You have **47 files** in your Nextcloud storage. Here are the most recent:
- bitcoin.conf
- lnd.conf
- notes.md
- budget-2026.csv
- family-photos/ (folder)
- Documents/ (folder)
- backup-keys.txt
- todo.txt
- project-ideas.md
- recipes/ (folder)
Would you like me to read any of these files? I can open text files like .txt, .md, .json, .csv, .conf, .yaml, and more.`,
},
{
id: 'node-read-file',
userQuery: 'Read my todo.txt',
assistantResponse: `Here's the contents of **todo.txt** (342 bytes):
\`\`\`
Shopping List
- Groceries for the week
- New HDMI cable for the node monitor
- Backup hard drive (4TB minimum)
Node Tasks
- Open more Lightning channels
- Set up Ride The Lightning (RTL)
- Configure automatic channel backups
- Test BTCPay Server checkout flow
Personal
- Call dentist for appointment
- Renew domain registration
- Plan weekend hike
\`\`\`
Looks like you've got some node tasks on your list! I can help you with a few of those — I can open the app marketplace to install Ride The Lightning, or check your current channel backup configuration. Want me to help with any of these?`,
},
{
id: 'node-read-config',
userQuery: 'Show me my bitcoin.conf',
assistantResponse: `Here's your **bitcoin.conf** (512 bytes):
\`\`\`conf
# Bitcoin Core Configuration
server=1
daemon=1
txindex=1
# RPC Settings
rpcuser=archipelago
rpcpassword=archipelago123
rpcallowip=127.0.0.1
rpcbind=127.0.0.1
rpcport=8332
# Network
listen=1
maxconnections=40
maxuploadtarget=5000
# Mempool
maxmempool=300
mempoolexpiry=72
# Performance
dbcache=450
par=2
# ZMQ (for LND)
zmqpubrawblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
\`\`\`
Your config looks solid. A few observations:
- **txindex=1** is enabled, which is needed for Electrum Server and block explorers
- **dbcache=450** MB — reasonable for your hardware
- **ZMQ** is configured for LND to receive real-time block/tx notifications
- **maxuploadtarget=5000** MB/day limits upload bandwidth
Everything is properly set up for running LND and Mempool alongside Bitcoin Core.`,
},
{
id: 'node-app-logs',
userQuery: "Mempool seems slow, can you check what's going on?",
assistantResponse: `Let me check the Mempool logs for you.
Here are the last 50 lines of **Mempool** logs:
\`\`\`
[2026-03-05 14:23:01] Updating mempool stats...
[2026-03-05 14:23:02] Fetched 42318 transactions from Bitcoin Core
[2026-03-05 14:23:03] Block 886421 indexed successfully
[2026-03-05 14:23:05] WebSocket: 12 clients connected
[2026-03-05 14:23:10] Mining pool stats updated
[2026-03-05 14:23:15] Mempool size: 68.2 MB (42318 txs)
[2026-03-05 14:23:20] Fee estimation updated: 12-25 sat/vB
[2026-03-05 14:23:25] WARN: Electrs sync delay detected (2.3s)
[2026-03-05 14:23:30] Updating difficulty adjustment...
[2026-03-05 14:23:35] Hashrate calculation complete
\`\`\`
I see one warning: **Electrs sync delay of 2.3 seconds**. This is the Electrum Server indexer that Mempool uses for address lookups. A small delay like this is usually temporary and happens when:
1. A new block was just found (extra indexing work)
2. The mempool is particularly full (more data to process)
The rest of the logs look normal. Mempool is serving 12 active WebSocket connections. If the slowness persists, you might want to restart the Mempool container. Want me to do that?`,
},
{
id: 'node-open-app',
userQuery: 'Open Mempool so I can check it myself',
assistantResponse: `Opening **Mempool** for you now. The app should appear in a new view — you'll see the real-time mempool visualization, fee estimates, and recent blocks.
If it's still feeling slow, try refreshing the page. The Electrs sync delay I noticed earlier should resolve itself within a few minutes.`,
},
{
id: 'node-install',
userQuery: 'What other apps can I install?',
assistantResponse: `Here are some apps available in the Archipelago marketplace that you haven't installed yet:
**Recommended for your setup:**
- **Ride The Lightning (RTL)** — Visual Lightning node manager with channel management, payments, and routing fee settings. Great companion to LND.
- **ThunderHub** — Another Lightning dashboard with a modern UI. More advanced routing and rebalancing tools.
- **BOS (Balance of Satoshis)** — CLI tool for advanced Lightning channel management and rebalancing.
**Privacy & Communication:**
- **Nostr Relay** — Run your own Nostr relay for censorship-resistant social media
- **SimpleX Chat Server** — Private messaging server
**Other:**
- **Home Assistant** — Smart home automation
- **Gitea** — Self-hosted Git repository (like a private GitHub)
- **Vaultwarden** — Self-hosted password manager (Bitwarden compatible)
Since you mentioned wanting to set up RTL in your todo list, would you like me to install **Ride The Lightning** for you?`,
},
{
id: 'node-summary',
userQuery: 'Give me a full status summary of my node',
assistantResponse: `Here's your complete **Archipelago Node Status**:
---
**System**
- Archipelago v0.1.0
- Network: Connected
- Uptime: 14 days, 6 hours
**Bitcoin Core** — Running
- Block: 886,421 (100% synced)
- Mempool: 42,318 txs (68.2 MB)
- Connections: 38 peers
**Lightning (LND)** — Running
- Alias: ArchyNode-228
- Channels: 5 active, 0 pending
- Peers: 8 connected
- Channel balance: 3,150,000 sats
- On-chain balance: 1,245,000 sats
- Total: 4,395,000 sats
**Apps** — 6/6 running
- Bitcoin Core, LND, Mempool, Nextcloud, BTCPay Server, Tor
**Storage**
- Nextcloud: 47 files
- Blockchain: ~600 GB
---
Everything is running smoothly. No errors or warnings in the last 24 hours. Your node is fully synced, all channels are active, and all apps are healthy.`,
},
]
/**
* Build a single conversation with all node demo prompts as sequential messages.
*/
export function nodeDemoToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772492400000 // Slightly after seed prompts
const messages: { id: string; role: string; content: string; timestamp: number }[] = []
for (let i = 0; i < nodeDemoPrompts.length; i++) {
const prompt = nodeDemoPrompts[i]
const ts = baseTime + i * 120000 // 2 min between each exchange
messages.push(
{ id: `${prompt.id}-q`, role: 'user', content: prompt.userQuery, timestamp: ts },
{ id: `${prompt.id}-a`, role: 'assistant', content: prompt.assistantResponse, timestamp: ts + 5000 },
)
}
return {
id: 'node-demo',
title: 'Exploring My Node',
messages,
createdAt: baseTime,
updatedAt: baseTime + nodeDemoPrompts.length * 120000,
}
}
@@ -0,0 +1,468 @@
/**
* Seed Prompt Index — realistic AI prompt/response pairs covering every content type.
* Used by: .dev/chats.json (seeded conversations), extraction tests, e2e tests.
*
* Each entry represents a user query + AI response that exercises a specific
* content surface (films, songs, books, TV, places, podcasts, images, code,
* recipes, events, news/magazine, mixed content).
*/
export interface SeedPrompt {
id: string
/** Content types this prompt exercises */
types: string[]
userQuery: string
assistantResponse: string
/** Expected extraction counts for validation */
expected: {
films?: number
songs?: number
books?: number
tvSeries?: number
places?: number
podcasts?: number
images?: number
codeBlocks?: number
recipes?: number
events?: number
magazineSections?: number
apps?: number
}
}
export const seedPrompts: SeedPrompt[] = [
// ─── Films ──────────────────────────────────────────────────
{
id: 'seed-films',
types: ['films'],
userQuery: 'What are the best Christopher Nolan films?',
assistantResponse: `Here are Christopher Nolan's finest works:
[[film_ext:Inception|2010|Christopher Nolan]] — A mind-bending heist through layers of dreams. DiCaprio leads an ensemble cast through architecture-defying action sequences and emotionally resonant storytelling.
[[film_ext:The Dark Knight|2008|Christopher Nolan]] — Heath Ledger's Joker elevates this beyond superhero fare into genuine crime thriller territory. The interrogation scene alone is worth the watch.
[[film_ext:Interstellar|2014|Christopher Nolan]] — A father-daughter love story wrapped in hard science fiction. The docking scene and "years of messages" sequence are devastating.
[[film_ext:Memento|2000|Christopher Nolan]] — The reverse-chronology thriller that put Nolan on the map. Guy Pearce hunts his wife's killer while unable to form new memories.
[[film_ext:Oppenheimer|2023|Christopher Nolan]] — Cillian Murphy embodies the father of the atomic bomb in Nolan's most dialogue-heavy and politically charged film.
All feature practical effects, IMAX cinematography, and Hans Zimmer scores (except Memento and Oppenheimer's Göransson score).`,
expected: { films: 5 },
},
// ─── Songs / Music ──────────────────────────────────────────
{
id: 'seed-songs',
types: ['songs'],
userQuery: 'Recommend some ambient electronic music for deep work',
assistantResponse: `Here are excellent ambient electronic tracks for focused work:
[[song_ext:Weightless|Marconi Union|2011]] — Scientifically designed to reduce anxiety. The 8-minute track uses sustained harmonies and a gradually slowing tempo (60 BPM down to 50).
[[song_ext:An Ending (Ascent)|Brian Eno|1983]] — From the Apollo album. Pure synthesizer beauty — ascending chords that feel like floating above Earth. 4 minutes of transcendence.
[[song_ext:Intro|The xx|2009]] — Minimalist guitar loops over electronic beats. Not strictly ambient, but the repetitive structure aids concentration.
[[song_ext:Avril 14th|Aphex Twin|2001]] — A rare gentle piano piece from Richard D. James. Delicate and hypnotic — perfect for reading or writing.
[[song_ext:Music for Airports 1/1|Brian Eno|1978]] — The track that coined "ambient music." Tape loops of piano and voices create an ever-evolving soundscape.
[[song_ext:Treefingers|Radiohead|2000]] — From Kid A. Processed guitar creating ethereal textures. Works beautifully on loop.`,
expected: { songs: 10 },
},
// ─── Books ──────────────────────────────────────────────────
{
id: 'seed-books',
types: ['books'],
userQuery: 'Best books on Bitcoin and monetary history',
assistantResponse: `Essential reading on Bitcoin and the history of money:
[[book_ext:The Bitcoin Standard|Saifedean Ammous|2018]] — The foundational text connecting Austrian economics to Bitcoin. Traces money from primitive shells through gold to fiat, making the case for Bitcoin as the hardest money ever created.
[[book_ext:The Fiat Standard|Saifedean Ammous|2021]] — The sequel examining how fiat currency actually works — debt creation, cantillon effects, and why the system incentivizes time preference distortion.
[[book_ext:Mastering Bitcoin|Andreas Antonopoulos|2017]] — The technical reference. Covers cryptography, transactions, mining, and the protocol in depth. Essential for developers.
[[book_ext:The Price of Tomorrow|Jeff Booth|2020]] — Argues that technology is naturally deflationary, but central banks fight this with money printing. Bitcoin aligns with technology's deflationary nature.
[[book_ext:Broken Money|Lyn Alden|2023]] — A comprehensive monetary history from ancient to modern times, examining why monetary systems break and how Bitcoin fits into the broader evolution of money technology.
**Reading order:** Start with *The Bitcoin Standard* for philosophy, then *Broken Money* for history, then *Mastering Bitcoin* for technical depth.`,
expected: { books: 5 },
},
// ─── TV Series ──────────────────────────────────────────────
{
id: 'seed-tv',
types: ['tvSeries'],
userQuery: 'Best TV shows of the last decade',
assistantResponse: `Outstanding television from the 2010s and 2020s:
[[tv_ext:Breaking Bad|Vince Gilligan|20082013]] — Walter White's transformation from chemistry teacher to drug lord. The gold standard of character-driven TV drama with a flawless final season.
[[tv_ext:Severance|Dan Erickson|2022present]] — A workplace thriller where employees surgically separate work and personal memories. Ben Stiller directs a Kafkaesque masterpiece.
[[tv_ext:Chernobyl|Craig Mazin|2019]] — Five episodes covering the 1986 nuclear disaster. Haunting, meticulously researched, and terrifyingly relevant to institutional failure.
[[tv_ext:The Bear|Christopher Storer|2022present]] — A fine-dining chef returns to run his family's Chicago sandwich shop. Captures kitchen intensity with a season 2 that rivals prestige drama.
[[tv_ext:Better Call Saul|Peter Gould|20152022]] — The Breaking Bad prequel that arguably surpasses it. Bob Odenkirk delivers a career-defining performance across 6 seasons.
[[tv_ext:True Detective|Nic Pizzolatto|2014present]] — Season 1 with McConaughey and Harrelson is peak television. Philosophical detective fiction in the Louisiana bayou.
All available on major streaming platforms. Start with whichever genre appeals most — there's no wrong entry point.`,
expected: { tvSeries: 6 },
},
// ─── Places / Restaurants ───────────────────────────────────
{
id: 'seed-places',
types: ['places'],
userQuery: 'Best restaurants in Austin Texas',
assistantResponse: `Here are Austin's standout restaurants:
[[place_ext:Franklin Barbecue|BBQ|Austin TX|4.8|2|1900 E 11th St]] — The most famous BBQ in Texas. Expect 3-4 hour lines for brisket that's genuinely worth the wait. Get there by 8am on weekends.
[[place_ext:Uchi|Japanese|Austin TX|4.7|3|801 S Lamar Blvd]] — Tyson Cole's Japanese farmhouse dining. The tasting menu is exceptional — the maguro sashimi with goat cheese and cracked pepper is iconic Austin dining.
[[place_ext:Ramen Tatsu-Ya|Ramen|Austin TX|4.6|2|8557 Research Blvd]] — Austin's best ramen. The Underbelly (pork broth, woodear mushroom, nori) has cult status. Cash only, expect a line.
[[place_ext:Suerte|Mexican|Austin TX|4.5|2|1800 E 6th St]] — Modern Mexican from the Uchi team. Smoked beef suadero tacos and duck carnitas are standouts. Great mezcal program.
[[place_ext:Odd Duck|American|Austin TX|4.4|2|1201 S Lamar Blvd]] — Farm-to-table small plates in a converted trailer park. The menu changes daily based on what's fresh. Excellent craft cocktails.
**Pro tip:** Reservations are essential for Uchi and Suerte. Franklin is walk-in only. Visit Odd Duck for late-night bites after 9pm.`,
expected: { places: 5 },
},
// ─── Podcasts ───────────────────────────────────────────────
{
id: 'seed-podcasts',
types: ['podcasts'],
userQuery: 'Best Bitcoin and technology podcasts',
assistantResponse: `Top podcasts for Bitcoin and tech:
[[podcast_ext:What Bitcoin Did|Peter McCormack|2018]] — Twice-weekly interviews covering Bitcoin from beginner to advanced. Peter's honest, accessible style makes complex topics approachable. Essential listening.
[[podcast_ext:Lex Fridman Podcast|Lex Fridman|2018]] — Long-form conversations with scientists, engineers, and thinkers. The Bitcoin episodes with Saylor, Dorsey, and Antonopoulos are must-listens.
[[podcast_ext:Bitcoin Audible|Guy Swann|2016]] — Guy reads and discusses the best Bitcoin articles and papers. Perfect for commutes — turns written content into audio education.
[[podcast_ext:TFTC - Tales from the Crypt|Marty Bent|2018]] — No-nonsense Bitcoin podcast. Marty's "Rabbit Hole Recap" Friday episodes are a great weekly Bitcoin news summary.
[[podcast_ext:Stephan Livera Podcast|Stephan Livera|2018]] — Technical and economic deep dives. Stephan interviews protocol developers, economists, and Lightning Network builders.
Start with **What Bitcoin Did** for breadth, **Stephan Livera** for depth, and **Lex Fridman** for cross-disciplinary thinking.`,
expected: { podcasts: 5 },
},
// ─── Code ───────────────────────────────────────────────────
{
id: 'seed-code',
types: ['code'],
userQuery: 'Show me how to implement a debounce function in TypeScript',
assistantResponse: `Here's a proper TypeScript debounce with generic types and cancel support:
\`\`\`typescript
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number,
): { (...args: Parameters<T>): void; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}
debounced.cancel = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return debounced
}
\`\`\`
Usage example:
\`\`\`typescript
const debouncedSearch = debounce((query: string) => {
console.log('Searching for:', query)
fetch(\`/api/search?q=\${encodeURIComponent(query)}\`)
}, 300)
// In an input handler:
input.addEventListener('input', (e) => {
debouncedSearch((e.target as HTMLInputElement).value)
})
// Cleanup:
debouncedSearch.cancel()
\`\`\`
Key design decisions:
- **Generic types** preserve the original function's parameter types
- **Cancel method** prevents stale calls on component unmount
- **ReturnType<typeof setTimeout>** works in both Node and browser
- The return type explicitly shows the augmented function signature`,
expected: { codeBlocks: 2 },
},
// ─── Images ─────────────────────────────────────────────────
{
id: 'seed-images',
types: ['images'],
userQuery: 'Show me some beautiful landscape photography',
assistantResponse: `Here are stunning landscape photographs:
![Patagonia's Torres del Paine at sunrise](https://images.unsplash.com/photo-1531804055935-76f44d7c3621?w=1200)
The granite towers catching first light create one of Earth's most dramatic mountain scenes.
![Iceland's Kirkjufell mountain with aurora borealis](https://images.unsplash.com/photo-1504829857797-ddff29c27927?w=1200)
The iconic arrowhead-shaped mountain with northern lights dancing above.
![Japanese autumn — Kiyomizu-dera temple in Kyoto](https://images.unsplash.com/photo-1493976040374-85c8e12f0c0e?w=1200)
Crimson maples frame the famous wooden stage during peak koyo season.
These represent three different approaches to landscape photography: dramatic alpine, astrophotography, and cultural landscape. Each uses natural light and careful composition to tell a story about place.`,
expected: { images: 3 },
},
// ─── Mixed: Films + Songs ───────────────────────────────────
{
id: 'seed-mixed-films-songs',
types: ['films', 'songs'],
userQuery: 'What are the best movie soundtracks?',
assistantResponse: `Great question — here are films with legendary soundtracks:
[[film_ext:Drive|2011|Nicolas Winding Refn]] — The synthwave soundtrack defined a genre. Cliff Martinez's score plus curated tracks create an unforgettable atmosphere.
Key tracks:
[[song_ext:Nightcall|Kavinsky|2010]] — The opening drive through LA at night. Pure 80s-inspired synth perfection.
[[song_ext:A Real Hero|College & Electric Youth|2010]] — The emotional core of the film. Dreamy, melancholic electronica.
[[film_ext:Blade Runner 2049|2017|Denis Villeneuve]] — Hans Zimmer and Benjamin Wallfisch created a soundscape that honors Vangelis while pushing into new territory.
[[film_ext:Interstellar|2014|Christopher Nolan]] — Zimmer's organ-driven score is overwhelming in IMAX. "No Time for Caution" during the docking scene is peak cinema.
[[song_ext:Time|Hans Zimmer|2010]] — From Inception. The building layers of strings and brass have become the template for modern trailer music.
[[song_ext:Where Is My Mind?|Pixies|1988]] — The Fight Club ending. Sometimes the perfect needle drop is worth more than any original score.
The best soundtracks don't just accompany the film — they become inseparable from the experience.`,
expected: { films: 3, songs: 2 },
},
// ─── Mixed: Books + Podcasts ────────────────────────────────
{
id: 'seed-mixed-books-podcasts',
types: ['books', 'podcasts'],
userQuery: 'I want to learn about Austrian economics, what should I read and listen to?',
assistantResponse: `Here's a complete Austrian economics learning path:
## Essential Reading
[[book_ext:Economics in One Lesson|Henry Hazlitt|1946]] — The best starting point. Hazlitt distills Bastiat's "seen and unseen" into a masterclass on economic thinking. Short, clear, devastating to interventionist arguments.
[[book_ext:Human Action|Ludwig von Mises|1949]] — The magnum opus of Austrian economics. Dense but comprehensive — covers praxeology, money, banking, and the business cycle. Read after Hazlitt.
[[book_ext:The Road to Serfdom|Friedrich Hayek|1944]] — Hayek's warning about central planning. Written during WWII, prophetic about the dangers of collectivism. Surprisingly readable.
[[book_ext:Man, Economy, and State|Murray Rothbard|1962]] — Rothbard's systematic treatise building from first principles. More accessible than Mises, more rigorous than Hazlitt.
## Podcasts for Ongoing Learning
[[podcast_ext:The Tom Woods Show|Tom Woods|2013]] — Daily episodes on economics, liberty, and history. Woods makes Austrian concepts accessible with humor and clarity.
[[podcast_ext:Human Action Podcast|Jeff Deist|2018]] — Chapter-by-chapter walkthrough of Mises' Human Action. Perfect companion to the book.
[[podcast_ext:EconTalk|Russ Roberts|2006]] — Not strictly Austrian, but Roberts' classical liberal perspective and interview style make this essential economics listening.
**Path:** Hazlitt → Tom Woods podcast → Road to Serfdom → Human Action book + podcast → Rothbard for depth.`,
expected: { books: 4, podcasts: 3 },
},
// ─── Magazine / News ────────────────────────────────────────
{
id: 'seed-magazine',
types: ['magazine'],
userQuery: "What's happening in the Bitcoin ecosystem this week?",
assistantResponse: `## Bitcoin Weekly Roundup
### Lightning Network Milestone
The Lightning Network reached 6,000 BTC in public capacity this week, a new all-time high. Channel count grew 12% month-over-month, with major growth in developing markets.
**amboss.space** — Track Lightning network statistics and node rankings.
---
### Ordinals Debate Heats Up
Bitcoin Core developers are split on whether to filter Ordinals inscriptions at the mempool level. Luke Dashjr's patch was rejected, but the debate continues about block space allocation.
**delvingbitcoin.org** — Developer discussion forum where the technical arguments play out.
---
### Regulatory Update: EU MiCA
The EU's Markets in Crypto-Assets regulation takes effect next month. Self-custody remains legal, but exchanges face new reporting requirements. Most Bitcoin-only companies are prepared.
---
### Mining Difficulty Adjustment
Difficulty increased 4.7% — the third consecutive positive adjustment. Hash rate continues climbing post-halving as miners optimize operations.
**mempool.space** — Real-time mempool and mining statistics.
---
*Sources: Bitcoin Magazine, Nostr, Lightning Labs blog*`,
expected: { magazineSections: 4 },
},
// ─── Recipes ────────────────────────────────────────────────
{
id: 'seed-recipes',
types: ['recipes'],
userQuery: 'How do I make a proper Italian carbonara?',
assistantResponse: `Here's an authentic Roman carbonara — no cream, no shortcuts:
<recipe_ext title="Spaghetti alla Carbonara" servings="4" time="25 min" calories="550">
- 400g spaghetti or rigatoni
- 200g guanciale (cured pork cheek), cut into strips
- 4 large egg yolks + 2 whole eggs
- 100g Pecorino Romano, finely grated
- Freshly ground black pepper
1. Bring a large pot of well-salted water to boil. Cook pasta 1 minute short of al dente.
2. Cut guanciale into 1cm strips. Cook in a cold dry pan over medium heat until fat renders and edges crisp (8-10 min). Remove from heat.
3. Whisk egg yolks, whole eggs, and most of the Pecorino together. Season generously with black pepper.
4. When pasta is ready, reserve 200ml pasta water. Drain and add pasta to the guanciale pan (off heat).
5. Wait 30 seconds for the pan to cool slightly, then pour the egg mixture over the pasta. Toss vigorously — the residual heat cooks the eggs into a creamy sauce. Add pasta water a splash at a time if needed.
6. Serve immediately with remaining Pecorino and more black pepper.
</recipe_ext>
**Keys to success:**
- Guanciale, not pancetta or bacon — the flavor is completely different
- Pan OFF heat before adding eggs — scrambled eggs ≠ carbonara
- Pecorino Romano, not Parmesan — sharper, saltier, authentic
- Save that pasta water — it's liquid gold for emulsifying the sauce`,
expected: { recipes: 1 },
},
// ─── Events ─────────────────────────────────────────────────
{
id: 'seed-events',
types: ['events'],
userQuery: 'What Bitcoin conferences are coming up?',
assistantResponse: `Here are the major upcoming Bitcoin events:
<event_ext title="Bitcoin 2026" date="2026-07-25" location="Nashville, TN" url="https://b.tc/conference">
The largest annual Bitcoin conference. 35,000+ attendees, major announcements, and networking. Past speakers include Saylor, Dorsey, and Bukele.
</event_ext>
<event_ext title="Baltic Honeybadger" date="2026-09-05" location="Riga, Latvia" url="https://baltichoneybadger.com">
The OG Bitcoin-only conference. Technical focus, cypherpunk ethos, and intimate atmosphere. 1,000 attendees who actually build things.
</event_ext>
<event_ext title="Adopting Bitcoin" date="2026-11-15" location="San Salvador, El Salvador" url="https://adoptingbitcoin.org">
Focused on real-world Bitcoin adoption. Lightning Network workshops, merchant onboarding, and stories from the ground in El Salvador.
</event_ext>
<event_ext title="TABConf" date="2026-10-10" location="Atlanta, GA" url="https://tabconf.com">
Developer-focused unconference. Hands-on workshops for building on Bitcoin and Lightning. Small, technical, high signal.
</event_ext>
**Tip:** Baltic Honeybadger sells out fast. TABConf is the best value for developers. Bitcoin 2026 is the big tent event.`,
expected: { events: 4 },
},
// ─── TV + Places combined ───────────────────────────────────
{
id: 'seed-mixed-tv-places',
types: ['tvSeries', 'places'],
userQuery: 'Best food shows and where to eat what they feature',
assistantResponse: `Food TV worth watching, with real-world destinations:
[[tv_ext:The Bear|Christopher Storer|2022present]] — Set in a Chicago Italian beef shop. The kitchen chaos is real — watch it for the cooking AND the anxiety.
Eat the real thing at:
[[place_ext:Mr. Beef|Italian Beef|Chicago IL|4.2|1|666 N Orleans St]] — The actual shop that inspired The Bear. Cash only. Get the Italian beef dipped with hot giardiniera.
[[tv_ext:Chef's Table|David Gelb|2015present]] — Netflix's cinematic exploration of the world's best chefs. Each episode is a visual feast.
Featured restaurant still taking reservations:
[[place_ext:Gaggan Anand|Indian|Bangkok|4.9|4|68/1 Soi Langsuan]] — The molecular Indian cuisine from Chef's Table Season 2. 25-course tasting menu.
[[tv_ext:Anthony Bourdain: Parts Unknown|Zero Point Zero|20132018]] — Bourdain's masterpiece. Food, culture, and humanity. The Japan and Iran episodes are peak television.
Bourdain's favorite:
[[place_ext:Bún Bò Huế Bà Phượng|Vietnamese|Huế Vietnam|4.5|1|Huế City]] — The noodle soup spot from the Vietnam episode. Simple, perfect, $2 a bowl.`,
expected: { tvSeries: 3, places: 3 },
},
// ─── Tag stripping test — all tags in one response ──────────
{
id: 'seed-strip-tags',
types: ['films', 'songs', 'books', 'tvSeries', 'places', 'podcasts'],
userQuery: 'Give me your top pick in every category',
assistantResponse: `Here's one masterpiece per category:
**Film:** [[film_ext:There Will Be Blood|2007|Paul Thomas Anderson]] — Daniel Day-Lewis as an oil prospector consumed by greed. The milkshake scene. The bowling alley. Perfect.
**Song:** [[song_ext:Bohemian Rhapsody|Queen|1975]] — Six minutes that redefined what a pop single could be. Opera section? Guitar solo? Headbanging? Yes to all.
**Book:** [[book_ext:Blood Meridian|Cormac McCarthy|1985]] — The darkest, most beautiful novel in American literature. The Judge is literature's greatest villain.
**TV Show:** [[tv_ext:The Wire|David Simon|20022008]] — Every institution fails. Every character is compromised. Baltimore becomes a lens for all of America.
**Restaurant:** [[place_ext:Jiro Sushi|Sushi|Tokyo|4.9|4|Ginza]] — 20 pieces of sushi. No menu. The greatest craftsman alive serves fish that transcends food.
**Podcast:** [[podcast_ext:Hardcore History|Dan Carlin|2006]] — Multi-hour epics on history's most dramatic moments. "Blueprint for Armageddon" (WWI) is the greatest podcast ever made.
One of each is all you need to start.`,
expected: { films: 1, songs: 1, books: 1, tvSeries: 1, places: 1, podcasts: 1 },
},
]
/**
* Convert seed prompts to the .dev/chats.json conversation format.
*/
/** Build a single conversation with all seed prompts as sequential messages. */
export function seedPromptsToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772488800000
const messages: { id: string; role: string; content: string; timestamp: number }[] = []
for (let i = 0; i < seedPrompts.length; i++) {
const seed = seedPrompts[i]
const ts = baseTime + i * 60000
messages.push(
{ id: `${seed.id}-q`, role: 'user', content: seed.userQuery, timestamp: ts },
{ id: `${seed.id}-a`, role: 'assistant', content: seed.assistantResponse, timestamp: ts + 3000 },
)
}
return {
id: 'seed-all',
title: 'Content Showcase',
messages,
createdAt: baseTime,
updatedAt: baseTime + seedPrompts.length * 60000,
}
}
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock proxy request/response logic without actually spawning processes
describe('Proxy Integration', () => {
describe('SSE streaming format', () => {
it('should produce valid SSE content_block_delta events', () => {
const text = 'Hello, world!'
const sseData = {
type: 'content_block_delta',
delta: { type: 'text_delta', text },
}
const sseString = `data: ${JSON.stringify(sseData)}\n\n`
expect(sseString).toMatch(/^data: /)
expect(sseString).toMatch(/\n\n$/)
const parsed = JSON.parse(sseString.replace('data: ', '').trim())
expect(parsed.type).toBe('content_block_delta')
expect(parsed.delta.type).toBe('text_delta')
expect(parsed.delta.text).toBe(text)
})
it('should produce valid DONE event', () => {
const done = 'data: [DONE]\n\n'
expect(done).toBe('data: [DONE]\n\n')
})
it('should produce valid error events', () => {
const errData = {
type: 'error',
error: { message: 'Anthropic API 401: Unauthorized' },
}
const sseString = `data: ${JSON.stringify(errData)}\n\n`
const parsed = JSON.parse(sseString.replace('data: ', '').trim())
expect(parsed.type).toBe('error')
expect(parsed.error.message).toContain('401')
})
})
describe('Model mapping', () => {
function mapModelToApi(model: string): string {
if (model?.includes('opus')) return 'claude-opus-4-20250514'
if (model?.includes('haiku')) return 'claude-haiku-4-5-20251001'
return 'claude-sonnet-4-20250514'
}
it('should map sonnet model correctly', () => {
expect(mapModelToApi('sonnet')).toBe('claude-sonnet-4-20250514')
expect(mapModelToApi('claude-sonnet')).toBe('claude-sonnet-4-20250514')
})
it('should map opus model correctly', () => {
expect(mapModelToApi('opus')).toBe('claude-opus-4-20250514')
expect(mapModelToApi('claude-opus')).toBe('claude-opus-4-20250514')
})
it('should map haiku model correctly', () => {
expect(mapModelToApi('haiku')).toBe('claude-haiku-4-5-20251001')
})
it('should default to sonnet for unknown models', () => {
expect(mapModelToApi('unknown')).toBe('claude-sonnet-4-20250514')
})
})
describe('Request validation', () => {
it('should reject non-POST requests', () => {
const method = 'GET' as string
const isValid = method === 'POST'
expect(isValid).toBe(false)
})
it('should reject unknown paths', () => {
const validPaths = ['/v1/messages', '/v1/openrouter']
expect(validPaths.includes('/v1/unknown')).toBe(false)
expect(validPaths.includes('/v1/messages')).toBe(true)
expect(validPaths.includes('/v1/openrouter')).toBe(true)
})
it('should parse request body correctly', () => {
const body = JSON.stringify({
model: 'sonnet',
messages: [{ role: 'user', content: 'Hello' }],
system: 'You are helpful.',
webSearch: true,
})
const parsed = JSON.parse(body)
expect(parsed.model).toBe('sonnet')
expect(parsed.messages).toHaveLength(1)
expect(parsed.system).toBe('You are helpful.')
expect(parsed.webSearch).toBe(true)
})
it('should handle malformed JSON', () => {
const badBody = 'not json'
expect(() => JSON.parse(badBody)).toThrow()
})
})
describe('Tool use round-trips', () => {
it('should format search_web tool correctly', () => {
const tool = {
name: 'search_web',
description: 'Search the web for current information.',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
}
expect(tool.name).toBe('search_web')
expect(tool.input_schema.properties.query.type).toBe('string')
})
it('should construct tool_result messages correctly', () => {
const toolResult = {
type: 'tool_result',
tool_use_id: 'toolu_123',
content: '1. [Bitcoin price](https://example.com) — Current price is...',
}
expect(toolResult.type).toBe('tool_result')
expect(toolResult.tool_use_id).toBe('toolu_123')
expect(toolResult.content).toContain('Bitcoin')
})
it('should limit tool rounds to 5', () => {
const maxToolRounds = 5
let rounds = 0
while (rounds < maxToolRounds) {
rounds++
}
expect(rounds).toBe(5)
})
})
describe('Error handling', () => {
it('should handle 401 unauthorized', () => {
const status = 401
const errMsg = `Anthropic API ${status}: Unauthorized`
expect(errMsg).toContain('401')
})
it('should handle 429 rate limit', () => {
const status = 429
const errMsg = `Anthropic API ${status}: Rate limited`
expect(errMsg).toContain('429')
})
it('should handle 500 server error', () => {
const status = 500
const errMsg = `Anthropic API ${status}: Internal Server Error`
expect(errMsg).toContain('500')
})
})
describe('OAuth token detection', () => {
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
it('should detect OAuth tokens', () => {
expect(isOAuthToken('sk-ant-oat-abc123')).toBe(true)
})
it('should not flag API keys as OAuth', () => {
expect(isOAuthToken('sk-ant-api03-abc123')).toBe(false)
})
})
describe('Client disconnect handling', () => {
it('should track client disconnection', () => {
const state = { clientDisconnected: false }
// Simulate disconnect
state.clientDisconnected = true
expect(state.clientDisconnected).toBe(true)
})
it('should not write after disconnect', () => {
const clientDisconnected = true
const writes: string[] = []
const write = (data: string) => {
if (!clientDisconnected) writes.push(data)
}
write('should not appear')
expect(writes).toHaveLength(0)
})
})
})
@@ -0,0 +1,59 @@
/**
* Seed Conversation Regression Tests
*
* Ensures each seed conversation produces at least the expected content types
* when run through the content extraction pipeline. This is a quick smoke test
* that verifies the extraction pipeline hasn't regressed.
*/
import { describe, it, expect } from 'vitest'
import { seedPrompts } from './fixtures/seedPrompts'
import {
extractAllFilms,
extractAllSongs,
extractAllPodcasts,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractCodeBlocks,
extractRecipes,
extractEvents,
stripContentTags,
} from '@/composables/contentExtraction'
describe('Seed conversation regression', () => {
for (const seed of seedPrompts) {
it(`[${seed.id}] produces expected content types for "${seed.userQuery.slice(0, 50)}"`, () => {
const text = seed.assistantResponse
const query = seed.userQuery
// Run all extractors
const films = extractAllFilms(text)
const songs = extractAllSongs(text, query)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, query)
const tvSeries = extractAllTVSeries(text, query)
const places = extractAllPlaces(text, query)
const images = extractAllImages(text, query)
const codeBlocks = extractCodeBlocks(text)
const recipes = extractRecipes(text)
const events = extractEvents(text)
// Validate expected counts match
if (seed.expected.films !== undefined) expect(films.length).toBe(seed.expected.films)
if (seed.expected.songs !== undefined) expect(songs.length).toBe(seed.expected.songs)
if (seed.expected.podcasts !== undefined) expect(podcasts.length).toBe(seed.expected.podcasts)
if (seed.expected.books !== undefined) expect(books.length).toBe(seed.expected.books)
if (seed.expected.tvSeries !== undefined) expect(tvSeries.length).toBe(seed.expected.tvSeries)
if (seed.expected.places !== undefined) expect(places.length).toBe(seed.expected.places)
if (seed.expected.images !== undefined) expect(images.length).toBe(seed.expected.images)
if (seed.expected.codeBlocks !== undefined) expect(codeBlocks.length).toBe(seed.expected.codeBlocks)
if (seed.expected.recipes !== undefined) expect(recipes.length).toBe(seed.expected.recipes)
if (seed.expected.events !== undefined) expect(events.length).toBe(seed.expected.events)
// Verify stripping leaves no tags
const stripped = stripContentTags(text)
expect(stripped).not.toMatch(/\[\[[^\]]+\]\]/)
})
}
})
@@ -0,0 +1,239 @@
/**
* Seed Extraction Tests
*
* Validates that every seed prompt in the prompt index extracts the expected
* content types and counts. These are the gold-standard test cases — if any
* fail, the content surfacing pipeline has regressed.
*
* Run overnight to harden extraction patterns against real-world AI responses.
*/
import { describe, it, expect } from 'vitest'
import { seedPrompts } from './fixtures/seedPrompts'
import {
extractAllFilms,
extractAllSongs,
extractAllPodcasts,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractCodeBlocks,
extractRecipes,
extractEvents,
extractMagazineSections,
stripContentTags,
stripRecipeTags,
stripEventTags,
} from '@/composables/contentExtraction'
// ─── Extraction count validation ─────────────────────────────
describe('Seed prompt extraction', () => {
for (const seed of seedPrompts) {
describe(`[${seed.id}] "${seed.userQuery}"`, () => {
const text = seed.assistantResponse
const query = seed.userQuery
if (seed.expected.films !== undefined) {
it(`extracts ${seed.expected.films} films`, () => {
const films = extractAllFilms(text)
expect(films.length).toBe(seed.expected.films)
for (const f of films) {
expect(f.title).toBeTruthy()
}
})
}
if (seed.expected.songs !== undefined) {
it(`extracts ${seed.expected.songs} songs`, () => {
const songs = extractAllSongs(text, query)
expect(songs.length).toBe(seed.expected.songs)
for (const s of songs) {
expect(s.title).toBeTruthy()
expect(s.artist).toBeTruthy()
}
})
}
if (seed.expected.books !== undefined) {
it(`extracts ${seed.expected.books} books`, () => {
const books = extractAllBooks(text, query)
expect(books.length).toBe(seed.expected.books)
for (const b of books) {
expect(b.title).toBeTruthy()
}
})
}
if (seed.expected.tvSeries !== undefined) {
it(`extracts ${seed.expected.tvSeries} TV series`, () => {
const tv = extractAllTVSeries(text, query)
expect(tv.length).toBe(seed.expected.tvSeries)
for (const t of tv) {
expect(t.title).toBeTruthy()
}
})
}
if (seed.expected.places !== undefined) {
it(`extracts ${seed.expected.places} places`, () => {
const places = extractAllPlaces(text, query)
expect(places.length).toBe(seed.expected.places)
for (const p of places) {
expect(p.name).toBeTruthy()
}
})
}
if (seed.expected.podcasts !== undefined) {
it(`extracts ${seed.expected.podcasts} podcasts`, () => {
const podcasts = extractAllPodcasts(text)
expect(podcasts.length).toBe(seed.expected.podcasts)
for (const p of podcasts) {
expect(p.title).toBeTruthy()
}
})
}
if (seed.expected.images !== undefined) {
it(`extracts ${seed.expected.images} images`, () => {
const images = extractAllImages(text, query)
expect(images.length).toBe(seed.expected.images)
})
}
if (seed.expected.codeBlocks !== undefined) {
it(`extracts ${seed.expected.codeBlocks} code blocks`, () => {
const code = extractCodeBlocks(text)
expect(code.length).toBe(seed.expected.codeBlocks)
for (const c of code) {
expect(c.code.trim()).toBeTruthy()
}
})
}
if (seed.expected.recipes !== undefined) {
it(`extracts ${seed.expected.recipes} recipes`, () => {
const recipes = extractRecipes(text)
expect(recipes.length).toBe(seed.expected.recipes)
for (const r of recipes) {
expect(r.title).toBeTruthy()
expect(r.ingredients.length).toBeGreaterThan(0)
expect(r.steps.length).toBeGreaterThan(0)
}
})
}
if (seed.expected.events !== undefined) {
it(`extracts ${seed.expected.events} events`, () => {
const events = extractEvents(text)
expect(events.length).toBe(seed.expected.events)
for (const e of events) {
expect(e.title).toBeTruthy()
}
})
}
if (seed.expected.magazineSections !== undefined) {
it(`extracts ${seed.expected.magazineSections} magazine sections`, () => {
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(seed.expected.magazineSections!)
})
}
})
}
})
// ─── Tag stripping — no tags leak into displayed content ──────
describe('Tag stripping completeness', () => {
for (const seed of seedPrompts) {
it(`[${seed.id}] stripContentTags removes all bracket tags`, () => {
const cleaned = stripContentTags(seed.assistantResponse)
// No [[...]] bracket tags should remain
const bracketMatches = cleaned.match(/\[\[[^\]]+\]\]/g)
expect(bracketMatches).toBeNull()
})
it(`[${seed.id}] strip functions remove all XML tags`, () => {
let cleaned = stripRecipeTags(stripEventTags(seed.assistantResponse))
cleaned = stripContentTags(cleaned)
// No <..._ext> XML tags should remain
const xmlMatches = cleaned.match(/<\/?(?:recipe|event)_ext[^>]*>/g)
expect(xmlMatches).toBeNull()
})
}
})
// ─── Data integrity — extracted content has required fields ───
describe('Extraction data integrity', () => {
const filmSeed = seedPrompts.find(s => s.id === 'seed-films')!
it('films have title, year, and director', () => {
const films = extractAllFilms(filmSeed.assistantResponse)
for (const f of films) {
expect(f.title).toBeTruthy()
expect(f.year).toBeGreaterThan(1900)
expect(f.director).toBeTruthy()
}
})
const songSeed = seedPrompts.find(s => s.id === 'seed-songs')!
it('songs have title, artist, and year', () => {
const songs = extractAllSongs(songSeed.assistantResponse, songSeed.userQuery)
for (const s of songs) {
expect(s.title).toBeTruthy()
expect(s.artist).toBeTruthy()
}
})
const bookSeed = seedPrompts.find(s => s.id === 'seed-books')!
it('books have title and author', () => {
const books = extractAllBooks(bookSeed.assistantResponse, bookSeed.userQuery)
for (const b of books) {
expect(b.title).toBeTruthy()
expect(b.author).toBeTruthy()
}
})
const tvSeed = seedPrompts.find(s => s.id === 'seed-tv')!
it('TV series have title and creator', () => {
const tv = extractAllTVSeries(tvSeed.assistantResponse, tvSeed.userQuery)
for (const t of tv) {
expect(t.title).toBeTruthy()
expect(t.creator).toBeTruthy()
}
})
const placeSeed = seedPrompts.find(s => s.id === 'seed-places')!
it('places have name, cuisine, and city', () => {
const places = extractAllPlaces(placeSeed.assistantResponse, placeSeed.userQuery)
for (const p of places) {
expect(p.name).toBeTruthy()
expect(p.cuisine).toBeTruthy()
expect(p.city).toBeTruthy()
}
})
const recipeSeed = seedPrompts.find(s => s.id === 'seed-recipes')!
it('recipes have complete data', () => {
const recipes = extractRecipes(recipeSeed.assistantResponse)
expect(recipes.length).toBe(1)
const r = recipes[0]
expect(r.title).toBe('Spaghetti alla Carbonara')
expect(r.servings).toBe('4')
expect(r.time).toBe('25 min')
expect(r.ingredients.length).toBeGreaterThanOrEqual(4)
expect(r.steps.length).toBeGreaterThanOrEqual(5)
})
const eventSeed = seedPrompts.find(s => s.id === 'seed-events')!
it('events have title, date, and location', () => {
const events = extractEvents(eventSeed.assistantResponse)
for (const e of events) {
expect(e.title).toBeTruthy()
expect(e.date).toBeTruthy()
expect(e.location).toBeTruthy()
}
})
})
@@ -0,0 +1,354 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock web search before importing useAI
vi.mock('@/composables/useWebSearch', () => ({
searchWeb: vi.fn().mockResolvedValue([]),
}))
// Mock mock data to avoid importing large fixture files
vi.mock('@/mocks/films', () => ({
mockFilms: [
{ id: 'f1', title: 'Test Film', year: 2020, director: 'Test Dir', genres: ['Drama'], rating: 8, sources: [{ type: 'stream' }] },
],
}))
vi.mock('@/mocks/songs', () => ({
mockSongs: [
{ id: 's1', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', year: 2020, genres: ['Rock'], sources: [{ type: 'jamendo' }] },
],
}))
vi.mock('@/mocks/podcasts', () => ({
mockPodcasts: [
{ id: 'p1', title: 'Test Podcast', host: 'Test Host', year: 2020, genres: ['Tech'], sources: [{ type: 'rss' }] },
],
}))
// Mock idb-storage to avoid IndexedDB access in tests
vi.mock('@/utils/idb-storage', () => ({
saveConversation: vi.fn().mockResolvedValue(undefined),
loadAllConversations: vi.fn().mockResolvedValue(new Map()),
deleteConversation: vi.fn().mockResolvedValue(undefined),
isIDBAvailable: vi.fn().mockReturnValue(false),
}))
import { useAI } from '@/composables/useAI'
import { useChatStore } from '@/stores/chat'
import { searchWeb } from '@/composables/useWebSearch'
const originalFetch = globalThis.fetch
// Helper to create a mock SSE readable stream
function createSSEStream(events: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
let index = 0
return new ReadableStream({
pull(controller) {
if (index < events.length) {
controller.enqueue(encoder.encode(events[index]))
index++
} else {
controller.close()
}
},
})
}
function mockClaudeResponse(events: string[]) {
return {
ok: true,
body: createSSEStream(events),
text: () => Promise.resolve(''),
}
}
describe('useAI', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Reset provider state (module-level ref) back to claude
const { setProvider } = useAI()
setProvider('claude')
// Re-mock searchWeb
vi.mocked(searchWeb).mockResolvedValue([])
// Default fetch mock (catches loadServerChats and any stray calls)
globalThis.fetch = originalFetch
})
describe('provider selection', () => {
it('defaults to claude provider', () => {
const { activeProvider } = useAI()
expect(activeProvider.value).toBe('claude')
})
it('switches provider via setProvider', () => {
const { setProvider, activeProvider, activeModel } = useAI()
setProvider('openrouter')
expect(activeProvider.value).toBe('openrouter')
expect(activeModel.value).toBe('meta-llama/llama-4-maverick')
})
it('switches to mock provider', () => {
const { setProvider, activeProvider, activeModel } = useAI()
setProvider('mock')
expect(activeProvider.value).toBe('mock')
expect(activeModel.value).toBe('echo')
})
it('lists available providers with models', () => {
const { availableProviders } = useAI()
expect(availableProviders.value.length).toBe(3)
const ids = availableProviders.value.map(p => p.id)
expect(ids).toContain('claude')
expect(ids).toContain('openrouter')
expect(ids).toContain('mock')
})
it('sets model directly via setModel', () => {
const { setModel, activeModel } = useAI()
setModel('claude-sonnet-4')
expect(activeModel.value).toBe('claude-sonnet-4')
})
})
describe('context injection', () => {
it('includes film library in system prompt', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('hello')
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
expect(claudeCall).toBeDefined()
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('Test Film')
expect(body.system).toContain("user's film library")
})
it('includes song library in system prompt', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('hello')
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('Test Song')
expect(body.system).toContain("user's song library")
})
it('includes content tag format instructions', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('hello')
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('[[film_ext:')
expect(body.system).toContain('[[song_ext:')
expect(body.system).toContain('[[book_ext:')
})
})
describe('sendMessage', () => {
it('adds user message to store', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"ok"}}\n\n'])
)
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('test message')
const userMsg = chatStore.messages.find(m => m.role === 'user')
expect(userMsg).toBeDefined()
expect(userMsg!.content).toBe('test message')
})
it('creates assistant message placeholder', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n'])
)
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('test')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('hi')
})
it('sets isStreaming to true during stream and false after', async () => {
const streamingStates: boolean[] = []
globalThis.fetch = vi.fn().mockImplementation(() => {
streamingStates.push(useChatStore().isStreaming)
return Promise.resolve(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hello"}}\n\n'])
)
})
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
expect(chatStore.isStreaming).toBe(false)
await sendMessage('hi')
expect(chatStore.isStreaming).toBe(false)
// During the streaming fetch call, isStreaming should have been true
// (earlier non-streaming fetches like refreshWavlakeCatalog may also be captured)
expect(streamingStates.some(s => s === true)).toBe(true)
})
it('handles stream errors gracefully', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error'),
})
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage, setProvider } = useAI()
setProvider('claude') // Ensure claude provider
await sendMessage('test')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('⚠')
expect(chatStore.isStreaming).toBe(false)
})
it('handles connection errors gracefully', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage, setProvider } = useAI()
setProvider('claude') // Ensure claude provider
await sendMessage('test')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('Connection error')
expect(chatStore.isStreaming).toBe(false)
})
it('uses mock provider when set to mock', async () => {
const { sendMessage, setProvider } = useAI()
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
setProvider('mock')
await sendMessage('echo this')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('echo this')
expect(assistantMsg!.content).toContain('echo mode')
})
})
describe('stopGeneration', () => {
it('aborts active stream and sets isStreaming to false', async () => {
// Create a fetch that returns a stream which rejects on abort
globalThis.fetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => {
const signal = init?.signal
return Promise.resolve({
ok: true,
body: new ReadableStream<Uint8Array>({
start(controller) {
// Send first chunk so readSSE enters its loop
const encoder = new TextEncoder()
controller.enqueue(encoder.encode('data: {"type":"content_block_delta","delta":{"text":"h"}}\n\n'))
// When aborted, close the stream
if (signal) {
signal.addEventListener('abort', () => {
try { controller.close() } catch { /* already closed */ }
})
}
},
}),
text: () => Promise.resolve(''),
})
})
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage, stopGeneration, setProvider } = useAI()
setProvider('claude')
const sendPromise = sendMessage('test')
// Wait for fetch to start and first chunk to process
await new Promise(r => setTimeout(r, 50))
expect(chatStore.isStreaming).toBe(true)
stopGeneration()
expect(chatStore.isStreaming).toBe(false)
await sendPromise
})
})
describe('web search integration', () => {
it('injects web results into system prompt when enabled', async () => {
const mockResults = [
{ title: 'Result 1', url: 'https://example.com', content: 'Some content' },
]
vi.mocked(searchWeb).mockResolvedValue(mockResults)
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"answer"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = true
const { sendMessage } = useAI()
await sendMessage('latest bitcoin news')
// Find the Claude API call (not any dev-chats call)
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
expect(claudeCall).toBeDefined()
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('Web search results')
expect(body.system).toContain('Result 1')
expect(body.webSearch).toBe(true)
})
})
})
@@ -0,0 +1,113 @@
import type { AIAdapter, ChatMessage, ChatOptions } from './types'
import { getApiKey } from '@/utils/key-vault'
import { apiFetch } from '@/utils/api-fetch'
const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
export const claudeAdapter: AIAdapter = {
id: 'claude',
name: 'Claude (Max)',
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
models() {
return [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
{ id: 'claude-opus-4', name: 'Claude Opus 4' },
]
},
async chat(messages, options, onToken, onError) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const vaultKey = await getApiKey('claude')
if (vaultKey) {
headers['x-api-key'] = vaultKey
}
const apiMessages = messages
.filter(m => m.role !== 'system')
.map(m => ({ role: m.role, content: m.content }))
const res = await apiFetch(CLAUDE_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: options.model,
system: options.systemPrompt,
messages: apiMessages,
stream: true,
webSearch: options.webSearch ?? false,
}),
signal: options.signal,
})
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`Claude proxy error ${res.status}: ${body}`)
return
}
await readSSE(res, (data) => {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
onToken(parsed.delta.text)
} else if (parsed.type === 'error') {
onError(parsed.error?.message ?? 'Claude stream error')
}
} catch { /* malformed SSE chunk */ }
}, onError, options.signal)
},
}
async function readSSE(
res: Response,
onData: (data: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) {
reader.cancel()
return
}
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]') return
try {
onData(payload)
} catch {
// skip malformed chunks
}
}
}
} catch (err) {
if (signal?.aborted) return
onError(err instanceof Error ? err.message : 'Stream read error')
} finally {
reader.cancel().catch(() => {})
}
}
@@ -0,0 +1,94 @@
import type { AIAdapter, ChatOptions } from './types'
const OLLAMA_BASE = 'http://localhost:11434'
export const ollamaAdapter: AIAdapter = {
id: 'ollama',
name: 'Ollama (Local)',
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
models() {
return [
{ id: 'llama3.2', name: 'Llama 3.2' },
{ id: 'mistral', name: 'Mistral' },
{ id: 'gemma2', name: 'Gemma 2' },
{ id: 'qwen2.5', name: 'Qwen 2.5' },
]
},
async chat(messages, options, onToken, onError) {
const ollamaMessages = messages.map(m => ({
role: m.role,
content: m.content,
}))
if (options.systemPrompt) {
ollamaMessages.unshift({ role: 'system', content: options.systemPrompt })
}
let res: Response
try {
res = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model,
messages: ollamaMessages,
stream: true,
}),
signal: options.signal,
})
} catch {
onError('Cannot connect to Ollama. Is it running on localhost:11434?')
return
}
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`Ollama error ${res.status}: ${body}`)
return
}
// Ollama uses newline-delimited JSON (not SSE)
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (options.signal?.aborted) {
reader.cancel()
return
}
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) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line)
if (parsed.message?.content) {
onToken(parsed.message.content)
}
if (parsed.done) return
} catch {
// skip malformed lines
}
}
}
} finally {
reader.cancel().catch(() => {})
}
},
}
@@ -0,0 +1,119 @@
import type { AIAdapter, ChatOptions } from './types'
import { getApiKey } from '@/utils/key-vault'
import { apiFetch } from '@/utils/api-fetch'
const OPENROUTER_PATH = '/api/openrouter'
export const openrouterAdapter: AIAdapter = {
id: 'openrouter',
name: 'OpenRouter',
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
models() {
return [
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' },
{ id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' },
{ id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' },
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' },
]
},
async chat(messages, options, onToken, onError) {
const orMessages = messages.map(m => ({
role: m.role as 'user' | 'assistant' | 'system',
content: m.content,
}))
// Prepend system prompt as system message
if (options.systemPrompt) {
orMessages.unshift({ role: 'system', content: options.systemPrompt })
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'AIUI',
}
const vaultKey = await getApiKey('openrouter')
if (vaultKey) {
headers['Authorization'] = `Bearer ${vaultKey}`
}
const res = await apiFetch(OPENROUTER_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: options.model,
messages: orMessages,
stream: true,
}),
signal: options.signal,
})
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`OpenRouter error ${res.status}: ${body}`)
return
}
await readSSE(res, (data) => {
if (data === '[DONE]') return
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) onToken(delta)
} catch { /* malformed SSE chunk */ }
}, onError, options.signal)
},
}
async function readSSE(
res: Response,
onData: (data: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) {
reader.cancel()
return
}
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]') return
try {
onData(payload)
} catch {
// skip malformed chunks
}
}
}
} catch (err) {
if (signal?.aborted) return
onError(err instanceof Error ? err.message : 'Stream read error')
} finally {
reader.cancel().catch(() => {})
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Unified AI adapter interface for normalizing different provider APIs.
*/
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface ChatOptions {
model: string
systemPrompt?: string
webSearch?: boolean
signal?: AbortSignal
}
export interface AIAdapter {
id: string
name: string
supportsStreaming: boolean
supportsVision: boolean
supportsTools: boolean
/** List available models for this adapter */
models(): { id: string; name: string }[]
/** Stream chat completion, yielding text tokens */
chat(
messages: ChatMessage[],
options: ChatOptions,
onToken: (text: string) => void,
onError: (err: string) => void,
): Promise<void>
}
@@ -0,0 +1,58 @@
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-white/5 shrink-0">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white/80 truncate">{{ file.name }}</p>
<p class="text-xs text-white/30 truncate mt-0.5">{{ file.path }}</p>
</div>
<div class="flex items-center gap-2 shrink-0 ml-3">
<span class="text-xs text-white/25 font-mono">{{ formatSize(file.size) }}</span>
<button
class="min-w-[32px] min-h-[32px] flex items-center justify-center rounded-md text-white/40 hover:text-white/70 hover:bg-white/10 transition-colors"
aria-label="Close preview"
@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>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto">
<table class="text-xs font-mono leading-relaxed w-full">
<tbody>
<tr v-for="(line, i) in lines" :key="i" class="hover:bg-white/3">
<td class="text-white/20 text-right pr-4 pl-4 py-0 select-none align-top whitespace-nowrap sticky left-0 bg-[#0a0a0a]">{{ i + 1 }}</td>
<td class="text-white/70 pr-4 py-0 whitespace-pre">{{ line }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
file: {
name: string
path: string
content: string
size: number
}
}>()
defineEmits<{ close: [] }>()
const lines = computed(() => props.file.content.split('\n'))
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
@@ -0,0 +1,124 @@
<template>
<div class="space-y-0.5">
<div v-for="item in items" :key="item.path">
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left transition-colors min-h-[32px]"
:class="item.isDirectory
? 'hover:bg-white/5 text-white/70 hover:text-white/80'
: 'hover:bg-white/8 text-white/60 hover:text-white/80'"
@click="handleClick(item)"
>
<!-- Expand/collapse chevron for directories -->
<svg
v-if="item.isDirectory"
class="w-3 h-3 text-white/30 shrink-0 transition-transform duration-150"
:class="{ 'rotate-90': expanded.has(item.path) }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
<span v-else class="w-3 shrink-0" />
<!-- File/folder icon -->
<svg
class="w-4 h-4 shrink-0"
:class="iconColor(item)"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="iconPath(item)"
/>
</svg>
<!-- Name -->
<span class="text-sm truncate">{{ item.name }}</span>
</button>
<!-- Children (recursive) -->
<div
v-if="item.isDirectory && item.children?.length && expanded.has(item.path)"
class="pl-4 ml-[18px] border-l border-white/5"
>
<FileTree
:items="item.children"
@select-file="$emit('selectFile', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface FileEntry {
name: string
path: string
isDirectory: boolean
children?: FileEntry[]
}
defineProps<{ items: FileEntry[] }>()
const expanded = ref<Set<string>>(new Set())
const emit = defineEmits<{ selectFile: [entry: FileEntry] }>()
function handleClick(item: FileEntry) {
if (item.isDirectory) {
const next = new Set(expanded.value)
if (next.has(item.path)) {
next.delete(item.path)
} else {
next.add(item.path)
}
expanded.value = next
} else {
emit('selectFile', item)
}
}
const CODE_EXTS = new Set([
'ts', 'tsx', 'js', 'jsx', 'vue', 'svelte', 'py', 'rs', 'go', 'java',
'c', 'cpp', 'h', 'hpp', 'rb', 'php', 'swift', 'kt', 'cs', 'css',
'scss', 'less', 'html', 'xml', 'yaml', 'yml', 'toml', 'json', 'sh',
'bash', 'zsh', 'sql', 'md', 'mdx',
])
const IMAGE_EXTS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif',
])
function fileExt(name: string): string {
return name.split('.').pop()?.toLowerCase() ?? ''
}
function iconColor(item: FileEntry): string {
if (item.isDirectory) return 'text-yellow-500/70'
const ext = fileExt(item.name)
if (CODE_EXTS.has(ext)) return 'text-blue-400/70'
if (IMAGE_EXTS.has(ext)) return 'text-green-400/70'
return 'text-white/40'
}
const FOLDER_PATH = 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'
const FOLDER_OPEN_PATH = 'M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z'
const CODE_PATH = 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4'
const IMAGE_PATH = 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'
const DOC_PATH = 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'
function iconPath(item: FileEntry): string {
if (item.isDirectory) {
return expanded.value.has(item.path) ? FOLDER_OPEN_PATH : FOLDER_PATH
}
const ext = fileExt(item.name)
if (CODE_EXTS.has(ext)) return CODE_PATH
if (IMAGE_EXTS.has(ext)) return IMAGE_PATH
return DOC_PATH
}
</script>
@@ -0,0 +1,179 @@
<template>
<div class="px-3 md:px-4 pb-1">
<button
class="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60 transition-colors"
@click="isExpanded = !isExpanded"
>
<svg
class="w-3 h-3 transition-transform duration-200"
:class="isExpanded ? 'rotate-90' : ''"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Advanced
</button>
<div v-if="isExpanded && conv" class="mt-2 space-y-3 animate-fade-up-fast">
<!-- Temperature -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Temperature</label>
<span class="text-xs text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Max Tokens -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Max Tokens</label>
<span class="text-xs text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Top P -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Top P</label>
<span class="text-xs text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Stop Sequences (M9.10) -->
<div class="space-y-1">
<label class="text-xs text-white/40">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-xs text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">×</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<!-- Reset -->
<button
class="text-xs text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const isExpanded = ref(false)
const newStopSeq = ref('')
const conv = computed(() => chatStore.activeConversation)
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
// Sync from conversation on switch
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
@@ -0,0 +1,55 @@
<template>
<div
v-if="branches.length > 1"
class="flex items-center justify-center px-4 py-1.5 animate-fade-up-fast"
>
<div class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 flex items-center gap-1.5 px-2 text-xs">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex <= 0"
aria-label="Previous branch"
@click="switchToBranch(currentIndex - 1)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-white/70 select-none whitespace-nowrap">
Branch {{ currentIndex + 1 }} of {{ branches.length }}
</span>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex >= branches.length - 1"
aria-label="Next branch"
@click="switchToBranch(currentIndex + 1)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const branches = computed(() => {
if (!chatStore.activeConversationId) return []
return chatStore.getSiblingBranches(chatStore.activeConversationId)
})
const currentIndex = computed(() => {
return branches.value.findIndex(b => b.isCurrent)
})
function switchToBranch(index: number) {
const branch = branches.value[index]
if (branch) {
chatStore.setActiveConversation(branch.id)
}
}
</script>
@@ -0,0 +1,116 @@
<template>
<div
v-if="parsed"
class="rounded-xl p-3 space-y-2 my-2"
:class="isDark
? 'bg-white/[0.03] border border-[#F7931A]/20'
: 'bg-black/[0.02] border border-[#F7931A]/20'"
>
<!-- Header -->
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-[#F7931A]/10 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" />
<text x="12" y="16" text-anchor="middle" fill="white" font-size="12" font-weight="bold">C</text>
</svg>
</div>
<span
class="text-xs font-semibold"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
Cashu Token
</span>
<span class="ml-auto text-sm font-bold text-[#F7931A]">
{{ formattedAmount }}
</span>
</div>
<!-- Mint info -->
<div
class="text-xs font-mono"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
Mint: {{ displayMint }}
</div>
<!-- Memo -->
<div
v-if="parsed.memo"
class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'"
>
{{ parsed.memo }}
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="isDark
? 'bg-white/5 text-white/60 hover:bg-white/10'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="copyToken"
>
{{ copied ? 'Copied!' : 'Copy Token' }}
</button>
<button
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-[#F7931A]/10 text-[#F7931A] hover:bg-[#F7931A]/20"
@click="openInWallet"
>
Open in Wallet
</button>
</div>
</div>
<!-- Fallback for unparseable tokens -->
<div
v-else
class="rounded-lg p-2 my-1 text-xs font-mono break-all"
:class="isDark ? 'bg-white/5 text-white/40' : 'bg-gray-50 text-gray-500'"
>
{{ truncatedRaw }}
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { parseCashuToken, formatMintUrl, formatCashuAmount } from '@/utils/cashu'
const { isDark } = useTheme()
const props = defineProps<{
token: string
}>()
const copied = ref(false)
const parsed = computed(() => parseCashuToken(props.token))
const formattedAmount = computed(() => {
if (!parsed.value) return ''
return formatCashuAmount(parsed.value.amount, parsed.value.unit)
})
const displayMint = computed(() => {
if (!parsed.value) return ''
return formatMintUrl(parsed.value.mint)
})
const truncatedRaw = computed(() => {
const t = props.token
if (t.length <= 40) return t
return t.slice(0, 20) + '...' + t.slice(-16)
})
async function copyToken() {
await navigator.clipboard.writeText(props.token)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function openInWallet() {
// Use web+cashu: URI scheme for wallet deep-linking
window.open(`web+cashu:${props.token}`, '_blank')
}
</script>
@@ -0,0 +1,412 @@
<template>
<div
ref="headerRef"
class="flex flex-col gap-0 p-3 relative z-[60] shrink-0"
>
<div class="flex items-center justify-between gap-2">
<button
ref="modelPickerTriggerRef"
class="touch-target rounded-xl path-glass-icon shrink-0 transition-colors cursor-pointer text-[#fafafa] hover:text-white"
:title="`AI model: ${modelDisplayName}`"
aria-label="Select AI model"
@click="showModelPicker = !showModelPicker"
>
<span class="text-base"></span>
</button>
<div class="flex items-center gap-2 shrink-0">
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="chatStore.showHistory
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.showHistory ? 'Back to chat' : 'Chat history'"
aria-label="Toggle chat history"
@click="chatStore.toggleHistory()"
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="chatStore.chatCollapsed
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Prompt index'"
aria-label="Toggle prompt index"
@click="chatStore.toggleChatCollapse()"
>
<svg v-if="!chatStore.chatCollapsed" 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="M4 6h16M4 12h10M4 18h16" />
</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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="comparison.isComparing.value
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="comparison.isComparing.value ? 'Comparison mode on' : 'Compare models'"
aria-label="Toggle model comparison"
@click="comparison.toggleComparison()"
>
<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="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Settings"
title="Settings"
@click="$emit('openSettings')"
>
<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="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
ref="menuTriggerRef"
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Conversation menu"
@click="showMenu = !showMenu"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="New conversation"
@click="$emit('newChat')"
>
<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="M12 4v16m8-8H4" />
</svg>
</button>
<button
v-if="showClose"
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Close"
@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>
</div>
<div class="w-full text-left pt-3 pb-1 min-w-0">
<h2 class="text-sm font-semibold truncate text-white/96">{{ title }}</h2>
<div class="flex items-center gap-1.5 mt-0.5">
<p class="text-xs truncate font-mono text-white/40">{{ conversationId }}</p>
<span class="text-xs text-white/20">·</span>
<span class="text-xs truncate text-white/50">{{ modelDisplayName }}</span>
</div>
</div>
<Teleport to="body">
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
<Transition name="picker">
<div
v-if="showModelPicker"
class="fixed z-[9999] path-glass-card p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
:style="modelPickerDropdownStyle"
@click.stop
>
<div v-for="provider in availableProviders" :key="provider.id">
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ provider.name }}
</p>
<div class="space-y-0.5">
<button
v-for="model in provider.models"
:key="model.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2"
:class="model.id === activeModel && provider.id === activeProvider
? 'nav-tab-active'
: 'text-white/60 hover:text-white hover:bg-white/10'"
@click="selectModel(provider.id, model.id)"
>
<span class="flex-1">{{ model.name }}</span>
<span class="flex gap-0.5 shrink-0">
<span
v-if="getModelCaps(model.id).vision"
class="text-xs opacity-60"
title="Supports vision input"
>👁</span>
<span
v-if="getModelCaps(model.id).tools"
class="text-xs opacity-60"
title="Supports tool use"
>🔧</span>
<span
v-if="getModelCaps(model.id).longContext"
class="text-xs opacity-60"
title="Long context window"
>📄</span>
</span>
</button>
</div>
</div>
<!-- Design System -->
<div class="border-t mt-2 pt-2 border-white/10">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2 text-white/60 hover:text-white hover:bg-white/10"
@click="openDesignSystem"
>
<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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Design System
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Conversation menu (export, delete) -->
<Teleport to="body">
<div v-if="showMenu" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showMenu = false" />
<Transition name="picker">
<div
v-if="showMenu"
class="fixed z-[9999] path-glass-card p-2 animate-fade-up-fast shadow-2xl min-w-[160px]"
:style="menuDropdownStyle"
@click.stop
>
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-2 text-white/40">Export</p>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('markdown')"
>
Markdown (.md)
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('json')"
>
JSON
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('text')"
>
Plain text (.txt)
</button>
<div class="border-t border-white/10 mt-1 pt-1">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="triggerImport"
>
Import conversations
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-white/10 transition-all"
@click="handleDelete"
>
Delete conversation
</button>
</div>
</div>
</Transition>
</Teleport>
<input
ref="importInputRef"
type="file"
accept=".json"
class="hidden"
@change="handleImportFile"
/>
<!-- Import status -->
<div
v-if="importStatus"
class="absolute top-full left-3 right-3 mt-1 z-[100] glass px-3 py-2 rounded-lg text-xs animate-fade-up-fast"
:class="importStatus.startsWith('Error') ? 'text-red-400' : 'text-accent'"
>
{{ importStatus }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import'
import { useComparisonMode } from '@/composables/useComparisonMode'
defineProps<{
title: string
conversationId: string
side: 'left' | 'right'
showClose?: boolean
}>()
defineEmits<{
switchSide: []
newChat: []
close: []
openSettings: []
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const comparison = useComparisonMode()
// Model capabilities map
const MODEL_CAPS: Record<string, { vision: boolean; tools: boolean; longContext: boolean }> = {
'claude-haiku-4.5': { vision: true, tools: true, longContext: true },
'claude-sonnet-4': { vision: true, tools: true, longContext: true },
'claude-opus-4': { vision: true, tools: true, longContext: true },
'meta-llama/llama-4-maverick': { vision: true, tools: true, longContext: true },
'qwen/qwen3-235b-a22b-thinking-2507': { vision: false, tools: false, longContext: true },
'mistralai/mistral-small-3.1-24b-instruct:free': { vision: true, tools: true, longContext: false },
'google/gemma-3-27b-it:free': { vision: true, tools: false, longContext: false },
'echo': { vision: false, tools: false, longContext: false },
}
function getModelCaps(modelId: string) {
return MODEL_CAPS[modelId] ?? { vision: false, tools: false, longContext: false }
}
const chatStore = useChatStore()
const showModelPicker = ref(false)
const showMenu = ref(false)
const menuTriggerRef = ref<HTMLElement | null>(null)
const menuDropdownStyle = ref<Record<string, string>>({})
const headerRef = ref<HTMLElement | null>(null)
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
const modelPickerDropdownStyle = ref<Record<string, string>>({})
function updateModelPickerPosition() {
nextTick(() => {
const el = modelPickerTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
modelPickerDropdownStyle.value = {
top: `${r.bottom + 4}px`,
right: 'auto',
left: `${r.left}px`,
width: `${Math.max(r.width + 24, 220)}px`,
}
}
})
}
function updateMenuPosition() {
nextTick(() => {
const el = menuTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
menuDropdownStyle.value = {
top: `${r.bottom + 4}px`,
right: `${window.innerWidth - r.right}px`,
}
}
})
}
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
watch(showMenu, (v) => { if (v) updateMenuPosition() })
const conversationList = computed(() => chatStore.conversationList)
const modelDisplayName = computed(() => {
for (const p of availableProviders.value) {
const m = p.models.find((mm) => mm.id === activeModel.value)
if (m) return m.name
}
return activeModel.value
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
const { enterDesignSystemMode } = useContentPanel()
function openDesignSystem() {
enterDesignSystemMode()
showModelPicker.value = false
}
async function handleExport(format: ExportFormat) {
const conv = chatStore.activeConversation
if (!conv) return
await downloadConversation(conv, format)
showMenu.value = false
}
function handleDelete() {
const id = chatStore.activeConversationId
if (!id) return
chatStore.deleteConversation(id)
showMenu.value = false
}
const importInputRef = ref<HTMLInputElement | null>(null)
const importStatus = ref<string | null>(null)
function triggerImport() {
showMenu.value = false
importInputRef.value?.click()
}
async function handleImportFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
const result = parseImportFile(text)
if (result.error || result.conversations.length === 0) {
importStatus.value = `Error: ${result.error ?? 'No conversations found'}`
} else {
for (const conv of result.conversations) {
chatStore.conversations.set(conv.id, conv)
}
const count = result.conversations.length
importStatus.value = `Imported ${count} conversation${count > 1 ? 's' : ''} (${result.format})`
// Switch to first imported conversation
chatStore.setActiveConversation(result.conversations[0].id)
}
} catch {
importStatus.value = 'Error: Failed to read file'
}
// Clear file input for re-use
input.value = ''
// Auto-hide status after 3 seconds
setTimeout(() => { importStatus.value = null }, 3000)
}
</script>
<style scoped>
.picker-enter-active {
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.picker-leave-active {
transition: all 0.15s ease-in;
}
.picker-enter-from,
.picker-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<button
class="w-full text-left px-3 py-2.5 min-h-[44px] rounded-xl transition-all duration-150 hover:bg-white/5 flex items-center gap-2 text-white/70 mb-2"
@click="$emit('newChat')"
>
<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="M12 4v16m8-8H4" />
</svg>
<span class="text-sm font-medium">New Chat</span>
</button>
<div v-if="conversations.length === 0" class="flex items-center justify-center h-32">
<p class="text-xs text-white/30">No conversations yet</p>
</div>
<button
v-for="conv in conversations"
:key="conv.id"
class="w-full text-left px-3 py-2.5 min-h-[44px] rounded-xl transition-all duration-150"
:class="conv.id === activeId ? 'nav-tab-active' : 'hover:bg-white/5'"
@click="selectConversation(conv.id)"
>
<p class="text-sm leading-snug truncate" :class="conv.id === activeId ? 'text-white' : 'text-white/90'">
{{ conv.title || 'Untitled' }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-xs text-white/30">
{{ formatTime(conv.updatedAt) }}
</span>
<span class="text-xs text-white/20">&middot;</span>
<span class="text-xs text-white/30">
{{ conv.messages.length }} msg{{ conv.messages.length !== 1 ? 's' : '' }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const emit = defineEmits<{
select: [id: string]
newChat: []
}>()
const chatStore = useChatStore()
const conversations = computed(() => chatStore.conversationList)
const activeId = computed(() => chatStore.activeConversationId)
function selectConversation(id: string) {
emit('select', id)
}
function formatTime(ts: number): string {
const now = Date.now()
const diff = now - ts
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' })
}
</script>
@@ -0,0 +1,420 @@
<template>
<div
class="p-3 md:p-4 relative"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@drop.prevent="onDrop"
>
<SearchResults
v-if="isSearchMode"
:results="searchResults"
:is-searching="isSearching"
@select="handleSearchSelect"
/>
<!-- Reply-to quote -->
<div
v-if="replyTo"
class="mb-2 flex items-start gap-2 rounded-xl bg-white/5 border border-white/10 px-3 py-2 animate-fade-up-fast"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-accent/70 font-medium mb-0.5">Replying to</p>
<p class="text-xs text-white/50 truncate">{{ replyTo.excerpt }}</p>
</div>
<button
class="shrink-0 touch-target rounded-md hover:bg-white/10 transition-colors text-white/40 hover:text-white/70"
aria-label="Cancel reply"
@click="$emit('clearReply')"
>
<svg class="w-3 h-3" 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>
<!-- Image thumbnails -->
<div v-if="images.length > 0" class="mb-2 flex gap-2 flex-wrap animate-fade-up-fast">
<div
v-for="(img, i) in images"
:key="i"
class="relative group w-16 h-16 rounded-lg overflow-hidden border border-white/10 bg-white/5"
>
<img
:src="`data:${img.mediaType};base64,${img.data}`"
:alt="`Attached image ${i + 1}`"
class="w-full h-full object-cover"
/>
<button
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove image"
@click="removeImage(i)"
>
<svg class="w-4 h-4 text-white/80" 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>
<span v-if="images.length >= MAX_IMAGES" class="self-center text-xs text-white/30">
Max {{ MAX_IMAGES }} images
</span>
</div>
<!-- Prompt palette -->
<PromptPalette
ref="paletteRef"
:query="paletteQuery"
:is-open="isPaletteMode"
@select="handlePaletteSelect"
@close="closePalette"
/>
<!-- Drag overlay -->
<div
v-if="isDragging"
class="absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-accent/50 bg-accent/5 backdrop-blur-sm pointer-events-none"
>
<p class="text-sm text-accent/80 font-medium">Drop image here</p>
</div>
<div
class="rounded-2xl px-4 py-3 flex items-center gap-2 transition-all duration-300"
:class="[
isCodeMode
? 'bg-accent/15 border border-accent/25 backdrop-blur-xl'
: 'path-glass-bubble',
focused ? (isCodeMode ? 'border-accent/40' : 'border-white/30') : '',
]"
>
<!-- Image attach button -->
<button
v-if="!streaming && images.length < MAX_IMAGES"
class="shrink-0 min-w-[44px] min-h-[44px] -my-2 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Attach image"
@click="openFilePicker"
>
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
<textarea
ref="textareaRef"
v-model="text"
rows="1"
:placeholder="placeholder"
class="flex-1 resize-none bg-transparent text-base outline-none min-h-[24px] max-h-[120px] text-white/90 placeholder:text-white/25"
@keydown="handleKeydown"
@input="autoResize"
@paste="onPaste"
@focus="onInputFocus"
@blur="focused = false"
/>
<button
v-if="streaming"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
aria-label="Stop generation"
@click="$emit('stop')"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
<template v-else>
<!-- Extract/contextualize button shown after paste -->
<button
v-if="hasPasted && canSend"
:disabled="!canSend"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95 text-accent/80"
aria-label="Extract content"
title="Contextualize — extract media without sending to AI"
@click="extract"
>
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
<!-- Send button -->
<button
:disabled="!canSend"
class="shrink-0 rounded-xl px-3 transition-all duration-200"
:class="[
isCodeMode ? 'bg-accent/80 text-white' : 'path-glass-button path-glass-button-sm',
canSend
? 'hover:opacity-80 active:scale-95'
: 'opacity-30 cursor-not-allowed',
]"
:style="isCodeMode ? 'height: 32px' : ''"
aria-label="Send message"
@click="send"
>
<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 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</template>
</div>
<!-- Hidden file input -->
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
class="hidden"
@change="onFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
import SearchResults from '@/components/ui/SearchResults.vue'
import PromptPalette from './PromptPalette.vue'
import type { ImageAttachment } from '@aiui/core/types/message'
const MAX_IMAGES = 4
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const props = withDefaults(
defineProps<{
disabled?: boolean
streaming?: boolean
placeholder?: string
activeTab?: string
replyTo?: { messageId: string; excerpt: string } | null
}>(),
{
disabled: false,
streaming: false,
placeholder: 'Message AIUI...',
activeTab: '',
replyTo: null,
}
)
const isCodeMode = computed(() => props.activeTab === 'code')
const emit = defineEmits<{
send: [text: string, images: ImageAttachment[]]
extract: [text: string]
stop: []
clearReply: []
}>()
const text = ref('')
const focused = ref(false)
const hasPasted = ref(false)
const isDragging = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const images = ref<ImageAttachment[]>([])
const paletteRef = ref<InstanceType<typeof PromptPalette> | null>(null)
// Prompt palette: opens when text starts with "/" and has no space yet
const isPaletteMode = computed(() => {
const t = text.value.trimStart()
return t === '/' || (t.startsWith('/') && !t.includes(' '))
})
const paletteQuery = computed(() => {
if (!isPaletteMode.value) return ''
return text.value.trimStart().slice(1) // strip leading /
})
function handlePaletteSelect(templateText: string) {
// Slash commands — send immediately (except /search which needs query input)
if (templateText.startsWith('/') && !templateText.includes('{{')) {
text.value = templateText
if (templateText.trimEnd() === '/search') {
// /search needs a query — set text and let user type
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
return
}
nextTick(() => send())
return
}
text.value = templateText
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
}
function closePalette() {
// Add a space to exit palette mode
if (text.value.trimStart() === '/') {
text.value = ''
}
}
function onInputFocus() {
focused.value = true
nextTick(() => {
textareaRef.value?.scrollIntoView({ block: 'end', behavior: 'smooth' })
})
}
function handleKeydown(e: KeyboardEvent) {
if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) {
if (e.key === 'ArrowUp') {
e.preventDefault()
paletteRef.value?.navigateUp()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
paletteRef.value?.navigateDown()
return
}
if (e.key === 'Enter') {
e.preventDefault()
paletteRef.value?.selectHighlighted()
return
}
if (e.key === 'Escape') {
e.preventDefault()
closePalette()
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
const canSend = computed(() => (text.value.trim().length > 0 || images.value.length > 0) && !props.disabled)
function send() {
if (!canSend.value) return
emit('send', text.value.trim(), [...images.value])
text.value = ''
images.value = []
hasPasted.value = false
nextTick(autoResize)
}
function extract() {
if (!canSend.value) return
emit('extract', text.value.trim())
text.value = ''
hasPasted.value = false
nextTick(autoResize)
}
function onPaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) {
hasPasted.value = true
return
}
let hasImage = false
for (const item of items) {
if (ACCEPTED_TYPES.includes(item.type)) {
hasImage = true
const file = item.getAsFile()
if (file) addImageFile(file)
}
}
if (!hasImage) {
hasPasted.value = true
}
}
function onDragOver(e: DragEvent) {
if (e.dataTransfer?.types.includes('Files')) {
isDragging.value = true
}
}
function onDragLeave() {
isDragging.value = false
}
function onDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (!files) return
for (const file of files) {
if (ACCEPTED_TYPES.includes(file.type)) {
addImageFile(file)
}
}
}
function openFilePicker() {
fileInputRef.value?.click()
}
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
if (!files) return
for (const file of files) {
if (ACCEPTED_TYPES.includes(file.type)) {
addImageFile(file)
}
}
// Reset input so same file can be re-selected
input.value = ''
}
function addImageFile(file: File) {
if (images.value.length >= MAX_IMAGES) return
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Strip the data:...;base64, prefix
const base64 = result.split(',')[1]
if (base64) {
images.value.push({ data: base64, mediaType: file.type })
}
}
reader.readAsDataURL(file)
}
function removeImage(index: number) {
images.value.splice(index, 1)
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
// Federated search via /search command
const { results: searchResults, isSearching, search: doSearch, clear: clearSearch } = useFederatedSearch()
const isSearchMode = computed(() => text.value.trimStart().startsWith('/search '))
watch(text, (val) => {
if (isSearchMode.value) {
const searchQuery = val.trimStart().replace(/^\/search\s+/, '')
doSearch(searchQuery)
} else {
clearSearch()
}
})
function handleSearchSelect(result: SearchResult) {
// Insert content reference tag based on type
const tags: Record<string, string> = {
film: `[[film:${result.id}]]`,
song: `[[song:${result.id}]]`,
podcast: `[[podcast:${result.id}]]`,
}
text.value = tags[result.type] ?? result.title
clearSearch()
nextTick(() => textareaRef.value?.focus())
}
</script>
@@ -0,0 +1,759 @@
<template>
<div
class="group/msg flex animate-fade-up-fast"
:class="isUser ? 'justify-end' : 'justify-start'"
:style="{ animationDelay: `${index * 30}ms` }"
@contextmenu.prevent="openContextMenu"
>
<div class="relative max-w-[85%] md:max-w-[75%]">
<!-- Action buttons (hover on desktop, always visible on touch) -->
<div
v-if="!isEditing"
class="absolute -top-4 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 z-10"
:class="isUser ? 'right-1' : 'left-1'"
>
<div class="flex items-center gap-2 px-1 py-0.5 rounded-lg bg-black/60 backdrop-blur-md border border-white/10 shadow-lg">
<button
v-if="isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Edit message"
aria-label="Edit message"
@click.stop="startEditing"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Regenerate response"
aria-label="Regenerate response"
@click.stop="$emit('regenerate')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
</button>
<button
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Reply"
aria-label="Reply"
@click.stop="$emit('reply', message.id, message.content)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Branch from here"
aria-label="Branch from here"
@click.stop="$emit('branch', message.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5z" clip-rule="evenodd" /></svg>
</button>
<div v-if="!isUser" class="w-px h-4 bg-white/10 mx-0.5" />
<button
v-if="!isUser"
class="touch-target rounded-md transition-colors"
:class="message.feedback === 'up' ? 'text-green-400' : 'text-white/50 hover:text-green-400/80 hover:bg-white/10'"
title="Good response"
aria-label="Good response"
@click.stop="toggleFeedback('up')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md transition-colors"
:class="message.feedback === 'down' ? 'text-red-400' : 'text-white/50 hover:text-red-400/80 hover:bg-white/10'"
title="Poor response"
aria-label="Poor response"
@click.stop="toggleFeedback('down')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.106-1.79l-.05-.025A4 4 0 0011.057 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" /></svg>
</button>
</div>
</div>
<div
class="rounded-2xl px-4 py-3 transition-all duration-300"
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
@click="handleBubbleClick"
>
<!-- Edit mode -->
<div v-if="isEditing" class="space-y-2" @click.stop>
<textarea
ref="editTextareaRef"
v-model="editContent"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="3"
@keydown.enter.exact="submitEdit"
@keydown.escape="cancelEdit"
/>
<div class="flex gap-2 justify-end">
<button
class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 text-xs text-white/60 hover:text-white/90 px-3"
@click="cancelEdit"
>Cancel</button>
<button
class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 text-xs bg-accent/20 text-accent hover:bg-accent/30 px-3"
@click="submitEdit"
>Save & Resend</button>
</div>
</div>
<!-- Normal display -->
<template v-else>
<!-- Attached images -->
<div v-if="message.images && message.images.length > 0" class="flex gap-2 flex-wrap mb-2">
<img
v-for="(img, i) in message.images"
:key="i"
:src="`data:${img.mediaType};base64,${img.data}`"
:alt="`Attached image ${i + 1}`"
class="rounded-lg max-w-[200px] max-h-[200px] object-cover border border-white/10"
/>
</div>
<div
v-if="!isUser"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="renderedMarkdown"
/>
<p
v-else-if="message.content"
class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90"
>{{ displayText }}</p>
</template>
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
<FilmCard
v-for="film in inlineFilms"
:key="film.id"
:film="film"
@select="handleFilmSelect"
/>
</div>
<div v-if="inlineBooks.length > 0" class="mt-3 space-y-1" @click.stop>
<BookCard
v-for="book in inlineBooks"
:key="book.id"
:book="book"
@select="handleBookSelect"
/>
</div>
<div v-if="inlineTVSeries.length > 0" class="mt-3 space-y-1" @click.stop>
<TVSeriesCard
v-for="series in inlineTVSeries"
:key="series.id"
:series="series"
@select="handleTVSeriesSelect"
/>
</div>
<div v-if="inlineSongs.length > 0" class="mt-3 space-y-1" @click.stop>
<SongCard
v-for="song in inlineSongs"
:key="song.id"
:song="song"
@select="handleSongSelect"
/>
</div>
<div v-if="inlinePodcasts.length > 0" class="mt-3 space-y-1" @click.stop>
<PodcastCard
v-for="podcast in inlinePodcasts"
:key="podcast.id"
:podcast="podcast"
@select="handlePodcastSelect"
/>
</div>
<div v-if="inlinePlaces.length > 0" class="mt-3 space-y-1" @click.stop>
<PlaceCard
v-for="place in inlinePlaces"
:key="place.id"
:place="place"
@select="handlePlaceSelect"
/>
</div>
<div v-if="nostrUris.length > 0" class="mt-3 space-y-1.5" @click.stop>
<NostrEmbed
v-for="uri in nostrUris"
:key="uri"
:uri="uri"
/>
</div>
<div v-if="cashuTokens.length > 0" class="mt-3 space-y-1.5" @click.stop>
<CashuToken
v-for="token in cashuTokens"
:key="token"
:token="token"
/>
</div>
<div v-if="inlineRecipes.length > 0" class="mt-3 space-y-2" @click.stop>
<RecipeCard
v-for="(recipe, i) in inlineRecipes"
:key="`recipe-${i}`"
:recipe="recipe"
/>
</div>
<!-- Timeline view for 3+ events -->
<div v-if="inlineEvents.length >= 3" class="mt-3" @click.stop>
<TimelineRenderer :events="inlineEvents" />
</div>
<!-- Individual event cards for 1-2 events -->
<div v-else-if="inlineEvents.length > 0" class="mt-3 space-y-2" @click.stop>
<EventCard
v-for="(event, i) in inlineEvents"
:key="`event-${i}`"
:event="event"
/>
</div>
<div v-if="inlineTables.length > 0" class="mt-3 space-y-2" @click.stop>
<InteractiveTable
v-for="(table, i) in inlineTables"
:key="`table-${i}`"
:headers="table.headers"
:rows="table.rows"
/>
</div>
<div v-if="runnableCodeBlocks.length > 0" class="mt-3 space-y-2" @click.stop>
<CodeRunner
v-for="(block, i) in runnableCodeBlocks"
:key="`code-${i}`"
:code="block.code"
:language="block.language"
/>
</div>
<div v-if="bitcoinAddresses.length > 0" class="mt-3 space-y-2" @click.stop>
<BitcoinAddressCard
v-for="(addr, i) in bitcoinAddresses"
:key="`btc-${i}`"
:address="addr.address"
/>
</div>
<div v-if="bolt11Invoices.length > 0" class="mt-3 space-y-2" @click.stop>
<Bolt11InvoiceCard
v-for="(inv, i) in bolt11Invoices"
:key="`bolt11-${i}`"
:invoice="inv.invoice"
/>
</div>
<div v-if="bolt12Offers.length > 0" class="mt-3 space-y-2" @click.stop>
<Bolt12OfferCard
v-for="(offer, i) in bolt12Offers"
:key="`bolt12-${i}`"
:offer="offer.offer"
/>
</div>
<div v-if="detectedTxIds.length > 0" class="mt-3 space-y-2" @click.stop>
<MempoolTxCard
v-for="(tx, i) in detectedTxIds"
:key="`tx-${i}`"
:txid="tx.txid"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
:key="i"
:article="link"
@select-article="handleArticleSelect"
/>
</div>
<div v-if="inlineWebsitesLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineWebsitesLinks"
:key="`web-${i}`"
:article="link"
@select-article="handleWebsiteSelect"
/>
</div>
<div v-if="!isEditing" class="flex items-center gap-2 mt-1.5">
<span class="text-xs select-none text-white/30">{{ formattedTime }}</span>
<span v-if="message.editedAt" class="text-xs text-white/25 select-none">(edited)</span>
<span v-if="showTokenCount" class="text-xs text-white/20 select-none" :title="`~${estimatedTokens} tokens`">{{ tokenLabel }}</span>
<span v-if="message.feedback" class="text-xs select-none">{{ message.feedback === 'up' ? '👍' : '👎' }}</span>
<button
v-if="inlineFilms.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineFilms.length }} films
</button>
<button
v-else-if="inlineBooks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineBooks.length }} books
</button>
<button
v-else-if="inlineTVSeries.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineTVSeries.length }} series
</button>
<button
v-else-if="inlineImages.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineImages.length }} images
</button>
<button
v-else-if="inlinePlaces.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePlaces.length }} places
</button>
<button
v-if="inlinePlaces.length > 0 && inlinePlaces.some(p => p.lat != null)"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openMapView(inlinePlaces)"
>
View on map
</button>
<button
v-else-if="inlineSongs.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineSongs.length }} songs
</button>
<button
v-else-if="inlinePodcasts.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePodcasts.length }} podcasts
</button>
<button
v-else-if="inlineNewsLinks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineNewsLinks.length }} articles
</button>
<button
v-else-if="inlineWebsitesLinks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineWebsitesLinks.length }} websites
</button>
<button
v-else-if="inlineMagazineSections.length > 0"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View brief
</button>
<button
v-if="isLongForm"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openLongFormArticle(message.content)"
>
Read as article
</button>
</div>
</div>
</div>
<!-- Context menu -->
<ContextMenu ref="contextMenuRef">
<ContextMenuItem v-if="isUser" @click="startEditingFromMenu">Edit</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitRegenerate">Regenerate</ContextMenuItem>
<ContextMenuItem @click="emitReply">Reply</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitBranch">Branch from here</ContextMenuItem>
<ContextMenuItem @click="copyContent">Copy</ContextMenuItem>
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { hasMath, renderMathInHtml } from '@/composables/useMathRenderer'
import { hasMermaid, renderMermaidBlocks } from '@/composables/useMermaidRenderer'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenuItem from '@/components/ui/ContextMenuItem.vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
import { useCodeContext } from '@/composables/useCodeContext'
import FilmCard from '@/components/content/FilmCard.vue'
import BookCard from '@/components/content/BookCard.vue'
import TVSeriesCard from '@/components/content/TVSeriesCard.vue'
import SongCard from '@/components/content/SongCard.vue'
import PodcastCard from '@/components/content/PodcastCard.vue'
import PlaceCard from '@/components/content/PlaceCard.vue'
import NewsCard from '@/components/content/NewsCard.vue'
import NostrEmbed from '@/components/chat/NostrEmbed.vue'
import CashuToken from '@/components/chat/CashuToken.vue'
import { extractCashuTokens } from '@/utils/cashu'
import { extractRecipes, extractEvents } from '@/composables/contentExtraction'
import RecipeCard from '@/components/renderers/RecipeCard.vue'
import EventCard from '@/components/renderers/EventCard.vue'
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
import TimelineRenderer from '@/components/renderers/TimelineRenderer.vue'
import CodeRunner from '@/components/renderers/CodeRunner.vue'
import BitcoinAddressCard from '@/components/renderers/BitcoinAddressCard.vue'
import Bolt11InvoiceCard from '@/components/renderers/Bolt11InvoiceCard.vue'
import Bolt12OfferCard from '@/components/renderers/Bolt12OfferCard.vue'
import MempoolTxCard from '@/components/renderers/MempoolTxCard.vue'
import { detectBitcoinAddresses, detectBolt11Invoices, detectBolt12Offers, detectTxIds } from '@/composables/useBitcoinDetector'
import { extractTables } from '@/composables/useTableExtractor'
import { extractRunnableCodeBlocks } from '@/composables/useCodeBlockExtractor'
const props = withDefaults(
defineProps<{
message: Message
index: number
triggeringQuery?: string
}>(),
{ triggeringQuery: '' }
)
const emit = defineEmits<{
edit: [messageId: string, newContent: string]
regenerate: []
branch: [messageId: string]
reply: [messageId: string, content: string]
feedback: [messageId: string, value: 'up' | 'down' | undefined]
}>()
function toggleFeedback(value: 'up' | 'down') {
const newValue = props.message.feedback === value ? undefined : value
emit('feedback', props.message.id, newValue)
}
// Editing state
const isEditing = ref(false)
const editContent = ref('')
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
function startEditing() {
editContent.value = props.message.content
isEditing.value = true
nextTick(() => {
editTextareaRef.value?.focus()
// Auto-size textarea
if (editTextareaRef.value) {
editTextareaRef.value.style.height = 'auto'
editTextareaRef.value.style.height = editTextareaRef.value.scrollHeight + 'px'
}
})
}
function cancelEdit() {
isEditing.value = false
editContent.value = ''
}
function submitEdit() {
const trimmed = editContent.value.trim()
if (!trimmed) return
emit('edit', props.message.id, trimmed)
isEditing.value = false
editContent.value = ''
}
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, openLongFormArticle, openMapView, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const codeContext = useCodeContext()
const isUser = computed(() => props.message.role === 'user')
const inlineContent = computed(() => {
if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], images: [] as ImageItem[], places: [] as Place[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
})
const bubbleClasses = computed(() =>
isUser.value
? 'path-glass-bubble-user rounded-br-md rounded-2xl'
: 'path-glass-bubble rounded-bl-md rounded-2xl'
)
const inlineFilms = computed(() => inlineContent.value.films)
const inlineBooks = computed(() => inlineContent.value.books ?? [])
const inlineTVSeries = computed(() => inlineContent.value.tvSeries ?? [])
const inlineImages = computed(() => inlineContent.value.images ?? [])
const inlinePlaces = computed(() => inlineContent.value.places ?? [])
const inlineSongs = computed(() => inlineContent.value.songs)
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
const inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? [])
const NOSTR_URI_RE = /nostr:(note1[a-z0-9]{58}|npub1[a-z0-9]{58}|nevent1[a-z0-9]+|nprofile1[a-z0-9]+)/g
const nostrUris = computed(() => {
if (isUser.value) return []
const matches = props.message.content.match(NOSTR_URI_RE)
return matches ? [...new Set(matches)] : []
})
const cashuTokens = computed(() => {
if (isUser.value) return []
return extractCashuTokens(props.message.content)
})
const inlineRecipes = computed(() => {
if (isUser.value) return []
return extractRecipes(props.message.content)
})
const inlineTables = computed(() => {
if (isUser.value) return []
return extractTables(props.message.content)
})
const inlineEvents = computed(() => {
if (isUser.value) return []
return extractEvents(props.message.content)
})
const runnableCodeBlocks = computed(() => {
if (isUser.value) return []
return extractRunnableCodeBlocks(props.message.content)
})
const bitcoinAddresses = computed(() => {
if (isUser.value) return []
return detectBitcoinAddresses(props.message.content)
})
const bolt11Invoices = computed(() => {
if (isUser.value) return []
return detectBolt11Invoices(props.message.content)
})
const bolt12Offers = computed(() => {
if (isUser.value) return []
return detectBolt12Offers(props.message.content)
})
const detectedTxIds = computed(() => {
if (isUser.value) return []
return detectTxIds(props.message.content)
})
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
const hasContext = computed(() => !isUser.value && (
isCodeResponse.value ||
inlineFilms.value.length > 0 ||
inlineBooks.value.length > 0 ||
inlineTVSeries.value.length > 0 ||
inlineImages.value.length > 0 ||
inlineSongs.value.length > 0 ||
inlinePodcasts.value.length > 0 ||
inlineNewsLinks.value.length > 0 ||
inlineWebsitesLinks.value.length > 0 ||
inlineMagazineSections.value.length > 0
))
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
// Open links in new tab
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank')
tokens[idx].attrSet('rel', 'noopener noreferrer')
return defaultRender(tokens, idx, options, env, self)
}
const displayText = computed(() => {
if (isUser.value) return props.message.content
let text = stripContentTags(props.message.content)
if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text)
if (nostrUris.value.length > 0) text = text.replace(NOSTR_URI_RE, '').replace(/\n{3,}/g, '\n\n').trim()
if (cashuTokens.value.length > 0) {
for (const token of cashuTokens.value) {
text = text.replace(token, '')
}
text = text.replace(/\n{3,}/g, '\n\n').trim()
}
return text
})
const baseMarkdown = computed(() => md.render(displayText.value))
const renderedMarkdown = ref('')
// Render math/mermaid after markdown, batch on content change
watch(baseMarkdown, async (html) => {
let result = html
renderedMarkdown.value = html // Show immediately
if (hasMath(displayText.value)) {
result = await renderMathInHtml(result)
}
if (hasMermaid(displayText.value)) {
result = await renderMermaidBlocks(result)
}
renderedMarkdown.value = result
}, { immediate: true })
const formattedTime = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
// Long-form article detection (>800 words + has headings)
const isLongForm = computed(() => {
if (isUser.value) return false
const text = props.message.content
const words = text.split(/\s+/).length
const hasHeadings = /^#{2,3}\s+.+$/m.test(text)
return words > 800 && hasHeadings
})
// Token estimate (~4 chars per token)
const estimatedTokens = computed(() => Math.ceil(props.message.content.length / 4))
const showTokenCount = computed(() => props.message.content.length > 20)
const tokenLabel = computed(() => {
const t = estimatedTokens.value
if (t >= 1000) return `${(t / 1000).toFixed(1)}k tok`
return `${t} tok`
})
function openPanelWithContext() {
updatePanelFromText(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
}
function handleFilmSelect(film: Film) {
openPanelWithContext()
closeSongDetail()
closePodcastDetail()
openFilmDetail(film)
}
function handleBookSelect(book: Book) {
openPanelWithContext()
closeFilmDetail()
closeTVSeriesDetail()
closeSongDetail()
closePodcastDetail()
openBookDetail(book)
}
function handleTVSeriesSelect(series: TVSeries) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closeSongDetail()
closePodcastDetail()
openTVSeriesDetail(series)
}
function handleSongSelect(song: Song) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closePodcastDetail()
openSongDetail(song)
}
function handlePlaceSelect(place: Place) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closePlaceDetail()
openPlaceDetail(place)
}
function handlePodcastSelect(podcast: Podcast) {
openPanelWithContext()
closeFilmDetail()
closeSongDetail()
openPodcastDetail(podcast)
}
function openPanel() {
closeFilmDetail()
closeBookDetail()
closeTVSeriesDetail()
closeSongDetail()
closePodcastDetail()
openPanelWithContext()
}
function handleArticleSelect(article: WebSearchResult) {
openPanelWithContext()
openArticleDetail(article)
}
function handleWebsiteSelect(article: WebSearchResult) {
openPanelWithContext()
openWebsiteDetail(article)
}
function activateCodeMode() {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
}
function handleBubbleClick() {
if (isCodeResponse.value) {
activateCodeMode()
return
}
if (hasContext.value) openPanel()
}
// Context menu
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null)
function openContextMenu(e: MouseEvent) {
contextMenuRef.value?.open(e.clientX, e.clientY)
}
function startEditingFromMenu() {
contextMenuRef.value?.close()
startEditing()
}
function emitRegenerate() {
contextMenuRef.value?.close()
emit('regenerate')
}
function emitReply() {
contextMenuRef.value?.close()
emit('reply', props.message.id, props.message.content)
}
function emitBranch() {
contextMenuRef.value?.close()
emit('branch', props.message.id)
}
function copyContent() {
contextMenuRef.value?.close()
navigator.clipboard.writeText(props.message.content).catch(() => {})
}
</script>
@@ -0,0 +1,137 @@
<template>
<div
v-if="isOpen"
class="glass px-3 py-2 mx-3 mb-1 rounded-xl flex items-center gap-2 animate-fade-up-fast"
>
<svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search messages..."
class="flex-1 bg-transparent text-base text-white/90 placeholder:text-white/25 outline-none min-w-0"
@keydown.enter.exact="nextMatch"
@keydown.shift.enter="prevMatch"
@keydown.escape="close"
@keydown.up.prevent="prevMatch"
@keydown.down.prevent="nextMatch"
/>
<span v-if="query" class="text-xs text-white/40 whitespace-nowrap select-none">
{{ matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results' }}
</span>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80 disabled:opacity-30"
:disabled="matchCount === 0"
aria-label="Previous match"
@click="prevMatch"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80 disabled:opacity-30"
:disabled="matchCount === 0"
aria-label="Next match"
@click="nextMatch"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80"
aria-label="Close search"
@click="close"
>
<svg class="w-3 h-3" 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>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
scrollToMessage: [index: number]
}>()
const isOpen = ref(false)
const query = ref('')
const currentMatchIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const matchingIndices = computed(() => {
if (!query.value.trim()) return []
const q = query.value.toLowerCase()
const indices: number[] = []
for (let i = 0; i < props.messages.length; i++) {
if (props.messages[i].content.toLowerCase().includes(q)) {
indices.push(i)
}
}
return indices
})
const matchCount = computed(() => matchingIndices.value.length)
watch(query, () => {
currentMatchIndex.value = 0
if (matchingIndices.value.length > 0) {
emit('scrollToMessage', matchingIndices.value[0])
}
})
function nextMatch() {
if (matchCount.value === 0) return
currentMatchIndex.value = (currentMatchIndex.value + 1) % matchCount.value
emit('scrollToMessage', matchingIndices.value[currentMatchIndex.value])
}
function prevMatch() {
if (matchCount.value === 0) return
currentMatchIndex.value = (currentMatchIndex.value - 1 + matchCount.value) % matchCount.value
emit('scrollToMessage', matchingIndices.value[currentMatchIndex.value])
}
function open() {
isOpen.value = true
nextTick(() => inputRef.value?.focus())
}
function close() {
isOpen.value = false
query.value = ''
currentMatchIndex.value = 0
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault()
if (isOpen.value) {
inputRef.value?.focus()
} else {
open()
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
defineExpose({ open, close, isOpen, matchingIndices, currentMatchIndex })
</script>
@@ -0,0 +1,516 @@
<template>
<div class="flex flex-col h-full rounded-2xl overflow-visible transition-all duration-300">
<ChatHeader
:title="title"
:conversation-id="displayId"
:side="side"
:show-close="showClose"
@switch-side="$emit('switchSide')"
@new-chat="handleNewChat"
@close="$emit('close')"
@open-settings="showSettings = true"
/>
<BranchSwitcher />
<ChatSearch
ref="chatSearchRef"
:messages="messages"
@scroll-to-message="scrollToMessageIndex"
/>
<ContextBar :messages="messages" :active-model="activeModel" />
<PersonaSelector />
<SettingsModal v-model:open="showSettings" />
<!-- History: full conversation list -->
<ChatHistory
v-if="showHistory"
@select="handleHistorySelect"
@new-chat="handleNewChat"
/>
<!-- Collapsed: prompt index -->
<template v-else-if="chatCollapsed">
<PromptIndex
:messages="messages"
@select="handlePromptSelect"
/>
<div v-if="isStreaming" class="px-4 pb-3">
<StreamingDots />
</div>
</template>
<!-- Expanded: full message list (virtualized) -->
<div
v-else
ref="messageListRef"
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide"
>
<div v-if="messages.length === 0" class="flex items-center justify-center h-full p-4">
<div class="text-center space-y-4 animate-fade-up">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl text-[#fafafa]"></span>
</div>
<p class="text-sm text-white/30">
Start a conversation
</p>
<router-link
to="/guide"
class="inline-flex items-center gap-1.5 text-xs text-white/25 hover:text-white/50 transition-colors"
>
<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="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
AIUI Guide
</router-link>
</div>
</div>
<div
v-else
:style="{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }"
>
<div
v-for="virtualRow in virtualizer.getVirtualItems()"
:key="messages[virtualRow.index].id"
:ref="(el) => el && virtualizer.measureElement(el as Element)"
:data-index="virtualRow.index"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}"
:class="['px-4 py-2.5', virtualRow.index === 0 ? 'pt-6' : '']"
>
<ErrorBoundary title="Message failed to render">
<ChatMessage
:message="messages[virtualRow.index]"
:index="virtualRow.index"
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
@edit="handleEdit"
@regenerate="handleRegenerate"
@branch="handleBranch"
@reply="handleReply"
@feedback="handleFeedback"
/>
</ErrorBoundary>
</div>
</div>
<div v-if="isStreaming && lastMessageEmpty" class="px-4 pb-4">
<StreamingDots />
</div>
</div>
<!-- Comparison mode split view -->
<ComparisonView
v-if="comparison.isComparing.value && (comparison.response1.value || comparison.response2.value)"
class="flex-1 min-h-0"
/>
<!-- Code mode project indicator -->
<div
v-if="codeContext.isCodeMode.value && codeContext.activeProject.value"
class="px-3 pb-1 flex items-center gap-1.5"
>
<span class="text-xs px-2 py-0.5 rounded-md bg-accent/15 text-accent font-medium truncate max-w-[200px]">
{{ codeContext.activeProject.value.name }}
</span>
<button
class="text-white/30 hover:text-white/60 transition-colors p-0.5"
@click="codeContext.exitCodeMode()"
>
<svg class="w-3 h-3" 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>
<ChatInput
:disabled="isStreaming || comparison.isAnyStreaming.value"
:streaming="isStreaming || comparison.isAnyStreaming.value"
:placeholder="activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
:active-tab="activeTab"
:reply-to="replyTo"
@send="handleSend"
@extract="handleExtract"
@stop="handleStop"
@clear-reply="clearReply"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useChatStore } from '@/stores/chat'
import { useAI, streamWithModel } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { useComparisonMode } from '@/composables/useComparisonMode'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
import PromptIndex from './PromptIndex.vue'
import ChatHistory from './ChatHistory.vue'
import BranchSwitcher from './BranchSwitcher.vue'
import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.vue'
import PersonaSelector from './PersonaSelector.vue'
import SettingsModal from './SettingsModal.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message, ImageAttachment } from '@aiui/core/types/message'
withDefaults(
defineProps<{
variant?: 'standalone' | 'modal'
side?: 'left' | 'right'
showClose?: boolean
}>(),
{
variant: 'standalone',
side: 'right',
showClose: false,
}
)
defineEmits<{
switchSide: []
close: []
}>()
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI()
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext()
import { usePersonaStore } from '@/stores/personas'
const personaStore = usePersonaStore()
const comparison = useComparisonMode()
const messageListRef = ref<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
const showSettings = ref(false)
// A send/regenerate/edit failure that looks like a missing or invalid API
// key (see useAI.ts's looksLikeMissingApiKey) opens Settings automatically
// instead of leaving the user stuck on a silent/dead error with no obvious
// next step. One-shot: reset immediately after acting so a later retry that
// fails the same way can re-trigger it (the user may have closed Settings
// without fixing anything).
watch(needsApiKey, (needs) => {
if (needs) {
showSettings.value = true
needsApiKey.value = false
}
})
// Scroll position memory per conversation
const scrollPositions = new Map<string, number>()
function saveScrollPosition() {
const el = messageListRef.value
const id = chatStore.activeConversationId
if (el && id) {
scrollPositions.set(id, el.scrollTop)
}
}
function restoreScrollPosition(convId: string) {
const saved = scrollPositions.get(convId)
if (saved !== undefined) {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = saved
})
}
}
function scrollToMessageIndex(index: number) {
virtualizer.value.scrollToIndex(index, { align: 'center' })
}
// Reply-to threading state
const replyTo = ref<{ messageId: string; excerpt: string } | null>(null)
function handleReply(messageId: string, content: string) {
const excerpt = content.slice(0, 100) + (content.length > 100 ? '...' : '')
replyTo.value = { messageId, excerpt }
}
function clearReply() {
replyTo.value = null
}
const messages = computed(() => chatStore.messages)
const virtualizer = useVirtualizer(computed(() => ({
count: messages.value.length,
getScrollElement: () => messageListRef.value,
estimateSize: (index: number) => {
// User messages are typically shorter
return messages.value[index]?.role === 'user' ? 60 : 200
},
overscan: 5,
})))
const isStreaming = computed(() => chatStore.isStreaming)
const chatCollapsed = computed(() => chatStore.chatCollapsed)
const showHistory = computed(() => chatStore.showHistory)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
)
const displayId = computed(
() => chatStore.activeConversationId?.slice(0, 8) ?? '—'
)
const lastMessageEmpty = computed(() => {
const msgs = messages.value
if (msgs.length === 0) return true
return msgs[msgs.length - 1].content === ''
})
function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
if (msgs[idx]?.role !== 'assistant') return ''
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i]?.role === 'user') return msgs[i].content ?? ''
}
return ''
}
function handleNewChat() {
chatStore.createConversation('New Chat', personaStore.defaultPersona?.id)
chatStore.showHistory = false
}
function handleHistorySelect(id: string) {
chatStore.setActiveConversation(id)
chatStore.showHistory = false
}
function handleStop() {
stopGeneration()
comparison.stopComparison()
}
async function handleEdit(messageId: string, newContent: string) {
await editAndResend(messageId, newContent)
}
async function handleRegenerate() {
await regenerateLastResponse()
}
function handleBranch(messageId: string) {
const convId = chatStore.activeConversationId
if (!convId) return
chatStore.branchFromMessage(convId, messageId)
}
function handleFeedback(messageId: string, value: 'up' | 'down' | undefined) {
const convId = chatStore.activeConversationId
if (!convId) return
chatStore.setMessageFeedback(convId, messageId, value)
}
function handleExtract(text: string) {
// Run content extraction without sending to AI
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
chatStore.addMessage(convId, { role: 'user', content: `[Extract] ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}` })
chatStore.addMessage(convId, { role: 'assistant', content: text })
updatePanelFromText(text, '', [])
}
async function handleSend(text: string, images: ImageAttachment[] = []) {
// Command handling
const trimmed = text.trim().toLowerCase()
if (trimmed === '/code') {
codeContext.enterCodeMode()
// Open the content panel with code tab
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
// Add system message to chat
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, {
role: 'user',
content: '/code',
})
chatStore.addMessage(convId, {
role: 'assistant',
content: 'Code mode activated. Select a project from the content panel to start coding.',
})
}
return
}
if (trimmed === '/seed') {
await chatStore.loadSeedChats()
// loadSeedChats already switches to the seed conversation
// Collapse chat so user sees PromptIndex for quick picking
chatStore.chatCollapsed = true
chatStore.showHistory = false
return
}
if (trimmed === '/nostr') {
panelOpen.value = true
if (!availableTabs.value.includes('nostr')) {
availableTabs.value = [...availableTabs.value, 'nostr']
}
setActiveTab('nostr')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/nostr' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Nostr feed opened. Browse notes, articles, and zaps from the network.' })
}
return
}
if (trimmed === '/design') {
enterDesignSystemMode()
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/design' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Design system viewer opened. Browse colors, typography, spacing, and components.' })
}
return
}
if (trimmed === '/freefilms') {
const { freeFilms } = await import('@/data/freeFilms')
panelFilms.value = freeFilms
panelTitle.value = 'Free Documentary Films'
panelOpen.value = true
availableTabs.value = ['film', 'prompt']
setActiveTab('film')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/freefilms' })
chatStore.addMessage(convId, { role: 'assistant', content: `Browse ${freeFilms.length} free documentary films from InDeeHub. Click any film to see details, and hit play to watch.` })
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
availableTabs.value = availableTabs.value.filter(t => t !== 'code')
const convId2 = chatStore.activeConversationId
if (convId2) {
chatStore.addMessage(convId2, {
role: 'assistant',
content: 'Code mode deactivated.',
})
}
return
}
}
// Prepend quote if replying to a message
let finalText = text
if (replyTo.value) {
const quoteLine = replyTo.value.excerpt.split('\n').map(l => `> ${l}`).join('\n')
finalText = `${quoteLine}\n\n${text}`
clearReply()
}
// Comparison mode: stream to both models simultaneously
if (comparison.isComparing.value) {
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
chatStore.addMessage(convId, { role: 'user', content: finalText, images: images.length > 0 ? images : undefined })
const history = chatStore.messages.map(m => ({ role: m.role, content: m.content }))
await comparison.streamBothModels(streamWithModel, history)
return
}
await sendMessage(finalText, images.length > 0 ? images : undefined)
}
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
const userText = _userMsg.content?.trim().toLowerCase() ?? ''
if (userText === '/code') {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
return
}
if (assistantMsg?.content) {
updatePanelFromText(assistantMsg.content, _userMsg.content, assistantMsg.webResults ?? [])
}
}
function scrollToBottom() {
nextTick(() => {
if (messages.value.length > 0) {
virtualizer.value.scrollToIndex(messages.value.length - 1, { align: 'end' })
}
// Fallback: also scroll the container directly for streaming updates
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
})
}
// Scroll to bottom when expanding from collapsed
watch(chatCollapsed, (collapsed, wasCollapsed) => {
if (!collapsed && wasCollapsed) {
scrollToBottom()
}
})
// Scroll to bottom when mobile keyboard opens so latest messages + input stay visible
const { isKeyboardOpen } = useVisualViewport()
watch(isKeyboardOpen, (open) => {
if (open) scrollToBottom()
})
// Save/restore scroll position on conversation switch
watch(
() => chatStore.activeConversationId,
(newId, oldId) => {
if (oldId) saveScrollPosition()
if (newId) restoreScrollPosition(newId)
}
)
watch(
() => messages.value.length,
() => scrollToBottom()
)
watch(
() => {
const msgs = messages.value
const last = msgs[msgs.length - 1]
return last ? { content: last.content, webResults: last.webResults } : null
},
(val) => {
scrollToBottom()
if (val?.content) {
const msgs = messages.value
const lastMsg = msgs[msgs.length - 1]
const lastUser = [...msgs].reverse().find((m) => m.role === 'user')
// Skip panel updates for command messages (e.g. /code, /exit)
const userText = lastUser?.content?.trim() ?? ''
if (!userText.startsWith('/')) {
updatePanelFromText(val.content, userText, lastMsg?.webResults ?? [])
}
}
},
{ deep: true, immediate: true }
)
</script>
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col h-full">
<!-- Mobile tabs -->
<div class="flex md:hidden gap-2 px-3 pt-2">
<button
v-for="(tab, i) in tabs"
:key="i"
class="flex-1 text-sm min-h-[44px] rounded-lg transition-all"
:class="activeTab === i
? 'bg-accent/20 text-accent'
: 'text-white/50 hover:text-white/70'"
@click="activeTab = i"
>
{{ tab }}
</button>
</div>
<!-- Split panes -->
<div class="flex-1 min-h-0 flex gap-0.5 p-2">
<!-- Model 1 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 0 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-xs text-accent font-medium truncate">{{ model1Label }}</span>
<span v-if="isStreaming1" class="text-xs text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response1"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered1"
/>
<div v-else-if="error1" class="text-sm text-red-400/80">{{ error1 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
<!-- Model 2 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 1 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-xs text-accent font-medium truncate">{{ model2Label }}</span>
<span v-if="isStreaming2" class="text-xs text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response2"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered2"
/>
<div v-else-if="error2" class="text-sm text-red-400/80">{{ error2 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { useComparisonMode } from '@/composables/useComparisonMode'
const { model1, model2, response1, response2, isStreaming1, isStreaming2, error1, error2 } = useComparisonMode()
const activeTab = ref(0)
const tabs = computed(() => [model1Label.value, model2Label.value])
const model1Label = computed(() => `${model1.value.provider}/${model1.value.model}`.split('/').pop() ?? 'Model 1')
const model2Label = computed(() => `${model2.value.provider}/${model2.value.model}`.split('/').pop() ?? 'Model 2')
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const rendered1 = computed(() => md.render(response1.value))
const rendered2 = computed(() => md.render(response2.value))
</script>
@@ -0,0 +1,74 @@
<template>
<div
v-if="messages.length > 0"
class="h-1 mx-3 rounded-full bg-white/5 overflow-hidden shrink-0 group cursor-help relative"
:title="tooltipText"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="percentage > 80 ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
<!-- Tooltip on hover -->
<div
class="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:flex items-center px-2 py-1 rounded-md bg-black/80 text-xs text-white/80 whitespace-nowrap pointer-events-none z-10"
>
{{ tooltipText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
messages: Message[]
contextWindow?: number
activeModel?: string
}>()
// Default context window sizes per model (tokens)
const maxTokens = computed(() => props.contextWindow ?? 200000)
// Estimate: ~4 chars per token
const estimatedTokens = computed(() => {
let chars = 0
for (const msg of props.messages) {
chars += msg.content.length
}
return Math.ceil(chars / 4)
})
const percentage = computed(() => {
if (maxTokens.value === 0) return 0
return (estimatedTokens.value / maxTokens.value) * 100
})
// Model pricing per million tokens (input/output)
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
'claude-haiku-4.5': { input: 0.80, output: 4.00 },
'claude-sonnet-4': { input: 3.00, output: 15.00 },
'claude-opus-4': { input: 15.00, output: 75.00 },
}
const estimatedCost = computed(() => {
const pricing = MODEL_PRICING[props.activeModel ?? '']
if (!pricing) return null
// Rough split: 70% input, 30% output
const inputTokens = estimatedTokens.value * 0.7
const outputTokens = estimatedTokens.value * 0.3
const cost = (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000
return cost
})
const tooltipText = computed(() => {
const est = estimatedTokens.value.toLocaleString()
const max = maxTokens.value.toLocaleString()
let text = `~${est} / ${max} tokens used`
if (estimatedCost.value !== null) {
text += ` · ~$${estimatedCost.value.toFixed(4)}`
}
return text
})
</script>
@@ -0,0 +1,122 @@
<template>
<div class="px-3 md:px-4 pb-1">
<!-- Collapsed toggle -->
<button
class="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60 transition-colors"
@click="isExpanded = !isExpanded"
>
<svg
class="w-3 h-3 transition-transform duration-200"
:class="isExpanded ? 'rotate-90' : ''"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Memory ({{ memoryStore.items.length }}/20)
</button>
<!-- Expanded panel -->
<div v-if="isExpanded" class="mt-2 space-y-1.5 animate-fade-up-fast">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-base text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-xs text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<svg class="w-3 h-3" 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>
</template>
</div>
<!-- Add new -->
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-xs text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
const memoryStore = useMemoryStore()
const isExpanded = ref(false)
const newText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newText.value.trim()) return
memoryStore.addItem(newText.value)
newText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
</script>
@@ -0,0 +1,173 @@
<template>
<div
class="rounded-xl p-3 transition-all duration-150"
:class="isDark
? 'bg-purple-500/10 border border-purple-500/20'
: 'bg-purple-50 border border-purple-200'"
>
<!-- Loading -->
<div v-if="loading" class="flex items-center gap-2">
<div
class="w-6 h-6 rounded-full animate-pulse"
:class="isDark ? 'bg-purple-500/20' : 'bg-purple-200'"
/>
<div class="flex-1 space-y-1">
<div
class="h-3 w-24 rounded animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-gray-200'"
/>
<div
class="h-2 w-40 rounded animate-pulse"
:class="isDark ? 'bg-white/5' : 'bg-gray-100'"
/>
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="flex items-center gap-2">
<span class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ isProfile ? 'Profile' : 'Note' }} not found
</span>
<span
class="text-xs font-mono truncate"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400'"
>
{{ truncatedId }}
</span>
</div>
<!-- Note content -->
<div v-else-if="note">
<div class="flex items-center gap-2 mb-1.5">
<div
class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-xs font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span
class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/70' : 'text-gray-700'"
>
{{ note.authorName ?? 'anon' }}
</span>
<span
class="text-xs ml-auto shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'"
>
{{ formatTime(note.created_at) }}
</span>
</div>
<p
class="text-xs leading-relaxed line-clamp-4"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ note.content }}
</p>
<div class="flex items-center gap-2 mt-1.5">
<span
class="text-xs font-mono"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr
</span>
</div>
</div>
<!-- Profile card (npub) -->
<div v-else-if="isProfile">
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-xs font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ truncatedId.charAt(0).toUpperCase() }}
</div>
<div>
<span
class="text-xs font-mono block"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ truncatedId }}
</span>
<span
class="text-xs"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr profile
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useNostr, type NostrNote } from '@/composables/useNostr'
import { decodeNIP19 } from '@/utils/bech32'
const props = defineProps<{
uri: string
}>()
const { isDark } = useTheme()
const { connect, fetchNote: fetchFromRelay } = useNostr()
const note = ref<NostrNote | null>(null)
const loading = ref(false)
const error = ref(false)
const decoded = computed(() => {
const raw = props.uri.replace(/^nostr:/, '')
return decodeNIP19(raw)
})
const isProfile = computed(() => decoded.value?.type === 'npub' || decoded.value?.type === 'nprofile')
const truncatedId = computed(() => {
const hex = decoded.value?.hex ?? ''
if (hex.length <= 12) return hex
return hex.slice(0, 8) + '...' + hex.slice(-4)
})
function formatTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000 - ts)
if (diff < 60) return 'now'
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
onMounted(async () => {
if (!decoded.value) {
error.value = true
return
}
// For profiles, just show the card with no fetch needed
if (isProfile.value) return
// For notes/events, fetch from relay
const hexId = decoded.value.hex
if (!hexId) {
error.value = true
return
}
loading.value = true
connect()
// Small delay to allow relay connections to establish
await new Promise(r => setTimeout(r, 500))
const result = await fetchFromRelay(hexId)
loading.value = false
if (result) {
note.value = result
} else {
error.value = true
}
})
</script>
@@ -0,0 +1,248 @@
<template>
<div v-if="personaStore.personas.length > 0" class="px-3 md:px-4">
<div class="flex items-center gap-1.5 flex-wrap">
<button
v-for="p in personaStore.sortedPersonas"
:key="p.id"
class="px-2.5 py-1 rounded-full text-xs font-medium transition-all duration-200 border"
:class="isActive(p.id)
? 'bg-accent/20 text-accent border-accent/30'
: 'bg-white/5 text-white/50 border-white/10 hover:text-white/70 hover:bg-white/10'"
@click="selectPersona(p.id)"
>
<span
v-if="p.accentColor"
class="inline-block w-2 h-2 rounded-full mr-1"
:style="{ backgroundColor: p.accentColor }"
/>
{{ p.name }}
</button>
<button
class="px-2 py-1 rounded-full text-xs text-white/30 hover:text-white/60 border border-transparent hover:border-white/10 transition-all duration-200"
@click="showEditor = true"
>
+ New
</button>
<button
v-if="activePersonaId"
class="px-2 py-1 rounded-full text-xs text-white/30 hover:text-white/60 transition-all"
title="Clear persona"
@click="clearPersona"
>
</button>
</div>
<!-- Persona editor modal -->
<Teleport to="body">
<div
v-if="showEditor"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="closeEditor"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div class="relative glass-card w-full max-w-md p-5 space-y-4 animate-scale-in">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white/90">
{{ editingPersona ? 'Edit Persona' : 'New Persona' }}
</h3>
<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/10 transition-all"
@click="closeEditor"
>
<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>
<div class="space-y-3">
<div>
<label class="block text-xs text-white/40 mb-1">Name</label>
<input
v-model="formName"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 focus:outline-none focus:border-accent/50"
placeholder="e.g. Film Critic"
/>
</div>
<div>
<label class="block text-xs text-white/40 mb-1">System Prompt</label>
<textarea
v-model="formPrompt"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="5"
placeholder="You are a film critic who..."
/>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs text-white/40 mb-1">Model Preference</label>
<select
v-model="formModel"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 focus:outline-none focus:border-accent/50"
>
<option value="">Default</option>
<option value="claude-haiku-4.5">Claude 4.5 Haiku</option>
<option value="claude-sonnet-4">Claude Sonnet 4</option>
<option value="claude-opus-4">Claude Opus 4</option>
</select>
</div>
<div class="w-20">
<label class="block text-xs text-white/40 mb-1">Colour</label>
<input
v-model="formColor"
type="color"
class="w-full h-9 rounded-lg bg-white/5 border border-white/10 cursor-pointer"
/>
</div>
</div>
<label class="flex items-center gap-2 text-xs text-white/60">
<input
v-model="formDefault"
type="checkbox"
class="accent-[#F7931A]"
/>
Set as default for new conversations
</label>
</div>
<div class="flex items-center gap-2 pt-1">
<button
v-if="editingPersona"
class="px-3 py-1.5 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-red-400/10 transition-all"
@click="handleDelete"
>
Delete
</button>
<div class="flex-1" />
<button
class="px-3 py-1.5 rounded-lg text-xs text-white/50 hover:text-white/70 hover:bg-white/10 transition-all"
@click="closeEditor"
>
Cancel
</button>
<button
class="px-4 py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
:disabled="!formName.trim()"
@click="handleSave"
>
{{ editingPersona ? 'Save' : 'Create' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePersonaStore, type Persona } from '@/stores/personas'
import { useChatStore } from '@/stores/chat'
const personaStore = usePersonaStore()
const chatStore = useChatStore()
const showEditor = ref(false)
const editingPersona = ref<Persona | null>(null)
// Form state
const formName = ref('')
const formPrompt = ref('')
const formModel = ref('')
const formColor = ref('#F7931A')
const formDefault = ref(false)
const activePersonaId = computed(() => chatStore.activeConversation?.personaId ?? null)
function isActive(id: string): boolean {
return activePersonaId.value === id
}
function selectPersona(id: string) {
const conv = chatStore.activeConversation
if (!conv) return
if (conv.personaId === id) {
// Double-click to edit
openEditorFor(personaStore.getPersona(id))
return
}
conv.personaId = id
conv.updatedAt = Date.now()
}
function clearPersona() {
const conv = chatStore.activeConversation
if (!conv) return
conv.personaId = undefined
conv.updatedAt = Date.now()
}
function openEditorFor(persona?: Persona) {
if (persona) {
editingPersona.value = persona
formName.value = persona.name
formPrompt.value = persona.systemPrompt
formModel.value = persona.modelPreference ?? ''
formColor.value = persona.accentColor ?? '#F7931A'
formDefault.value = persona.isDefault ?? false
} else {
editingPersona.value = null
formName.value = ''
formPrompt.value = ''
formModel.value = ''
formColor.value = '#F7931A'
formDefault.value = false
}
showEditor.value = true
}
function closeEditor() {
showEditor.value = false
editingPersona.value = null
}
function handleSave() {
if (!formName.value.trim()) return
const data = {
name: formName.value.trim(),
systemPrompt: formPrompt.value.trim(),
modelPreference: formModel.value || undefined,
accentColor: formColor.value,
isDefault: formDefault.value,
}
if (editingPersona.value) {
personaStore.updatePersona(editingPersona.value.id, data)
} else {
const created = personaStore.addPersona(data)
// Auto-select the new persona
const conv = chatStore.activeConversation
if (conv) {
conv.personaId = created.id
conv.updatedAt = Date.now()
}
}
closeEditor()
}
function handleDelete() {
if (!editingPersona.value) return
const id = editingPersona.value.id
// Clear from any active conversation
const conv = chatStore.activeConversation
if (conv?.personaId === id) {
conv.personaId = undefined
}
personaStore.deletePersona(id)
closeEditor()
}
</script>
@@ -0,0 +1,109 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<div v-if="promptPairs.length === 0" class="flex items-center justify-center h-full">
<p class="text-xs text-white/30">
No prompts yet
</p>
</div>
<button
v-for="(pair, i) in promptPairs"
:key="pair.userMsg.id"
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150 group"
:class="[
activeIndex === i
? 'path-glass-bubble-user'
: 'hover:bg-white/5'
]"
@click="selectPrompt(pair, i)"
>
<p class="text-sm leading-snug truncate text-white/90">
{{ pair.userMsg.content }}
</p>
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<span class="text-xs text-white/30">
{{ formatTime(pair.userMsg.timestamp) }}
</span>
<span
v-for="badge in pair.badges"
:key="badge"
class="text-xs px-1.5 py-0.5 rounded-md bg-white/8 text-white/40"
>
{{ badge }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Message } from '@aiui/core/types/message'
import { useContentPanel } from '@/composables/useContentPanel'
interface PromptPair {
userMsg: Message
assistantMsg: Message | null
badges: string[]
}
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
select: [userMsg: Message, assistantMsg: Message | null]
}>()
const { getContextualInlineContent } = useContentPanel()
const activeIndex = ref<number | null>(null)
const promptPairs = computed<PromptPair[]>(() => {
const pairs: PromptPair[] = []
const msgs = props.messages
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].role !== 'user') continue
const userMsg = msgs[i]
const assistantMsg = (i + 1 < msgs.length && msgs[i + 1].role === 'assistant')
? msgs[i + 1]
: null
const badges: string[] = []
if (assistantMsg && assistantMsg.content) {
const content = getContextualInlineContent(
assistantMsg.content,
userMsg.content,
assistantMsg.webResults ?? [],
)
if (content.films.length > 0) badges.push('Films')
if ((content.books?.length ?? 0) > 0) badges.push('Books')
if ((content.tvSeries?.length ?? 0) > 0) badges.push('TV')
if ((content.images?.length ?? 0) > 0) badges.push('Images')
if ((content.places?.length ?? 0) > 0) badges.push('Places')
if (content.songs.length > 0) badges.push('Music')
if (content.podcasts.length > 0) badges.push('Podcasts')
if (content.magazineSections.length > 0) badges.push('Magazine')
if ((content.newsLinks?.length ?? 0) > 0) badges.push('News')
if ((content.websitesLinks?.length ?? 0) > 0) badges.push('Web')
if ((content.codeBlocks?.length ?? 0) > 0) badges.push('Code')
if ((content.apps?.length ?? 0) > 0) badges.push('Apps')
if (content.hasNostr) badges.push('Nostr')
}
pairs.push({ userMsg, assistantMsg, badges })
}
return pairs
})
function selectPrompt(pair: PromptPair, index: number) {
activeIndex.value = index
emit('select', pair.userMsg, pair.assistantMsg)
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
@@ -0,0 +1,214 @@
<template>
<div v-if="isOpen" class="absolute bottom-full left-2 right-2 mb-1 z-50">
<!-- Variable fill form -->
<div
v-if="selectedTemplate && variables.length > 0"
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl p-4 space-y-3 animate-scale-in"
>
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold text-white/80">{{ selectedTemplate.title }}</h4>
<button
class="text-xs text-white/40 hover:text-white/60 transition-colors"
@click="cancelTemplate"
>
Cancel
</button>
</div>
<div v-for="v in variables" :key="v" class="space-y-1">
<label class="text-xs text-white/40">{{ v }}</label>
<input
v-model="variableValues[v]"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-base text-white/90 focus:outline-none focus:border-accent/50"
:placeholder="v"
@keydown.enter="applyTemplate"
/>
</div>
<button
class="w-full py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
@click="applyTemplate"
>
Insert
</button>
</div>
<!-- Command + template list -->
<div
v-else
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl max-h-72 overflow-y-auto animate-scale-in"
>
<!-- Commands section -->
<div v-if="filteredCommands.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-xs text-white/30 px-2">Commands</p>
</div>
<div
v-for="(cmd, i) in filteredCommands"
:key="cmd.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="i === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectCommand(cmd)"
@mouseenter="highlightIndex = i"
>
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-accent/70">{{ cmd.slash }}</span>
<p class="text-sm text-white/80">{{ cmd.title }}</p>
</div>
<p v-if="cmd.preview" class="text-xs text-white/40 mt-0.5 truncate">{{ cmd.preview }}</p>
</div>
</div>
<!-- Templates section -->
<div v-if="filteredUserTemplates.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-xs text-white/30 px-2">Templates</p>
</div>
<div
v-for="(t, i) in filteredUserTemplates"
:key="t.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="(filteredCommands.length + i) === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectTemplate(t)"
@mouseenter="highlightIndex = filteredCommands.length + i"
>
<p class="text-sm text-white/80">{{ t.title }}</p>
<p v-if="t.preview" class="text-xs text-white/40 mt-0.5 truncate">{{ t.preview }}</p>
</div>
</div>
<div v-if="filteredCommands.length === 0 && filteredUserTemplates.length === 0" class="px-3 py-4 text-center">
<p class="text-xs text-white/30">No matching commands</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { usePromptTemplateStore, extractVariables, type PromptTemplate } from '@/stores/promptTemplates'
interface PaletteCommand {
id: string
slash: string
title: string
preview: string
}
const BUILT_IN_COMMANDS: PaletteCommand[] = [
{ id: 'cmd-code', slash: '/code', title: 'Code', preview: 'Open project browser and code editor' },
{ id: 'cmd-nostr', slash: '/nostr', title: 'Nostr', preview: 'Browse the Nostr network feed' },
{ id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' },
{ id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' },
{ id: 'cmd-seed', slash: '/seed', title: 'Seed', preview: 'Load seed conversations for all content types' },
{ id: 'cmd-freefilms', slash: '/freefilms', title: 'Free Films', preview: 'Browse free documentary films from InDeeHub' },
]
const props = defineProps<{
query: string
isOpen: boolean
}>()
const emit = defineEmits<{
select: [text: string]
close: []
}>()
const templateStore = usePromptTemplateStore()
const highlightIndex = ref(0)
const selectedTemplate = ref<PromptTemplate | null>(null)
const variableValues = ref<Record<string, string>>({})
const filteredCommands = computed(() => {
const q = props.query.toLowerCase()
if (!q) return BUILT_IN_COMMANDS
return BUILT_IN_COMMANDS.filter(c =>
c.slash.toLowerCase().includes('/' + q) ||
c.title.toLowerCase().includes(q) ||
c.preview.toLowerCase().includes(q)
)
})
const filteredUserTemplates = computed(() => {
const q = props.query.toLowerCase()
if (!q) return templateStore.sortedTemplates
return templateStore.sortedTemplates.filter(t =>
t.title.toLowerCase().includes(q) || (t.preview ?? '').toLowerCase().includes(q)
)
})
const allItemsCount = computed(() => filteredCommands.value.length + filteredUserTemplates.value.length)
const variables = computed(() => {
if (!selectedTemplate.value) return []
return extractVariables(selectedTemplate.value.content)
})
watch(() => props.query, () => {
highlightIndex.value = 0
selectedTemplate.value = null
})
watch(() => props.isOpen, (open) => {
if (!open) {
selectedTemplate.value = null
variableValues.value = {}
highlightIndex.value = 0
}
})
function selectCommand(cmd: PaletteCommand) {
// Commands like /code, /nostr send the slash text directly
emit('select', cmd.slash)
}
function selectTemplate(t: PromptTemplate) {
const vars = extractVariables(t.content)
if (vars.length === 0) {
emit('select', t.content)
return
}
selectedTemplate.value = t
variableValues.value = Object.fromEntries(vars.map(v => [v, '']))
}
function applyTemplate() {
if (!selectedTemplate.value) return
let result = selectedTemplate.value.content
for (const [key, val] of Object.entries(variableValues.value)) {
result = result.replaceAll(`{{${key}}}`, val || key)
}
emit('select', result)
selectedTemplate.value = null
variableValues.value = {}
}
function cancelTemplate() {
selectedTemplate.value = null
variableValues.value = {}
}
function navigateUp() {
if (highlightIndex.value > 0) highlightIndex.value--
}
function navigateDown() {
if (highlightIndex.value < allItemsCount.value - 1) highlightIndex.value++
}
function selectHighlighted() {
const idx = highlightIndex.value
if (idx < filteredCommands.value.length) {
selectCommand(filteredCommands.value[idx])
} else {
const t = filteredUserTemplates.value[idx - filteredCommands.value.length]
if (t) selectTemplate(t)
}
}
defineExpose({
navigateUp,
navigateDown,
selectHighlighted,
hasSelectedTemplate: computed(() => !!selectedTemplate.value),
})
</script>
@@ -0,0 +1,357 @@
<template>
<Teleport to="body">
<Transition name="settings-modal">
<div
v-if="open"
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-label="Settings"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape="$emit('update:open', false)"
@keydown.tab="trapFocus"
>
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
@click.self="$emit('update:open', false)"
/>
<div class="glass-card relative w-full max-w-md p-5 space-y-5 animate-scale-in max-h-[85vh] overflow-y-auto scrollbar-hide">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white/96">Settings</h2>
<button
ref="closeButtonRef"
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Close settings"
@click="$emit('update:open', false)"
>
<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>
<!-- Memory Section -->
<div class="space-y-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-white/50">
Memory ({{ memoryStore.items.length }}/20)
</h3>
<div class="space-y-1.5">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-base text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-xs text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<svg class="w-3 h-3" 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>
</template>
</div>
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newMemoryText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newMemoryText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-xs text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
<!-- Divider -->
<div class="border-t border-white/5" />
<!-- Advanced Section -->
<div class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-white/50">
Advanced
</h3>
<template v-if="conv">
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Temperature</label>
<span class="text-xs text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Max Tokens</label>
<span class="text-xs text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Top P</label>
<span class="text-xs text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-white/50">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-xs text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">&times;</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<button
class="text-xs text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</template>
<p v-else class="text-xs text-white/30">Start a conversation to configure parameters</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
import { useChatStore } from '@/stores/chat'
const props = defineProps<{
open: boolean
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
const dialogRef = ref<HTMLElement | null>(null)
const closeButtonRef = ref<HTMLElement | null>(null)
function trapFocus(e: KeyboardEvent) {
const dialog = dialogRef.value
if (!dialog) return
const focusable = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(() => props.open, async (isOpen) => {
if (isOpen) {
await nextTick()
closeButtonRef.value?.focus()
}
})
// --- Memory ---
const memoryStore = useMemoryStore()
const newMemoryText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newMemoryText.value.trim()) return
memoryStore.addItem(newMemoryText.value)
newMemoryText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
// --- Advanced ---
const chatStore = useChatStore()
const conv = computed(() => chatStore.activeConversation)
const newStopSeq = ref('')
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
<style scoped>
.settings-modal-enter-active {
transition: opacity 0.2s ease-out;
}
.settings-modal-enter-active .glass-card {
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.settings-modal-leave-active {
transition: opacity 0.15s ease-in;
}
.settings-modal-enter-from,
.settings-modal-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,14 @@
<template>
<div class="flex justify-start animate-fade-up-fast">
<div class="path-glass-card rounded-2xl rounded-bl-md px-4 py-3">
<div class="flex items-center gap-1.5">
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,181 @@
<template>
<div class="app-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div
class="w-full aspect-[16/7] flex items-center justify-center"
:style="{ background: appGradient }"
>
<span class="text-5xl font-bold text-white/20">{{ app.name.charAt(0) }}</span>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ app.name }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span
class="px-1.5 py-0.5 rounded text-xs font-medium bg-white/15"
>{{ categoryLabel }}</span>
<span
v-for="p in app.platforms"
:key="p"
class="text-xs"
>{{ platformLabel(p) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-5">
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ app.longDescription }}
</p>
<div v-if="app.howTo?.length">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Getting Started</h4>
<ol class="space-y-2">
<li
v-for="(step, i) in app.howTo"
:key="i"
class="flex gap-2.5 text-xs"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold shrink-0 mt-0.5"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>{{ i + 1 }}</span>
<span :class="isDark ? 'text-white/70' : 'text-gray-600'">{{ step }}</span>
</li>
</ol>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Open</h4>
<a
:href="app.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<div
class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold"
:style="{ background: appGradient }"
>
<span class="text-white/90">{{ app.name.charAt(0) }}</span>
</div>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ app.url.replace(/^https?:\/\//, '') }}</p>
<p class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">Official website</p>
</div>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div v-if="relatedApps.length > 0">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Related Apps</h4>
<div class="space-y-2">
<button
v-for="related in relatedApps"
:key="related.id"
class="w-full text-left flex items-center gap-2.5 p-2.5 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
@click="$emit('selectApp', related)"
>
<div
class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
:style="{ background: relatedGradient(related.id) }"
>
<span class="text-white/90">{{ related.name.charAt(0) }}</span>
</div>
<div class="min-w-0">
<p class="text-xs font-medium truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ related.name }}</p>
<p class="text-xs truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ related.description }}</p>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { APP_DATABASE, type AppEntry } from '@/data/apps'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ app: AppEntry }>()
defineEmits<{ back: []; selectApp: [app: AppEntry] }>()
const { isDark } = useTheme()
const categoryLabels: Record<string, string> = {
'nostr-client': 'Nostr Client',
'lightning-wallet': 'Lightning Wallet',
'bitcoin-wallet': 'Bitcoin Wallet',
privacy: 'Privacy',
node: 'Node Software',
'dev-tool': 'Dev Tool',
relay: 'Relay',
}
const categoryLabel = computed(() => categoryLabels[props.app.category] ?? props.app.category)
function platformLabel(p: string): string {
const labels: Record<string, string> = {
ios: 'iOS',
android: 'Android',
web: 'Web',
desktop: 'Desktop',
cli: 'CLI',
nodeos: 'Node',
}
return labels[p] ?? p
}
function hashToHue(id: string): number {
let hash = 0
for (let i = 0; i < id.length; i++) hash = id.charCodeAt(i) + ((hash << 5) - hash)
return Math.abs(hash % 360)
}
const appGradient = computed(() => {
const hue = hashToHue(props.app.id)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
})
function relatedGradient(id: string): string {
const hue = hashToHue(id)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
}
const relatedApps = computed(() => {
if (!props.app.relatedApps?.length) return []
return props.app.relatedApps
.map(id => APP_DATABASE.find(a => a.id === id))
.filter((a): a is AppEntry => !!a)
})
</script>
@@ -0,0 +1,167 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredApps.length }} apps
</span>
</div>
<input
v-model="search"
type="text"
placeholder="Search apps..."
class="w-full px-3 py-2 rounded-lg text-base outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cat in categories"
:key="cat.value"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeCategory === cat.value
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="activeCategory = activeCategory === cat.value ? null : cat.value"
>
{{ cat.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="space-y-2">
<button
v-for="app in filteredApps"
:key="app.id"
class="w-full text-left p-3 rounded-xl transition-all duration-200 flex items-start gap-3"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
:aria-label="app.name"
@click="$emit('selectApp', app)"
>
<div
class="w-10 h-10 rounded-xl flex items-center justify-center text-lg font-bold shrink-0"
:style="{ background: appGradient(app.id) }"
>
<span class="text-white/90">{{ app.name.charAt(0) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ app.name }}
</p>
<span
class="text-xs px-1.5 py-0.5 rounded font-medium shrink-0"
:class="isDark ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ categoryLabel(app.category) }}
</span>
</div>
<p class="text-xs mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ app.description }}
</p>
<div class="flex gap-1 mt-1.5">
<span
v-for="p in app.platforms"
:key="p"
class="text-xs px-1 py-0.5 rounded"
:class="isDark ? 'bg-white/5 text-white/30' : 'bg-black/3 text-gray-400'"
>
{{ platformLabel(p) }}
</span>
</div>
</div>
</button>
</div>
<div v-if="filteredApps.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No apps match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { AppEntry } from '@/data/apps'
import { useTheme } from '@/composables/useTheme'
const props = withDefaults(defineProps<{
apps: AppEntry[]
title?: string
}>(), {
title: 'Recommended Apps',
})
defineEmits<{ selectApp: [app: AppEntry] }>()
const { isDark } = useTheme()
const search = ref('')
const activeCategory = ref<string | null>(null)
const categories = [
{ value: 'nostr-client', label: 'Nostr' },
{ value: 'lightning-wallet', label: 'Lightning' },
{ value: 'bitcoin-wallet', label: 'Bitcoin' },
{ value: 'privacy', label: 'Privacy' },
{ value: 'node', label: 'Nodes' },
{ value: 'dev-tool', label: 'Dev' },
]
function categoryLabel(cat: string): string {
return categories.find(c => c.value === cat)?.label ?? cat
}
function platformLabel(p: string): string {
const labels: Record<string, string> = {
ios: 'iOS',
android: 'Android',
web: 'Web',
desktop: 'Desktop',
cli: 'CLI',
nodeos: 'Node',
}
return labels[p] ?? p
}
function appGradient(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i++) hash = id.charCodeAt(i) + ((hash << 5) - hash)
const hue = Math.abs(hash % 360)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
}
const filteredApps = computed(() => {
let result = props.apps
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
a => a.name.toLowerCase().includes(q) ||
a.description.toLowerCase().includes(q) ||
a.keywords.some(k => k.toLowerCase().includes(q))
)
}
if (activeCategory.value) {
result = result.filter(a => a.category === activeCategory.value)
}
return result
})
</script>
@@ -0,0 +1,137 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" style="border-bottom: 1px solid rgba(255, 255, 255, 0.08)">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold text-white/90">
Node Apps
</h3>
<span class="text-xs font-mono text-white/30">
{{ filteredApps.length }} apps
</span>
</div>
<input
v-model="search"
type="text"
placeholder="Search node apps..."
class="w-full px-3 py-2 rounded-lg text-base outline-none transition-colors bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cat in categories"
:key="cat.value"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeCategory === cat.value
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeCategory = activeCategory === cat.value ? null : cat.value"
>
{{ cat.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 gap-2">
<button
v-for="app in filteredApps"
:key="app.id"
class="text-left p-3 rounded-xl transition-all duration-200 glass-card"
:class="app.liveStatus === 'running' ? 'hover:bg-white/10 cursor-pointer' : 'opacity-70'"
@click="handleAppClick(app)"
>
<div class="flex items-center gap-2 mb-1.5">
<span class="text-lg leading-none">{{ app.icon }}</span>
<span class="text-xs font-semibold text-white/90 truncate">{{ app.name }}</span>
</div>
<p class="text-xs text-white/50 line-clamp-2 leading-relaxed">
{{ app.description }}
</p>
<div class="mt-2 flex items-center gap-1.5">
<span
class="w-1.5 h-1.5 rounded-full"
:class="statusDotClass(app.liveStatus)"
/>
<span class="text-xs" :class="statusTextClass(app.liveStatus)">
{{ statusLabel(app.liveStatus) }}
</span>
</div>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ARCHY_APPS, type ArchyAppMeta } from '@/data/archy-apps'
import { useArchy } from '@/composables/useArchy'
interface MergedApp extends ArchyAppMeta {
liveStatus: 'running' | 'stopped' | 'not-installed'
}
const { isEmbedded, installedApps, requestAction } = useArchy()
const search = ref('')
const activeCategory = ref<string | null>(null)
const categories = [
{ label: 'Bitcoin', value: 'bitcoin' },
{ label: 'Lightning', value: 'lightning' },
{ label: 'Storage', value: 'storage' },
{ label: 'Social', value: 'social' },
{ label: 'Tools', value: 'tools' },
{ label: 'AI', value: 'ai' },
]
const mergedApps = computed<MergedApp[]>(() => {
return ARCHY_APPS.map((app) => {
const live = installedApps.value.find((a) => a.id === app.id)
let liveStatus: MergedApp['liveStatus'] = 'not-installed'
if (live) {
liveStatus = live.state === 'running' ? 'running' : 'stopped'
}
return { ...app, liveStatus }
})
})
const filteredApps = computed(() => {
let apps = mergedApps.value
if (activeCategory.value) {
apps = apps.filter((a) => a.category === activeCategory.value)
}
if (search.value.trim()) {
const q = search.value.toLowerCase()
apps = apps.filter((a) =>
a.name.toLowerCase().includes(q) || a.description.toLowerCase().includes(q),
)
}
return apps
})
function statusDotClass(status: MergedApp['liveStatus']) {
if (status === 'running') return 'bg-green-400'
if (status === 'stopped') return 'bg-yellow-400'
return 'bg-white/20'
}
function statusTextClass(status: MergedApp['liveStatus']) {
if (status === 'running') return 'text-green-400/80'
if (status === 'stopped') return 'text-yellow-400/70'
return 'text-white/30'
}
function statusLabel(status: MergedApp['liveStatus']) {
if (status === 'running') return 'Running'
if (status === 'stopped') return 'Stopped'
return isEmbedded.value ? 'Not installed' : 'Available'
}
function handleAppClick(app: MergedApp) {
if (app.liveStatus === 'running' && isEmbedded.value) {
requestAction('open-app', { appId: app.id })
}
}
</script>
@@ -0,0 +1,93 @@
<template>
<div class="article-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="article.imgSrc && isSafeImgSrc(article.imgSrc)"
:src="article.imgSrc"
:alt="article.title"
class="absolute inset-0 w-full h-full object-cover object-center block"
/>
<div
v-else
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ article.title }}</h2>
<p v-if="articleDomain" class="text-xs text-white/60 mt-1">{{ articleDomain }}</p>
</div>
</div>
<div class="p-4 space-y-4">
<article
v-if="article.content"
class="text-white/90 [&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
>
<div v-html="sanitizedContent" />
</article>
<div v-else class="py-4">
<p class="text-sm text-white/50">
Full article content is not available. Open the link below to read on the source site.
</p>
</div>
<a
v-if="article.url"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors bg-white/10 hover:bg-white/15 text-white/90"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Read full article
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ back: [] }>()
const articleDomain = computed(() => {
const url = props.article?.url
if (!url || typeof url !== 'string') return ''
try {
const u = new URL(url)
if (!/^https?:$/i.test(u.protocol)) return ''
return u.hostname.replace(/^www\./, '')
} catch {
return ''
}
})
const fallbackGradient = computed(() => {
const hue = [...props.article.title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)`
})
const sanitizedContent = computed(() => {
const c = props.article.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${escapeHtml(c)}</p>`
})
</script>
@@ -0,0 +1,199 @@
<template>
<Teleport to="body">
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
@click.self="store.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
<div
class="article-overlay-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl path-glass-card"
:class="panelClasses"
>
<div class="flex items-center gap-3 px-4 py-3 shrink-0"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
v-if="!store.content"
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors transition-transform duration-300 disabled:opacity-70 disabled:cursor-not-allowed"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Refresh page"
title="Refresh"
:disabled="isRefreshing"
@click="refreshIframe"
>
<svg
class="w-5 h-5"
:class="{ 'animate-spin': isRefreshing }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span class="flex-1 truncate text-sm font-medium min-w-0"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ store.title || 'Article' }}
</span>
<a
v-if="store.url"
:href="store.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Open in new tab"
title="Open in new tab"
@click.stop
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Close"
@click="store.close()"
>
<svg class="w-5 h-5" 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>
<div class="relative flex-1 min-h-0 bg-black/20 overflow-hidden">
<!-- When we have RSS/article content, render it; otherwise load URL in iframe -->
<div
v-if="store.content"
class="absolute inset-0 overflow-y-auto p-4 md:p-6 text-sm leading-relaxed"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
>
<article
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
>
<img
v-if="store.imgSrc && isSafeImgSrc(store.imgSrc)"
:src="store.imgSrc"
:alt="store.title"
class="w-full rounded-lg object-cover max-h-48 mb-4"
/>
<div v-html="sanitizedContent" />
</article>
<a
:href="store.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-4 text-sm"
:class="isDark ? 'text-white/70 hover:text-white' : 'text-gray-500 hover:text-gray-800'"
>
Read full article
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<iframe
v-else-if="store.url"
ref="iframeRef"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0"
style="-ms-overflow-style: none; scrollbar-width: none;"
title="Article content"
@load="onIframeLoad"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import { useTheme } from '@/composables/useTheme'
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
const store = useArticleOverlayStore()
const { isDark } = useTheme()
const sanitizedContent = computed(() => {
const c = store.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${escapeHtml(c)}</p>`
})
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
function refreshIframe() {
isRefreshing.value = true
iframeRefreshKey.value++
}
function onIframeLoad() {
isRefreshing.value = false
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
store.close()
e.preventDefault()
e.stopPropagation()
}
}
watch(
() => store.isOpen,
(open) => {
if (!open) isRefreshing.value = false
}
)
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true)
})
const panelClasses = [
'w-full max-w-[calc(100vw-3rem)] h-[80vh] max-h-[calc(100vh-5rem)]',
'md:max-w-[calc(100vw-5rem)]',
]
</script>
<style scoped>
iframe::-webkit-scrollbar {
display: none;
}
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.25s ease;
}
.app-launcher-enter-active .article-overlay-panel,
.app-launcher-leave-active .article-overlay-panel {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.app-launcher-enter-from,
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-enter-from .article-overlay-panel,
.app-launcher-leave-to .article-overlay-panel {
transform: scale(0.96);
opacity: 0;
}
</style>
@@ -0,0 +1,71 @@
<template>
<button
class="flex items-start gap-3 w-full text-left p-2.5 rounded-xl transition-all duration-150"
:class="isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'"
@click="$emit('select', book)"
>
<div class="w-12 h-auto shrink-0 rounded-md overflow-hidden shadow-md">
<div class="aspect-[2/3] relative">
<img
v-if="coverSrc"
:src="coverSrc"
:alt="book.title"
class="w-full h-full object-cover"
loading="lazy"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
</div>
<div class="flex-1 min-w-0 py-0.5">
<p class="text-sm font-medium leading-snug line-clamp-2"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ book.title }}
</p>
<p class="text-xs mt-0.5 truncate"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ book.author }}<span v-if="book.year"> · {{ book.year }}</span>
</p>
<p v-if="book.description" class="text-xs mt-1 line-clamp-2 leading-relaxed"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ book.description }}
</p>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateBookCoverFallback, fetchBookImage } from '@/composables/useImageFallback'
const props = defineProps<{ book: Book }>()
defineEmits<{ select: [book: Book] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.book.coverUrl || fetchedCover.value
})
const fallbackCover = computed(() =>
generateBookCoverFallback(props.book.title, props.book.author)
)
onMounted(() => {
if (props.book.coverUrl) return
fetchBookImage(props.book.title, props.book.author).then((url) => {
if (url) fetchedCover.value = url
})
})
</script>
@@ -0,0 +1,158 @@
<template>
<div class="book-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div class="w-full aspect-[16/7] flex items-center justify-center overflow-hidden bg-black/20">
<img
v-if="bannerSrc"
:src="bannerSrc"
:alt="book.title"
class="w-full h-full object-cover object-center block"
@error="onBannerError"
/>
<div
v-else
class="w-full h-full"
:style="{ background: fallbackGradient }"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ book.title }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span>{{ book.author }}</span>
<span v-if="book.year">{{ book.year }}</span>
<span v-if="book.pages">{{ book.pages }} pages</span>
<span v-if="book.rating" class="text-amber-400"> {{ book.rating.toFixed(1) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<p v-if="book.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ book.description }}
</p>
<div v-if="book.genres?.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in book.genres"
:key="genre"
class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Read on</h4>
<div class="space-y-2">
<a
v-for="src in (book.sources ?? [])"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<a
v-for="link in readLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchBookImage } from '@/composables/useImageFallback'
const props = defineProps<{ book: Book }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.book.coverUrl],
apiFetch: async () => {
const url = await fetchBookImage(props.book.title, props.book.author)
return { posterUrl: url, backdropUrl: null }
},
title: () => props.book.title,
gradientSeed: () => props.book.title + (props.book.author ?? ''),
})
const q = computed(() =>
`${props.book.title} ${props.book.author}`.trim().replace(/\s+/g, '+'),
)
const readLinks = computed(() => [
{ name: 'Open Library', url: `https://openlibrary.org/search?q=${q.value}`, icon: '📖', desc: 'Free, open catalog' },
{ name: 'Internet Archive', url: `https://archive.org/search?query=${q.value}`, icon: '🏛️', desc: 'Borrow & read free' },
{ name: 'Project Gutenberg', url: `https://www.gutenberg.org/ebooks/search/?query=${q.value}`, icon: '📜', desc: 'Public domain' },
{ name: 'Standard Ebooks', url: `https://standardebooks.org/ebooks?query=${q.value}`, icon: '📕', desc: 'Beautifully formatted' },
])
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
openlibrary: '📖',
gutenberg: '📜',
archive: '🏛️',
goodreads: '📚',
libgen: '🔓',
local: '💾',
}
return icons[type] ?? '📚'
}
</script>
@@ -0,0 +1,163 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredBooks.length }} books
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search books..."
class="w-full px-3 py-2 rounded-lg text-base outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
<div v-if="topGenres.length > 0" class="flex flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="book in filteredBooks"
:key="book.id"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
:aria-label="`${book.title} by ${book.author}`"
@click="$emit('selectBook', book)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(book) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="isLoading(book)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(book)"
:src="coverSrc(book)!"
:alt="`${book.title} by ${book.author}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(book)"
/>
<img
v-else-if="!isLoading(book)"
:src="fallbackSrc(book)"
:alt="book.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(book)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ book.title }}
</p>
<p class="text-xs text-white/40 truncate mt-0.5">{{ book.author }}</p>
</div>
<div v-if="book.rating" class="absolute top-1.5 left-1.5">
<span class="text-xs px-1.5 py-0.5 rounded bg-black/60 text-amber-400 backdrop-blur-sm font-medium">
{{ book.rating.toFixed(1) }}
</span>
</div>
<div v-if="book.year" class="absolute top-1.5 right-1.5">
<span class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm">
{{ book.year }}
</span>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredBooks.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No books match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { generateBookCoverFallback, fetchBookImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
books: Book[]
title?: string
}>(), {
title: 'Recommended Books',
})
defineEmits<{ selectBook: [book: Book] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'books'),
id: (b) => b.id,
existingUrl: (b) => b.coverUrl,
fetch: (b) => fetchBookImage(b.title, b.author),
fallback: (b) => generateBookCoverFallback(b.title, b.author),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const b of props.books) {
for (const g of b.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredBooks = computed(() => {
let result = props.books
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(b) =>
b.title.toLowerCase().includes(q) ||
b.author.toLowerCase().includes(q) ||
(b.genres ?? []).some((g) => g.toLowerCase().includes(q))
)
}
if (activeGenre.value) {
result = result.filter((b) => (b.genres ?? []).includes(activeGenre.value!))
}
return result
})
</script>
@@ -0,0 +1,22 @@
<template>
<button
class="absolute top-3 right-3 z-10 p-2 rounded-lg path-glass-icon transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Close"
aria-label="Close"
@click="$emit('click')"
>
<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>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
defineEmits<{ click: [] }>()
</script>
@@ -0,0 +1,104 @@
<template>
<div class="code-detail h-full flex flex-col overflow-hidden"
:class="isDark ? 'bg-[#1a1a2e]' : 'bg-[#fafafa]'">
<!-- Header with file name + back button -->
<div class="shrink-0 flex items-center gap-2 px-3 py-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4" :class="isDark ? 'text-white/70' : 'text-gray-600'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex-1 min-w-0 pl-8">
<div class="flex items-center gap-2">
<!-- Language badge -->
<span class="shrink-0 text-xs px-1.5 py-0.5 rounded font-mono"
:class="isDark ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'">
{{ language }}
</span>
<p class="text-xs font-mono truncate"
:class="isDark ? 'text-white/70' : 'text-gray-700'">
{{ fileName }}
</p>
</div>
<p v-if="projectName" class="text-xs font-mono mt-0.5 truncate"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ projectName }} / {{ filePath }}
</p>
</div>
</div>
<!-- Code content -->
<div class="flex-1 min-h-0 overflow-auto custom-scrollbar">
<div v-if="content" class="font-mono text-xs leading-relaxed">
<table class="w-full border-collapse">
<tbody>
<tr v-for="(line, i) in lines" :key="i"
class="hover:bg-white/[0.03]">
<td class="select-none text-right pr-4 pl-4 py-0 align-top w-1"
:class="isDark ? 'text-white/15' : 'text-gray-300'"
style="min-width: 3rem;">
{{ i + 1 }}
</td>
<td class="pr-4 py-0 whitespace-pre"
:class="isDark ? 'text-white/75' : 'text-gray-700'">{{ line }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-else class="flex items-center justify-center h-full">
<div class="text-center space-y-3 px-6">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<svg class="w-7 h-7" :class="isDark ? 'text-white/20' : 'text-gray-300'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Select a file to view its contents.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext } from '@/composables/useCodeContext'
defineEmits<{
back: []
}>()
const { isDark } = useTheme()
const { activeFile, activeFileContent, activeFileLanguage, activeProject } = useCodeContext()
const content = computed(() => activeFileContent.value)
const language = computed(() => activeFileLanguage.value)
const filePath = computed(() => activeFile.value ?? '')
const fileName = computed(() => filePath.value.split('/').pop() ?? '')
const projectName = computed(() => activeProject.value?.name ?? '')
const lines = computed(() => {
if (!content.value) return []
return content.value.split('\n')
})
</script>
<style scoped>
.code-detail table {
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,163 @@
<template>
<div class="flex-1 min-h-0 flex flex-col">
<FilmGrid
v-if="activeTab === 'film'"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
/>
<BookGrid
v-else-if="activeTab === 'book'"
:books="panelBooks"
:title="panelTitle"
@select-book="openBookDetail"
/>
<TVSeriesGrid
v-else-if="activeTab === 'tvshow'"
:series="panelTVSeries"
:title="panelTitle"
@select-series="openTVSeriesDetail"
/>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
/>
<PlaceGrid
v-else-if="activeTab === 'place'"
:places="panelPlaces"
:title="panelTitle"
@select-place="openPlaceDetail"
/>
<SongGrid
v-else-if="activeTab === 'song'"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
/>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:hero-image-url="panelMagazineHeroImage"
:title="panelTitle"
:query="panelQuery"
/>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
/>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
variant="websites"
/>
<PodcastGrid
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
/>
<RecipeGrid
v-else-if="activeTab === 'recipe'"
:recipes="panelRecipes"
:title="panelTitle"
@select-recipe="openRecipeDetail"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
:is-wide-desktop="isWideDesktop"
:is-mobile="isMobile"
/>
<DesignSystemGrid
v-else-if="activeTab === 'design-system'"
/>
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
<MagazineGrid
v-else-if="activeTab === 'prompt'"
:sections="promptSections"
:hero-image-url="null"
title="Prompt"
:query="panelQuery"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import type { ContentTab, MagazineSection } from '@/composables/useContentPanel'
import type { RecipeData, AppEntry } from '@/composables/contentExtraction'
import { useContentPanel } from '@/composables/useContentPanel'
import { extractMagazineSections, stripContentTags } from '@/composables/contentExtraction'
import FilmGrid from './FilmGrid.vue'
import BookGrid from './BookGrid.vue'
import TVSeriesGrid from './TVSeriesGrid.vue'
import ImageGrid from './ImageGrid.vue'
import PlaceGrid from './PlaceGrid.vue'
import SongGrid from './SongGrid.vue'
import PodcastGrid from './PodcastGrid.vue'
import MagazineGrid from './MagazineGrid.vue'
import NewsGrid from './NewsGrid.vue'
import RecipeGrid from './RecipeGrid.vue'
import AppsGrid from './AppsGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
import DesignSystemGrid from './DesignSystemGrid.vue'
import NostrGrid from './NostrGrid.vue'
const props = defineProps<{
activeTab: ContentTab
isWideDesktop?: boolean
isMobile?: boolean
panelFilms: Film[]
panelBooks: Book[]
panelTVSeries: TVSeries[]
panelImages: ImageItem[]
panelPlaces: Place[]
panelSongs: Song[]
panelPodcasts: Podcast[]
panelWebResults: WebSearchResult[]
panelWebsites: WebSearchResult[]
panelMagazineSections: MagazineSection[]
panelMagazineHeroImage: string | null
panelRecipes: RecipeData[]
panelApps: AppEntry[]
panelTitle: string
panelQuery: string
panelResponseText?: string
}>()
const promptSections = computed<MagazineSection[]>(() => {
const text = props.panelResponseText ?? ''
if (!text) return [{ title: props.panelQuery || 'Prompt', content: '' }]
// Use magazine extraction to format the response beautifully
const sections = extractMagazineSections(text)
if (sections.length > 0) return sections
// Fallback: single section with cleaned response
return [{ title: props.panelQuery || 'Response', content: stripContentTags(text) }]
})
const {
openFilmDetail,
openBookDetail,
openTVSeriesDetail,
openImageDetail,
openPlaceDetail,
openSongDetail,
openPodcastDetail,
openRecipeDetail,
openAppDetail,
} = useContentPanel()
</script>
@@ -0,0 +1,411 @@
<template>
<Transition name="panel">
<aside
v-if="panelOpen"
class="path-glass-card overflow-hidden flex flex-col"
:class="isMobile
? 'fixed inset-0 z-30'
: 'w-80 xl:w-96 shrink-0'"
>
<!-- Mobile header -->
<div
v-if="isMobile"
class="p-3 flex items-center justify-between shrink-0"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ panelTitle }}
</h3>
<button
class="touch-target rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5'"
@click="closePanel"
>
<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>
<!-- Tab bar (when multiple tabs available) -->
<div
v-if="displayTabs.length > 1 && !hasDetailOpen"
class="flex items-center gap-2 px-3 pt-3 pb-1 shrink-0 overflow-x-auto scrollbar-hide"
>
<button
v-for="tab in displayTabs"
:key="tab"
class="text-xs px-2.5 min-h-[44px] flex items-center justify-center rounded-lg font-medium whitespace-nowrap transition-all duration-150"
:class="activeTab === tab
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="tab === 'favorites' || tab === 'discover' ? (activeTab = tab) : setActiveTab(tab)"
>
{{ tabLabel(tab) }}
</button>
</div>
<!-- Content area -->
<div class="flex-1 min-h-0">
<ErrorBoundary title="Content failed to load">
<!-- Detail views (override tab content) -->
<component
:is="filmRenderer?.panelPlay"
v-if="selectedFilm && filmRenderer?.panelPlay"
:key="selectedFilm.id"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<BookDetail
v-else-if="selectedBook"
:key="selectedBook.id"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:key="selectedTVSeries.id"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<component
:is="songRenderer?.panelPlay"
v-else-if="selectedSong && songRenderer?.panelPlay"
:key="selectedSong.id"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:key="selectedPodcast.id"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:key="selectedImage.url"
:image="selectedImage"
@back="closeImageDetail"
/>
<PlaceDetail
v-else-if="selectedPlace"
:key="selectedPlace.id"
:place="selectedPlace"
@back="closePlaceDetail"
/>
<RecipeDetail
v-else-if="selectedRecipe"
:key="selectedRecipe.title"
:recipe="selectedRecipe"
@back="closeRecipeDetail"
/>
<AppDetail
v-else-if="selectedApp"
:key="selectedApp.id"
:app="selectedApp"
@back="closeAppDetail"
@select-app="openAppDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:key="selectedArticle.url"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<ArticleReader
v-else-if="longFormArticle"
:content="longFormArticle.content"
:title="longFormArticle.title"
@back="closeLongFormArticle"
/>
<PdfViewer
v-else-if="pdfUrl"
:url="pdfUrl.url"
:title="pdfUrl.title"
@back="closePdfViewer"
/>
<MapRenderer
v-else-if="mapPlaces.length > 0"
:places="mapPlaces"
@back="closeMapView"
/>
<!-- Grid views by active tab -->
<component
:is="filmRenderer?.panelPreview"
v-else-if="activeTab === 'film' && filmRenderer?.panelPreview"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
/>
<BookGrid
v-else-if="activeTab === 'book'"
:books="panelBooks"
:title="panelTitle"
@select-book="openBookDetail"
/>
<TVSeriesGrid
v-else-if="activeTab === 'tvshow'"
:series="panelTVSeries"
:title="panelTitle"
@select-series="openTVSeriesDetail"
/>
<component
:is="songRenderer?.panelPreview"
v-else-if="activeTab === 'song' && songRenderer?.panelPreview"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
/>
<PodcastGrid
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
/>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
/>
<PlaceGrid
v-else-if="activeTab === 'place'"
:places="panelPlaces"
:title="panelTitle"
@select-place="openPlaceDetail"
/>
<RecipeGrid
v-else-if="activeTab === 'recipe'"
:recipes="panelRecipes"
:title="panelTitle"
@select-recipe="openRecipeDetail"
/>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
variant="news"
/>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
:query="panelQuery"
variant="websites"
/>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:title="panelTitle"
:query="panelQuery"
:hero-image="panelMagazineHeroImage ?? undefined"
/>
<ArchyAppsGrid
v-else-if="activeTab === 'app' && isArchyEmbedded"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
/>
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
<FavoritesGrid
v-else-if="activeTab === 'favorites'"
/>
<DiscoverPanel
v-else-if="activeTab === 'discover'"
/>
</ErrorBoundary>
</div>
</aside>
</Transition>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type ContentTab } from '@/composables/useContentPanel'
import { getRendererForContentType } from '@aiui/core'
import BookGrid from './BookGrid.vue'
import BookDetail from './BookDetail.vue'
import TVSeriesGrid from './TVSeriesGrid.vue'
import TVSeriesDetail from './TVSeriesDetail.vue'
import ImageGrid from './ImageGrid.vue'
import ImageDetail from './ImageDetail.vue'
import PlaceGrid from './PlaceGrid.vue'
import PlaceDetail from './PlaceDetail.vue'
import RecipeGrid from './RecipeGrid.vue'
import RecipeDetail from './RecipeDetail.vue'
import PodcastGrid from './PodcastGrid.vue'
import PodcastDetail from './PodcastDetail.vue'
import NewsGrid from './NewsGrid.vue'
import ArticleDetail from './ArticleDetail.vue'
import ArticleReader from '@/components/renderers/ArticleReader.vue'
const PdfViewer = defineAsyncComponent({
loader: () => import('@/components/renderers/PdfViewer.vue'),
loadingComponent: { template: '<div class="flex items-center justify-center h-32"><span class="text-sm text-white/50">Loading PDF viewer...</span></div>' },
})
const MapRenderer = defineAsyncComponent({
loader: () => import('@/components/renderers/MapRenderer.vue'),
loadingComponent: { template: '<div class="flex items-center justify-center h-32"><span class="text-sm text-white/50">Loading map...</span></div>' },
})
import MagazineGrid from './MagazineGrid.vue'
import AppsGrid from './AppsGrid.vue'
import ArchyAppsGrid from './ArchyAppsGrid.vue'
import AppDetail from './AppDetail.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
import FavoritesGrid from './FavoritesGrid.vue'
import DiscoverPanel from './DiscoverPanel.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { useArchy } from '@/composables/useArchy'
// Film and song renderers loaded from plugin registry
const filmRenderer = computed(() => getRendererForContentType('film'))
const songRenderer = computed(() => getRendererForContentType('song'))
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const { isEmbedded: isArchyEmbedded } = useArchy()
const {
panelOpen,
panelFilms,
panelBooks,
panelTVSeries,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelImages,
panelPlaces,
panelRecipes,
panelApps,
panelMagazineSections,
panelMagazineHeroImage,
panelTitle,
panelQuery,
activeTab,
availableTabs,
selectedFilm,
selectedBook,
selectedTVSeries,
selectedSong,
selectedPodcast,
selectedImage,
selectedPlace,
selectedArticle,
selectedRecipe,
selectedApp,
selectedDesignSystemItem,
setActiveTab,
openFilmDetail,
closeFilmDetail,
openBookDetail,
closeBookDetail,
openTVSeriesDetail,
closeTVSeriesDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openImageDetail,
closeImageDetail,
openPlaceDetail,
closePlaceDetail,
openRecipeDetail,
closeRecipeDetail,
openAppDetail,
closeAppDetail,
closeArticleDetail,
longFormArticle,
closeLongFormArticle,
pdfUrl,
closePdfViewer,
mapPlaces,
closeMapView,
closePanel,
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedImage.value || selectedPlace.value || selectedRecipe.value || selectedArticle.value || selectedApp.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
)
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const displayTabs = computed(() => {
const tabs = [...availableTabs.value]
if (favoritesStore.items.length > 0 && !tabs.includes('favorites')) {
tabs.push('favorites')
}
if (!tabs.includes('discover')) {
tabs.push('discover')
}
return tabs
})
function onResize() {
windowWidth.value = window.innerWidth
}
const TAB_LABELS: Record<ContentTab, string> = {
film: 'Films',
book: 'Books',
tvshow: 'TV',
image: 'Images',
place: 'Places',
recipe: 'Recipes',
song: 'Music',
podcast: 'Podcasts',
news: 'News',
websites: 'Web',
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
app: 'Apps',
nostr: 'Nostr',
favorites: 'Favorites',
discover: 'Discover',
prompt: 'Prompt',
}
function tabLabel(tab: ContentTab): string {
return TAB_LABELS[tab] ?? tab
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
</script>
<style scoped>
.panel-enter-active {
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.panel-leave-active {
transition: all 0.2s ease-in;
}
.panel-enter-from {
opacity: 0;
transform: translateX(20px);
}
.panel-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>
@@ -0,0 +1,142 @@
<template>
<div class="relative flex-1 flex flex-col min-h-0 overflow-hidden">
<div
v-if="['film','song','podcast','book','tvshow','image','news','websites','magazine'].includes(contextType)"
class="flex-1 flex flex-col min-h-0"
>
<div
class="p-4 shrink-0 flex items-center justify-between gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<p class="text-sm font-medium"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ contextLabel }}
</p>
<div class="flex items-center gap-2 shrink-0">
<p class="text-xs font-mono uppercase tracking-[0.2em]"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
Surfacing
</p>
<slot name="header-actions" />
</div>
</div>
<LoadingContentGrid :variant="skeletonVariant" :count="skeletonCount" />
</div>
<div
v-else
class="relative flex-1 flex items-center justify-center min-h-0"
>
<div
class="absolute inset-0 opacity-[0.04] pointer-events-none"
:class="isDark ? 'bg-white' : 'bg-black'"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E&quot;)"
/>
<div class="relative flex flex-col items-center gap-8">
<div class="flex items-center gap-2">
<div
v-for="(_, i) in 5"
:key="i"
class="w-12 h-16 rounded-lg overflow-hidden relative animate-cell-pulse"
:class="isDark
? 'bg-white/8 border border-white/15 shadow-xl shadow-accent/5'
: 'bg-black/6 border border-black/8 shadow-xl shadow-accent/10'"
:style="{ animationDelay: `${i * 100}ms` }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden">
<div
class="absolute inset-0 w-1/2 animate-shimmer-sweep"
:class="isDark
? 'bg-gradient-to-r from-transparent via-accent/25 to-transparent'
: 'bg-gradient-to-r from-transparent via-accent/35 to-transparent'"
/>
</div>
</div>
</div>
<p class="text-sm font-medium"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ contextLabel }}
</p>
<div class="w-40 h-px rounded-full overflow-hidden"
:class="isDark ? 'bg-white/8' : 'bg-black/8'">
<div class="h-full bg-accent/90 rounded-full animate-progress-sweep" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import LoadingContentGrid from './LoadingContentGrid.vue'
const props = withDefaults(
defineProps<{
contextType?: 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'news' | 'websites' | 'magazine' | 'generic'
}>(),
{ contextType: 'film' }
)
const { isDark } = useTheme()
const contextLabel = computed(() => {
if (props.contextType === 'film') return 'Film recommendations'
if (props.contextType === 'song') return 'Song recommendations'
if (props.contextType === 'podcast') return 'Podcast recommendations'
if (props.contextType === 'book') return 'Book recommendations'
if (props.contextType === 'tvshow') return 'TV Series recommendations'
if (props.contextType === 'image') return 'Images'
if (props.contextType === 'news') return 'Articles'
if (props.contextType === 'websites') return 'Websites'
if (props.contextType === 'magazine') return 'Brief'
return 'Content'
})
const skeletonVariant = computed<'poster' | 'square' | 'list' | 'magazine'>(() => {
if (['song', 'podcast', 'image'].includes(props.contextType)) return 'square'
if (['news', 'websites'].includes(props.contextType)) return 'list'
if (props.contextType === 'magazine') return 'magazine'
return 'poster'
})
const skeletonCount = computed(() => {
if (props.contextType === 'magazine') return 6
if (['news', 'websites'].includes(props.contextType)) return 6
return 12
})
</script>
<style scoped>
.animate-cell-pulse {
animation: cell-pulse 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.animate-shimmer-sweep {
animation: shimmer-sweep 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.animate-progress-sweep {
animation: progress-sweep 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes cell-pulse {
0%, 100% { opacity: 0.6; transform: scale(0.96); }
50% { opacity: 1; transform: scale(1.02); }
}
@keyframes shimmer-sweep {
0% { transform: translateX(-100%); }
60% { transform: translateX(200%); }
100% { transform: translateX(200%); }
}
@keyframes progress-sweep {
0% { width: 0; margin-left: 0; }
45% { width: 60%; margin-left: 20%; }
90% { width: 0; margin-left: 100%; }
100% { width: 0; margin-left: 0; }
}
</style>
@@ -0,0 +1,438 @@
<template>
<div class="h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<!-- Header -->
<div class="shrink-0 px-4 py-3 flex items-center gap-3"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="min-w-[44px] min-h-[44px] rounded-lg path-glass-icon flex items-center justify-center transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10' : 'hover:bg-black/5'"
@click="$emit('back')"
>
<svg class="w-3.5 h-3.5" :class="isDark ? 'text-white/70' : 'text-gray-500'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ item.name }}
</h2>
<p class="text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ categoryLabel }}
</p>
</div>
<button
class="text-xs px-2 py-1 rounded-md transition-colors"
:class="copied
? 'bg-emerald-500/20 text-emerald-400'
: isDark
? 'bg-white/5 text-white/50 hover:bg-white/10'
: 'bg-black/5 text-gray-500 hover:bg-black/10'"
@click="copyCode"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
<div class="p-4 space-y-4">
<!-- Description -->
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ item.description }}
</p>
<!-- Live preview -->
<div>
<h4 class="text-xs uppercase tracking-[0.2em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Preview
</h4>
<div class="rounded-xl p-4 overflow-hidden"
:class="isDark ? 'bg-white/[0.03] border border-white/10' : 'bg-black/[0.02] border border-black/10'">
<!-- Color preview -->
<div v-if="item.category === 'colors'" class="space-y-2">
<div class="h-12 rounded-lg border"
:class="isDark ? 'border-white/10' : 'border-black/10'"
:style="{ background: extractColorValue(item.code) }" />
<p class="text-xs font-mono text-center"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ extractColorValue(item.code) }}
</p>
</div>
<!-- Typography preview -->
<div v-else-if="item.category === 'typography'" class="space-y-2">
<p class="text-2xl font-bold"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
:style="fontStyle">
Aa Bb Cc 123
</p>
<p class="text-sm"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
:style="fontStyle">
The quick brown fox jumps over the lazy dog.
</p>
</div>
<!-- Spacing preview -->
<div v-else-if="item.category === 'spacing'" class="flex items-end gap-2">
<div v-for="(size, i) in [4, 8, 12, 16, 20, 24, 32]" :key="i"
class="bg-accent/30 rounded-sm flex items-center justify-center"
:style="{ width: `${size}px`, height: `${size}px` }">
<span v-if="size >= 16" class="text-[7px] text-accent font-mono">{{ size }}</span>
</div>
</div>
<!-- Component preview (rendered as styled blocks) -->
<div v-else class="space-y-2">
<!-- Glass button preview -->
<div v-if="item.id === 'atom-glass-btn'" class="flex gap-3">
<button class="glass-button text-sm">Action</button>
<button class="glass-button text-sm opacity-50 cursor-not-allowed">Disabled</button>
</div>
<div v-else-if="item.id === 'atom-glass-btn-sm'" class="flex gap-3">
<button class="glass-button glass-button-sm text-xs">Small</button>
<button class="glass-button glass-button-sm text-xs opacity-50 cursor-not-allowed">Disabled</button>
</div>
<div v-else-if="item.id === 'atom-icon-btn'" class="flex gap-3">
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
:class="isDark ? 'text-white/70' : 'text-gray-500'">
<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="M12 4v16m8-8H4" />
</svg>
</button>
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
:class="isDark ? 'text-white/70' : 'text-gray-500'">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
:class="isDark ? 'text-white/70' : 'text-gray-500'">
<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>
<div v-else-if="item.id === 'atom-badge'" class="flex flex-wrap gap-1.5">
<span class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Science Fiction</span>
<span class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Drama</span>
<span class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Thriller</span>
</div>
<div v-else-if="item.id === 'mol-glass-card'">
<div class="glass-card p-4">
<h3 class="text-sm font-semibold mb-1" :class="isDark ? 'text-white/90' : 'text-gray-900'">Glass Card</h3>
<p class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-500'">Content with frosted glass background and subtle border.</p>
</div>
</div>
<!-- Nav tab preview -->
<div v-else-if="item.id === 'atom-nav-tab'" class="flex gap-1.5">
<button class="text-xs px-2.5 py-1 rounded-md font-medium bg-accent/20 text-accent">Films</button>
<button class="text-xs px-2.5 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/5 text-white/50' : 'bg-black/5 text-gray-500'">Songs</button>
<button class="text-xs px-2.5 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/5 text-white/50' : 'bg-black/5 text-gray-500'">Podcasts</button>
</div>
<!-- Text input preview -->
<div v-else-if="item.id === 'atom-input'">
<input
class="w-full px-3 py-2 rounded-lg text-base outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/5 text-gray-800 placeholder:text-gray-400 focus:bg-black/10'"
placeholder="Search..."
readonly
/>
</div>
<!-- Scrollbar preview -->
<div v-else-if="item.id === 'atom-scrollbar'" class="space-y-2">
<div class="h-16 overflow-y-auto rounded-lg px-3 py-2"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
style="scrollbar-width: thin;">
<p v-for="n in 8" :key="n" class="text-xs py-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Scrollable content line {{ n }}
</p>
</div>
<p class="text-xs text-center" :class="isDark ? 'text-white/30' : 'text-gray-400'">
4px wide, translucent thumb
</p>
</div>
<!-- Gradient card preview -->
<div v-else-if="item.id === 'mol-gradient-card'">
<div class="gradient-card p-4 rounded-2xl">
<h3 class="text-sm font-semibold mb-1 text-white">Featured</h3>
<p class="text-xs text-white/70">Gradient background card for highlights.</p>
</div>
</div>
<!-- Source link row preview -->
<div v-else-if="item.id === 'mol-source-link'" class="space-y-1.5">
<div class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<div class="flex items-center gap-2.5">
<span class="text-sm">🎬</span>
<div>
<p class="text-xs font-medium" :class="isDark ? 'text-white/80' : 'text-gray-800'">Netflix</p>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">Stream now</p>
</div>
</div>
<svg class="w-3.5 h-3.5" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
<!-- Banner hero preview -->
<div v-else-if="item.id === 'mol-banner-hero'">
<div class="relative w-full aspect-[16/7] rounded-lg overflow-hidden">
<div class="absolute inset-0"
:style="{ background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)' }" />
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<div class="absolute bottom-0 left-0 p-3">
<h3 class="text-sm font-bold text-white/90">Banner Title</h3>
<p class="text-xs text-white/50">Subtitle text</p>
</div>
</div>
</div>
<!-- Cover card preview -->
<div v-else-if="item.id === 'mol-cover-card'" class="flex gap-2">
<div v-for="n in 3" :key="n"
class="flex-1 rounded-xl overflow-hidden">
<div class="aspect-[2/3] relative"
:style="{ background: `linear-gradient(${120 * n}deg, ${['#2d1b69','#1b3a4b','#3b1b2b'][n-1]}, ${['#1a0a3e','#0a2030','#200a1a'][n-1]})` }">
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-1.5">
<p class="text-xs text-white/80 font-medium truncate">{{ ['Film', 'Album', 'Series'][n-1] }}</p>
</div>
</div>
</div>
</div>
<!-- Chat bubble preview -->
<div v-else-if="item.id === 'org-chat-bubble'" class="space-y-2">
<div class="flex justify-end">
<div class="max-w-[80%] px-3 py-2 rounded-2xl text-xs"
:class="isDark ? 'bg-white/10 text-white/90' : 'bg-black/10 text-gray-800'">
What films should I watch?
</div>
</div>
<div class="flex justify-start">
<div class="max-w-[80%] px-3 py-2 text-xs"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
Here are some great picks from your library...
</div>
</div>
</div>
<!-- Content panel preview -->
<div v-else-if="item.id === 'org-content-panel'" class="space-y-2">
<div class="flex gap-1 pb-1.5"
:style="isDark ? 'border-bottom: 1px solid rgba(255,255,255,0.08)' : 'border-bottom: 1px solid rgba(0,0,0,0.06)'">
<span class="text-xs px-2 py-0.5 rounded font-medium bg-accent/20 text-accent">Films</span>
<span class="text-xs px-2 py-0.5 rounded font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Songs</span>
<span class="text-xs px-2 py-0.5 rounded font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Books</span>
</div>
<div class="grid grid-cols-3 gap-1">
<div v-for="n in 6" :key="n" class="aspect-[2/3] rounded-md"
:class="isDark ? 'bg-white/5' : 'bg-black/5'" />
</div>
</div>
<!-- Detail view preview -->
<div v-else-if="item.id === 'org-detail-view'" class="space-y-2">
<div class="relative aspect-[16/7] rounded-lg overflow-hidden"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div class="absolute top-1.5 left-1.5 w-4 h-4 rounded-md flex items-center justify-center"
:class="isDark ? 'bg-white/10' : 'bg-black/10'">
<svg class="w-2.5 h-2.5" :class="isDark ? 'text-white/60' : 'text-gray-500'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</div>
<div class="absolute bottom-1 left-2">
<p class="text-xs font-bold text-white/90">Title</p>
<p class="text-xs text-white/50">Meta</p>
</div>
</div>
<div class="space-y-1 px-1">
<div class="h-1.5 rounded-full w-full" :class="isDark ? 'bg-white/5' : 'bg-black/5'" />
<div class="h-1.5 rounded-full w-3/4" :class="isDark ? 'bg-white/5' : 'bg-black/5'" />
</div>
</div>
<!-- Magazine grid preview -->
<div v-else-if="item.id === 'org-magazine'">
<div class="grid grid-cols-2 gap-px rounded-lg overflow-hidden"
:class="isDark ? 'bg-white/[0.12]' : 'bg-black/[0.08]'">
<div class="col-span-2 px-3 py-3"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
<p class="text-[7px] uppercase tracking-[0.3em] mb-0.5"
:class="isDark ? 'text-white/30' : 'text-gray-400'">Editorial</p>
<p class="text-xs font-serif font-bold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">Hero Headline</p>
</div>
<div class="px-2 py-2" :class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
<p class="text-xs font-serif font-bold"
:class="isDark ? 'text-white/80' : 'text-gray-800'">Half Tile</p>
</div>
<div class="px-2 py-2" :class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
<p class="text-xs font-serif font-bold"
:class="isDark ? 'text-white/80' : 'text-gray-800'">Half Tile</p>
</div>
</div>
</div>
<!-- Nostr note preview -->
<div v-else-if="item.id === 'org-nostr-note'">
<div class="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 items-start gap-2.5">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0"
style="background: rgba(168, 85, 247, 0.2); color: rgba(168, 85, 247, 0.8);">
F
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold" :class="isDark ? 'text-white/80' : 'text-gray-800'">fiatjaf</span>
<span class="text-xs" :class="isDark ? 'text-white/25' : 'text-gray-300'">2h</span>
</div>
<p class="text-xs mt-0.5 leading-relaxed"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
Nostr is the simplest open protocol...
</p>
<div class="flex gap-3 mt-1.5">
<span class="text-xs" :class="isDark ? 'text-white/25' : 'text-gray-300'">3 replies</span>
<span class="text-xs text-amber-500/70">21000 sats</span>
</div>
</div>
</div>
</div>
</div>
<!-- Fade up animation preview -->
<div v-else-if="item.id === 'anim-fade-up'" class="flex flex-col items-center gap-2">
<div :key="fadeUpKey" class="animate-fade-up px-4 py-2 rounded-lg text-xs font-medium"
:class="isDark ? 'bg-white/10 text-white/70' : 'bg-black/10 text-gray-600'">
Fade Up (900ms)
</div>
<button class="text-xs px-2 py-0.5 rounded transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="fadeUpKey++">
Replay
</button>
</div>
<!-- Scale in animation preview -->
<div v-else-if="item.id === 'anim-scale-in'" class="flex flex-col items-center gap-2">
<div :key="scaleInKey" class="animate-scale-in px-4 py-2 rounded-lg text-xs font-medium"
:class="isDark ? 'bg-white/10 text-white/70' : 'bg-black/10 text-gray-600'">
Scale In (250ms)
</div>
<button class="text-xs px-2 py-0.5 rounded transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="scaleInKey++">
Replay
</button>
</div>
<!-- Generic preview fallback -->
<div v-else class="text-center py-4">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
See code below for usage pattern
</p>
</div>
</div>
</div>
</div>
<!-- Used In -->
<div v-if="item.usedIn">
<h4 class="text-xs uppercase tracking-[0.2em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Used In
</h4>
<div class="rounded-xl px-3 py-2.5"
:class="isDark ? 'bg-white/[0.03] border border-white/10' : 'bg-black/[0.02] border border-black/10'">
<p class="text-xs leading-relaxed"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ item.usedIn }}
</p>
</div>
</div>
<!-- Code block -->
<div>
<h4 class="text-xs uppercase tracking-[0.2em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Code
</h4>
<pre class="rounded-xl p-4 text-xs leading-relaxed font-mono overflow-x-auto"
:class="isDark
? 'bg-black/40 text-white/70 border border-white/10'
: 'bg-gray-50 text-gray-700 border border-gray-200'">{{ item.code }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import type { DesignSystemItem } from '@/composables/useContentPanel'
const props = defineProps<{ item: DesignSystemItem }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const copied = ref(false)
const fadeUpKey = ref(0)
const scaleInKey = ref(0)
const categoryLabels: Record<string, string> = {
colors: 'Colors',
typography: 'Typography',
spacing: 'Spacing',
atoms: 'Atoms',
molecules: 'Molecules',
organisms: 'Organisms',
}
const categoryLabel = computed(() => categoryLabels[props.item.category] ?? props.item.category)
const fontStyle = computed(() => {
if (props.item.id === 'type-mono') return { fontFamily: 'Menlo, Monaco, "Courier New", monospace' }
if (props.item.id === 'type-serif') return { fontFamily: 'Georgia, "Times New Roman", Times, serif' }
return { fontFamily: 'Inter, system-ui, -apple-system, sans-serif' }
})
function extractColorValue(code: string): string {
const match = /(?:background-color|color|background):\s*([^;]+)/i.exec(code)
if (!match) return '#333'
return match[1].trim()
}
async function copyCode() {
try {
await navigator.clipboard.writeText(props.item.code)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch { /* ignore */ }
}
</script>
@@ -0,0 +1,192 @@
<template>
<div class="flex flex-col h-full">
<div class="shrink-0 px-4 py-3 flex items-center justify-between gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<span class="text-sm font-semibold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
Design System
</span>
<p class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredItems.length }} items
</p>
</div>
<!-- Category filter -->
<div class="shrink-0 px-4 py-2 flex gap-1.5 overflow-x-auto scrollbar-hide">
<button
v-for="cat in categories"
:key="cat.id"
class="text-xs px-2.5 py-1 rounded-md font-medium whitespace-nowrap transition-colors"
:class="activeCategory === cat.id
? 'bg-accent/20 text-accent'
: isDark
? 'bg-white/5 text-white/50 hover:bg-white/10'
: 'bg-black/5 text-gray-500 hover:bg-black/10'"
@click="activeCategory = cat.id"
>
{{ cat.label }}
</button>
</div>
<!-- Items grid -->
<div class="flex-1 overflow-y-auto px-4 py-3">
<div class="grid grid-cols-2 gap-2">
<button
v-for="item in filteredItems"
:key="item.id"
class="text-left p-3 rounded-xl transition-all duration-150 group relative"
:class="[
codeMode && isDesignTokenSelected(item.id)
? 'ring-2 ring-accent/50 bg-accent/10 cursor-pointer'
: isDark
? 'bg-white/[0.03] hover:bg-white/[0.07] cursor-pointer'
: 'bg-black/[0.02] hover:bg-black/[0.05] cursor-pointer',
]"
@click="selectItem(item)"
>
<!-- Selection toggle (top-right) only this area toggles context selection -->
<div
v-if="codeMode"
class="absolute top-2 right-2 min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center z-10 cursor-pointer transition-colors"
:class="isDesignTokenSelected(item.id)
? 'bg-accent'
: isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-black/10 hover:bg-black/20'"
@click.stop="toggleDesignToken(item.id)"
>
<svg v-if="isDesignTokenSelected(item.id)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<!-- Preview swatch for colors -->
<div v-if="item.category === 'colors' && item.preview === 'inline'"
class="h-8 rounded-md mb-2 border"
:class="isDark ? 'border-white/10' : 'border-black/10'"
:style="{ background: extractColorValue(item.code) }" />
<!-- Preview for spacing -->
<div v-else-if="item.category === 'spacing' && item.preview === 'inline'"
class="h-8 flex items-end gap-0.5 mb-2">
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 30%" />
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 50%" />
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 70%" />
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 100%" />
</div>
<!-- Generic icon for components -->
<div v-else class="h-8 flex items-center mb-2">
<svg class="w-5 h-5 transition-colors"
:class="isDark ? 'text-white/20 group-hover:text-white/40' : 'text-black/15 group-hover:text-black/30'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="item.category === 'atoms'" 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" />
<path v-else-if="item.category === 'molecules'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
<path v-else-if="item.category === 'organisms'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<h3 class="text-xs font-semibold leading-tight mb-0.5"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
{{ item.name }}
</h3>
<p class="text-xs leading-snug line-clamp-2"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ item.description }}
</p>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type DesignSystemItem } from '@/composables/useContentPanel'
import { useCodeContext } from '@/composables/useCodeContext'
const { isDark } = useTheme()
const { openDesignSystemItem } = useContentPanel()
const { codeMode, toggleDesignToken, isDesignTokenSelected } = useCodeContext()
const activeCategory = ref<string>('all')
const categories = [
{ id: 'all', label: 'All' },
{ id: 'colors', label: 'Colors' },
{ id: 'typography', label: 'Typography' },
{ id: 'spacing', label: 'Spacing' },
{ id: 'atoms', label: 'Atoms' },
{ id: 'molecules', label: 'Molecules' },
{ id: 'organisms', label: 'Organisms' },
]
const items: DesignSystemItem[] = [
// Colors
{ id: 'color-bg', name: 'Background', category: 'colors', preview: 'inline', description: 'Primary app background', code: 'background-color: #0a0a0a;\n/* Tailwind: bg-[#0a0a0a] */', usedIn: 'ChatPage, all panels, base layout' },
{ id: 'color-accent', name: 'Accent / Bitcoin', category: 'colors', preview: 'inline', description: 'Primary action color, Bitcoin orange', code: 'color: #F7931A;\n/* Tailwind: text-accent */', usedIn: 'Gradient buttons, active tabs, zap counts, CTA elements' },
{ id: 'color-primary', name: 'Primary', category: 'colors', preview: 'inline', description: 'Primary neutral tone', code: 'color: #606060;\n/* Tailwind: text-primary */', usedIn: 'Secondary text, borders, muted elements' },
{ id: 'color-surface', name: 'Glass Surface', category: 'colors', preview: 'inline', description: 'Glass morphism panel background', code: 'background: rgba(0, 0, 0, 0.35);\nbackdrop-filter: blur(18px);\nborder: 1px solid rgba(255, 255, 255, 0.18);\n/* Tailwind: .glass */', usedIn: 'ChatInput, ContentPanel, all overlay panels' },
{ id: 'color-text-scale', name: 'Text Opacity Scale', category: 'colors', preview: 'inline', description: '/25 placeholder, /40 muted, /60 secondary, /80 body, /90 emphasis', code: '/* Text opacity scale */\n.placeholder { color: rgba(255,255,255, 0.25); }\n.muted { color: rgba(255,255,255, 0.40); }\n.secondary { color: rgba(255,255,255, 0.60); }\n.body { color: rgba(255,255,255, 0.80); }\n.emphasis { color: rgba(255,255,255, 0.90); }\n.heading { color: rgba(255,255,255, 0.96); }', usedIn: 'Every component — consistent hierarchy across the system' },
// Typography
{ id: 'type-body', name: 'Body Font', category: 'typography', description: 'Inter / system-ui for all body text', code: 'font-family: Inter, system-ui, -apple-system, sans-serif;\n/* Applied globally */', usedIn: 'Global default — ChatMessage, grids, detail views' },
{ id: 'type-mono', name: 'Monospace Font', category: 'typography', description: 'Menlo / Monaco for code and IDs', code: 'font-family: Menlo, Monaco, "Courier New", monospace;\n/* Tailwind: font-mono */', usedIn: 'CodeDetail, conversation IDs, relay URLs, metadata' },
{ id: 'type-serif', name: 'Serif Font', category: 'typography', description: 'Georgia for magazine/editorial layouts', code: 'font-family: Georgia, "Times New Roman", Times, serif;\n/* Used in MagazineGrid, AI Brief */', usedIn: 'MagazineGrid, MagazineSectionDetail, AI Brief' },
{ id: 'type-sizes', name: 'Text Sizes', category: 'typography', description: 'Compact scale: 10px labels to 2xl headings', code: '/* Key sizes used */\ntext-xs /* labels, metadata */\ntext-xs /* 12px - secondary text */\ntext-sm /* 14px - body text */\ntext-base /* 16px - primary text */\ntext-lg /* 18px - section headings */\ntext-xl /* 20px - page headings */\ntext-2xl /* 24px - hero text */', usedIn: 'Globally — see specific usage in each size bracket' },
// Spacing
{ id: 'space-grid', name: '4px Grid', category: 'spacing', preview: 'inline', description: 'All spacing follows a 4px base grid', code: '/* 4px grid system */\n1 = 4px /* micro gap */\n2 = 8px /* tight gap */\n3 = 12px /* small padding */\n4 = 16px /* standard padding */\n5 = 20px /* section padding */\n6 = 24px /* large gap */\n8 = 32px /* section spacing */\n12 = 48px /* large sections */', usedIn: 'Every layout — padding, margins, gaps between elements' },
{ id: 'space-radius', name: 'Border Radius', category: 'spacing', preview: 'inline', description: 'Rounded corners from subtle to full', code: '/* Border radius scale */\nrounded-md /* 6px - badges, tags */\nrounded-lg /* 8px - buttons, inputs */\nrounded-xl /* 12px - cards, panels */\nrounded-2xl /* 16px - large panels */\nrounded-full /* pill buttons */', usedIn: 'Badges (md), buttons (lg), cards (xl), panels (2xl)' },
// Atoms
{ id: 'atom-glass-btn', name: 'Glass Button', category: 'atoms', description: '48px height, glass morphism background', code: '<button class="glass-button">\n Action\n</button>\n\n/* glass-button:\n height: 48px\n background: rgba(0,0,0,0.6)\n backdrop-filter: blur(18px)\n border-radius: 12px\n border: 1px solid rgba(255,255,255,0.12)\n*/', usedIn: 'ChatInput send, modal actions, primary controls' },
{ id: 'atom-glass-btn-sm', name: 'Glass Button Small', category: 'atoms', description: 'Compact glass button variant', code: '<button class="glass-button-sm">\n Small\n</button>\n\n/* Compact variant of glass-button */', usedIn: 'ChatInput send/stop buttons, inline actions' },
{ id: 'atom-icon-btn', name: 'Icon Button', category: 'atoms', description: 'Path glass icon, 32-36px square', code: '<button class="w-9 h-9 rounded-xl path-glass-icon\n flex items-center justify-center">\n <svg class="w-4 h-4" ...>\n</button>\n\n/* path-glass-icon:\n background: transparent\n transition: colors\n hover: bg-white/10\n*/', usedIn: 'ChatHeader toolbar, detail back buttons, close buttons' },
{ id: 'atom-badge', name: 'Genre Badge', category: 'atoms', description: 'Tiny pill badge for tags/genres', code: '<span class="text-xs px-2 py-1 rounded-md\n font-medium bg-white/10 text-white/60">\n Science Fiction\n</span>', usedIn: 'FilmGrid, SongGrid, BookGrid, TVSeriesGrid genre filters' },
{ id: 'atom-nav-tab', name: 'Nav Tab', category: 'atoms', description: 'Content panel tab with active state', code: '<button class="nav-tab-active">\n Films\n</button>\n\n/* Active: accent underline\n Inactive: text-white/50 hover:text-white\n Transition: 200ms */', usedIn: 'ContentPanel tab bar, mobile content tab filters' },
{ id: 'atom-input', name: 'Text Input', category: 'atoms', description: 'Search/filter input field', code: '<input\n class="w-full px-3 py-2 rounded-lg text-xs\n outline-none transition-colors\n bg-white/5 text-white/80\n placeholder:text-white/25\n focus:bg-white/10"\n placeholder="Search..."\n/>', usedIn: 'All grid search bars, ProjectGrid new project' },
{ id: 'atom-scrollbar', name: 'Custom Scrollbar', category: 'atoms', description: 'Thin translucent scrollbar for scroll areas', code: '.custom-scrollbar::-webkit-scrollbar {\n width: 4px;\n}\n.custom-scrollbar::-webkit-scrollbar-thumb {\n background: rgba(255,255,255, 0.1);\n border-radius: 2px;\n}\n/* Also: .scrollbar-hide hides completely */', usedIn: 'Content grids, chat message list, file trees' },
// Molecules
{ id: 'mol-glass-card', name: 'Glass Card', category: 'molecules', description: 'Frosted glass card with border', code: '<div class="glass-card">\n <h3>Title</h3>\n <p>Content</p>\n</div>\n\n/* glass-card:\n background: rgba(0,0,0,0.65)\n backdrop-filter: blur(18px)\n border: 1px solid rgba(255,255,255,0.12)\n border-radius: 16px\n padding: 16px\n*/', usedIn: 'ChatWindow container, content panel wrapper' },
{ id: 'mol-gradient-card', name: 'Gradient Card', category: 'molecules', description: 'Card with gradient background', code: '<div class="gradient-card">\n <h3>Featured</h3>\n <p>Content</p>\n</div>\n\n/* gradient-card:\n background: linear-gradient(135deg, ...)\n border-radius: 16px\n*/', usedIn: 'Featured content highlights, promotional sections' },
{ id: 'mol-source-link', name: 'Source Link Row', category: 'molecules', description: 'Icon + label + external link arrow', code: '<a class="flex items-center justify-between\n p-3 rounded-xl bg-white/5\n hover:bg-white/10 transition-colors">\n <div class="flex items-center gap-2.5">\n <span class="text-sm">icon</span>\n <div>\n <p class="text-xs font-medium\n text-white/80">Name</p>\n <p class="text-xs\n text-white/30">Description</p>\n </div>\n </div>\n <svg><!-- external link icon --></svg>\n</a>', usedIn: 'FilmDetail, SongDetail, PodcastDetail sources' },
{ id: 'mol-banner-hero', name: 'Banner Hero', category: 'molecules', description: 'Aspect 16/7 image with gradient overlay', code: '<div class="relative w-full aspect-[16/7]\n overflow-hidden">\n <img :src="url" class="absolute inset-0\n w-full h-full object-cover" />\n <div class="absolute inset-0\n bg-gradient-to-t from-black/80\n via-black/30 to-transparent" />\n <div class="absolute bottom-0 p-4">\n <h2 class="text-lg font-bold\n text-white">Title</h2>\n </div>\n</div>', usedIn: 'FilmDetail, TVSeriesDetail, BookDetail banners' },
{ id: 'mol-cover-card', name: 'Cover Card', category: 'molecules', description: 'Poster/cover image card with overlay text', code: '<button class="group rounded-2xl overflow-hidden">\n <div class="aspect-[2/3] relative">\n <img class="w-full h-full object-cover\n group-hover:scale-110\n transition-transform duration-300" />\n <div class="absolute inset-0\n bg-gradient-to-t from-black/60\n to-transparent" />\n <div class="absolute bottom-0 p-2">\n <p class="text-xs text-white/90">\n Title</p>\n </div>\n </div>\n</button>', usedIn: 'FilmGrid, TVSeriesGrid, SongGrid, BookGrid cards' },
// Organisms
{ id: 'org-chat-bubble', name: 'Chat Bubble', category: 'organisms', description: 'AI/User message bubble with streaming', code: '<!-- User bubble -->\n<div class="flex justify-end">\n <div class="glass-card max-w-[85%]\n px-4 py-3 text-sm text-white/90">\n Message text\n </div>\n</div>\n\n<!-- AI bubble -->\n<div class="flex justify-start">\n <div class="max-w-[85%] px-4 py-3\n text-sm text-white/80">\n Response with markdown\n </div>\n</div>', usedIn: 'ChatMessage.vue — the primary chat interface' },
{ id: 'org-content-panel', name: 'Content Panel', category: 'organisms', description: 'Tabs + grid + detail navigation', code: '<!-- Structure -->\n<div class="flex flex-col h-full">\n <!-- Tab bar -->\n <div class="flex gap-1 px-3 py-2">\n <button class="nav-tab">Tab</button>\n </div>\n <!-- Grid view -->\n <ContentGridView />\n <!-- or Detail view -->\n <DetailView />\n</div>', usedIn: 'ChatPage middle column, mobile Content tab' },
{ id: 'org-detail-view', name: 'Detail View', category: 'organisms', description: 'Full detail with banner, back button, metadata', code: '<!-- Pattern: Banner → Meta → Content -->\n<div class="h-full overflow-y-auto">\n <!-- Banner with back button -->\n <div class="relative aspect-[16/7]">\n <img class="object-cover" />\n <div class="gradient-overlay" />\n <button class="absolute top-3 left-3\n path-glass-icon">Back</button>\n <div class="absolute bottom-0 p-4">\n <h2>Title</h2>\n <div>Metadata</div>\n </div>\n </div>\n <!-- Body -->\n <div class="p-4 space-y-4">\n <p>Description</p>\n <div>Genre badges</div>\n <div>Source links</div>\n </div>\n</div>', usedIn: 'FilmDetail, BookDetail, TVSeriesDetail, SongDetail, PodcastDetail' },
{ id: 'org-magazine', name: 'Magazine Grid', category: 'organisms', description: 'Editorial tile layout with hero, wide, and half tiles', code: '<!-- Magazine structure -->\n<div class="grid grid-cols-2 gap-px\n bg-white/12">\n <!-- Wide tile (col-span-2) -->\n <button class="col-span-2 px-5 py-5\n bg-[#0a0a0a]">\n <p class="text-xs uppercase\n tracking-[0.3em]">Label</p>\n <h2 class="font-serif text-lg\n font-bold">Title</h2>\n <p class="font-serif text-sm">Text</p>\n </button>\n <!-- Half tiles -->\n <button class="px-4 py-4 bg-[#0a0a0a]">\n <h3 class="font-serif text-sm\n font-bold">Title</h3>\n <p class="font-serif text-xs">Text</p>\n </button>\n</div>', usedIn: 'MagazineGrid.vue — AI Brief editorial view' },
{ id: 'org-nostr-note', name: 'Nostr Note', category: 'organisms', description: 'Note card with avatar, author, content, zaps', code: '<div class="p-3 rounded-xl bg-white/[0.03]\n border border-white/5">\n <div class="flex items-start gap-2.5">\n <div class="w-8 h-8 rounded-full\n bg-purple-500/20 text-purple-400">\n F\n </div>\n <div class="flex-1">\n <span class="text-xs font-semibold">\n author</span>\n <p class="text-xs text-white/60">\n Note content...</p>\n <span class="text-xs\n text-amber-500/70">21000 sats</span>\n </div>\n </div>\n</div>', usedIn: 'NostrGrid.vue — Nostr feed tab' },
// Animations
{ id: 'anim-fade-up', name: 'Fade Up', category: 'atoms', description: 'Entry animation: translate + opacity', code: '.animate-fade-up {\n animation: fadeUp 900ms ease-out;\n}\n@keyframes fadeUp {\n from {\n opacity: 0;\n transform: translateY(16px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n/* Also: animate-fade-up-fast (400ms) */', usedIn: 'Empty states, initial load elements, ChatWindow' },
{ id: 'anim-scale-in', name: 'Scale In', category: 'atoms', description: 'Micro entrance with scale and opacity', code: '.animate-scale-in {\n animation: scaleIn 250ms ease-out;\n}\n@keyframes scaleIn {\n from {\n opacity: 0;\n transform: scale(0.95);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}', usedIn: 'Modal entries, tooltip appearances, popovers' },
]
const filteredItems = computed(() => {
if (activeCategory.value === 'all') return items
return items.filter(i => i.category === activeCategory.value)
})
function selectItem(item: DesignSystemItem) {
openDesignSystemItem(item)
}
function extractColorValue(code: string): string {
const match = /(?:background-color|color|background):\s*([^;]+)/i.exec(code)
if (!match) return '#333'
const val = match[1].trim()
if (val.startsWith('#') || val.startsWith('rgb') || val.startsWith('hsl')) return val
return '#333'
}
</script>
@@ -0,0 +1,117 @@
<template>
<FilmDetail
v-if="selectedFilm"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<SongDetail
v-else-if="selectedSong"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<BookDetail
v-else-if="selectedBook"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:image="selectedImage"
@back="closeImageDetail"
/>
<PlaceDetail
v-else-if="selectedPlace"
:place="selectedPlace"
@back="closePlaceDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<WebsiteDetail
v-else-if="selectedWebsite"
:website="selectedWebsite"
@back="closeWebsiteDetail"
/>
<MagazineSectionDetail
v-else-if="selectedMagazineSection"
:section="selectedMagazineSection"
:current-index="magazineSectionIndex"
:total-sections="panelMagazineSections.length"
@back="closeMagazineSectionDetail"
@navigate="navigateMagazineSection"
/>
<CodeDetail
v-else-if="isCodeMode && activeCodeFile"
@back="closeCodeFile"
/>
<DesignSystemDetail
v-else-if="selectedDesignSystemItem"
:item="selectedDesignSystemItem"
@back="closeDesignSystemItem"
/>
</template>
<script setup lang="ts">
import { useContentPanel } from '@/composables/useContentPanel'
import FilmDetail from './FilmDetail.vue'
import BookDetail from './BookDetail.vue'
import TVSeriesDetail from './TVSeriesDetail.vue'
import SongDetail from './SongDetail.vue'
import PodcastDetail from './PodcastDetail.vue'
import ImageDetail from './ImageDetail.vue'
import PlaceDetail from './PlaceDetail.vue'
import ArticleDetail from './ArticleDetail.vue'
import WebsiteDetail from './WebsiteDetail.vue'
import MagazineSectionDetail from './MagazineSectionDetail.vue'
import CodeDetail from './CodeDetail.vue'
import DesignSystemDetail from './DesignSystemDetail.vue'
import { useCodeContext } from '@/composables/useCodeContext'
const { isCodeMode, activeFile: activeCodeFile } = useCodeContext()
function closeCodeFile() {
const { activeFile, activeFileContent } = useCodeContext()
activeFile.value = null
activeFileContent.value = ''
}
const {
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
closeFilmDetail,
closeBookDetail,
closeTVSeriesDetail,
closeImageDetail,
closePlaceDetail,
closeSongDetail,
closePodcastDetail,
closeArticleDetail,
selectedWebsite,
closeWebsiteDetail,
selectedMagazineSection,
magazineSectionIndex,
panelMagazineSections,
closeMagazineSectionDetail,
navigateMagazineSection,
selectedDesignSystemItem,
closeDesignSystemItem,
} = useContentPanel()
</script>
@@ -0,0 +1,421 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<h3 class="text-sm font-bold text-white/90 mb-3">Discover</h3>
<!-- Sub-tabs -->
<div class="flex gap-1.5 flex-wrap">
<button
v-for="tab in subTabs"
:key="tab.id"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeSubTab === tab.id
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeSubTab = tab.id"
>
{{ tab.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<!-- For You -->
<template v-if="activeSubTab === 'foryou'">
<div v-if="forYouItems.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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
<p class="text-xs text-white/30">Add favorites to get personalized suggestions</p>
</div>
<div
v-for="item in forYouItems.slice(0, 30)"
:key="item.id"
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150 cursor-pointer"
>
<span class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(item.type)">
{{ typeIcon(item.type) }}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold truncate text-white/80">{{ item.title }}</div>
<div v-if="item.subtitle" class="text-xs truncate text-white/40">{{ item.subtitle }}</div>
</div>
<span class="text-xs text-white/20 shrink-0">{{ item.type }}</span>
</div>
</template>
<!-- Recent -->
<template v-else-if="activeSubTab === 'recent'">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/30">{{ viewHistory.length }} items</span>
<button
v-if="viewHistory.length > 0"
class="text-xs text-red-400/50 hover:text-red-400/80 transition-colors"
@click="clearHistory"
>
Clear
</button>
</div>
<div v-if="viewHistory.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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-xs text-white/30">No recently viewed items</p>
</div>
<div
v-for="entry in viewHistory"
:key="entry.id + entry.viewedAt"
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150 cursor-pointer"
>
<span class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(entry.type)">
{{ typeIcon(entry.type) }}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold truncate text-white/80">{{ entry.title }}</div>
<div v-if="entry.subtitle" class="text-xs truncate text-white/40">{{ entry.subtitle }}</div>
</div>
<span class="text-xs text-white/20 shrink-0">{{ timeAgo(entry.viewedAt) }}</span>
</div>
</template>
<!-- Trending -->
<template v-else-if="activeSubTab === 'trending'">
<div v-if="trending.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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<p class="text-xs text-white/30">No trending items yet</p>
</div>
<div
v-for="item in trending"
:key="item.id"
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150"
>
<span class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(item.type)">
{{ typeIcon(item.type) }}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold truncate text-white/80">{{ item.title }}</div>
</div>
<span class="text-xs px-1.5 py-0.5 rounded bg-accent/15 text-accent/80 shrink-0">
{{ item.count }}x
</span>
</div>
</template>
<!-- Collections -->
<template v-else-if="activeSubTab === 'collections'">
<!-- Create new -->
<div class="flex gap-2 mb-3">
<input
v-model="newCollectionName"
type="text"
placeholder="New collection name..."
class="flex-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
@keydown.enter="createNewCollection"
/>
<button
class="px-2.5 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!newCollectionName.trim()"
@click="createNewCollection"
>
Create
</button>
</div>
<div v-if="collections.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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="text-xs text-white/30">No collections yet</p>
</div>
<div
v-for="col in collections"
:key="col.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 truncate">{{ col.name }}</p>
<p v-if="col.description" class="text-xs text-white/30 truncate">{{ col.description }}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<span class="text-xs text-white/25">{{ col.items.length }} items</span>
<button
class="text-xs px-1.5 py-0.5 rounded text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
@click="deleteCollection(col.id)"
>
Delete
</button>
</div>
</div>
<!-- Mosaic thumbnails -->
<div v-if="col.items.length > 0" class="grid grid-cols-4 gap-1">
<div
v-for="item in col.items.slice(0, 4)"
:key="item.id"
class="aspect-square rounded bg-white/5 flex items-center justify-center"
>
<span class="text-xs font-bold" :class="typeStyle(item.type)">{{ typeIcon(item.type) }}</span>
</div>
</div>
<!-- Items list -->
<div v-for="item in col.items" :key="item.id" class="flex items-center gap-2 text-xs">
<span :class="typeStyle(item.type)" class="w-4 h-4 rounded flex items-center justify-center text-[7px] shrink-0">{{ typeIcon(item.type) }}</span>
<span class="text-white/60 truncate flex-1">{{ item.title }}</span>
<button
class="text-red-400/40 hover:text-red-400/70 transition-colors text-xs shrink-0"
@click="removeFromCollection(col.id, item.id)"
>
x
</button>
</div>
</div>
</template>
<!-- Tags -->
<template v-else-if="activeSubTab === 'tags'">
<div v-if="tagCloud.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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="text-xs text-white/30">No tags yet</p>
<p class="text-xs text-white/20">Tag items from content cards to organize them</p>
</div>
<!-- Tag cloud -->
<div v-if="tagCloud.length > 0" class="flex flex-wrap gap-1.5 mb-4">
<button
v-for="tc in tagCloud"
:key="tc.tag"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeTagFilter === tc.tag
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 bg-white/5 hover:bg-white/10'"
@click="activeTagFilter = activeTagFilter === tc.tag ? null : tc.tag"
>
{{ tc.tag }} <span class="text-white/20 ml-0.5">{{ tc.count }}</span>
</button>
</div>
<!-- Filtered items by tag -->
<div v-if="activeTagFilter" class="space-y-2">
<p class="text-xs text-white/30">Items tagged "{{ activeTagFilter }}"</p>
<div
v-for="itemId in getItemsByTag(activeTagFilter)"
:key="itemId"
class="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/5"
>
<span class="text-xs text-white/60 font-mono truncate">{{ itemId }}</span>
<button
class="text-xs text-red-400/50 hover:text-red-400/80 transition-colors shrink-0"
@click="removeTag(itemId, activeTagFilter!)"
>
untag
</button>
</div>
</div>
</template>
<!-- Smart Playlists -->
<template v-else-if="activeSubTab === 'playlists'">
<div v-if="!hasAnySongs" 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="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
<p class="text-xs text-white/30">No music data yet</p>
</div>
<template v-else>
<!-- Recently played songs -->
<div v-if="recentSongs.length > 0" class="mb-4">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">Recently Played</p>
<div
v-for="entry in recentSongs.slice(0, 10)"
:key="entry.id"
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 mb-1 transition-colors"
>
<span class="text-xs w-5 h-5 rounded flex items-center justify-center shrink-0 bg-green-500/20 text-green-400">S</span>
<div class="flex-1 min-w-0">
<p class="text-xs text-white/70 truncate">{{ entry.title }}</p>
<p v-if="entry.subtitle" class="text-xs text-white/30 truncate">{{ entry.subtitle }}</p>
</div>
<span class="text-xs text-white/20 shrink-0">{{ timeAgo(entry.viewedAt) }}</span>
</div>
</div>
<!-- Most played songs -->
<div v-if="mostPlayedSongs.length > 0" class="mb-4">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">Most Played</p>
<div
v-for="item in mostPlayedSongs.slice(0, 10)"
:key="item.id"
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 mb-1 transition-colors"
>
<span class="text-xs w-5 h-5 rounded flex items-center justify-center shrink-0 bg-green-500/20 text-green-400">S</span>
<div class="flex-1 min-w-0">
<p class="text-xs text-white/70 truncate">{{ item.title }}</p>
</div>
<span class="text-xs px-1.5 py-0.5 rounded bg-green-400/15 text-green-400/80 shrink-0">{{ item.count }}x</span>
</div>
</div>
<!-- By genre -->
<div v-if="songsByGenre.length > 0" class="mb-4">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">By Genre</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="genre in songsByGenre"
:key="genre.genre"
class="text-xs px-2 py-1 rounded-md bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
>
{{ genre.genre }} <span class="text-white/20">{{ genre.count }}</span>
</button>
</div>
</div>
<!-- By decade -->
<div v-if="songsByDecade.length > 0">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">By Decade</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="dec in songsByDecade"
:key="dec.decade"
class="text-xs px-2 py-1 rounded-md bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
>
{{ dec.decade }}s <span class="text-white/20">{{ dec.count }}</span>
</button>
</div>
</div>
</template>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForYouFeed, useContentTags, useViewHistory, useTrending } from '@/composables/useContentDiscovery'
import { useContentCollections } from '@/composables/useContentCollections'
import { useFavoritesStore, type FavoriteType } from '@/stores/favorites'
type SubTab = 'foryou' | 'recent' | 'trending' | 'collections' | 'tags' | 'playlists'
const subTabs: { id: SubTab; label: string }[] = [
{ id: 'foryou', label: 'For You' },
{ id: 'recent', label: 'Recent' },
{ id: 'trending', label: 'Trending' },
{ id: 'collections', label: 'Collections' },
{ id: 'tags', label: 'Tags' },
{ id: 'playlists', label: 'Playlists' },
]
const activeSubTab = ref<SubTab>('foryou')
// M13.1 — For You
const { forYouItems } = useForYouFeed()
// M13.2 — Tags
const { tagCloud, getItemsByTag, removeTag } = useContentTags()
const activeTagFilter = ref<string | null>(null)
// M13.5 — Recent
const { viewHistory, clearHistory } = useViewHistory()
// M13.6 — Collections
const { collections, createCollection, deleteCollection, removeFromCollection } = useContentCollections()
const newCollectionName = ref('')
function createNewCollection() {
const name = newCollectionName.value.trim()
if (!name) return
createCollection(name)
newCollectionName.value = ''
}
// M13.7 — Trending
const { trending } = useTrending()
// M13.3 — Smart Playlists
const favoritesStore = useFavoritesStore()
const recentSongs = computed(() =>
viewHistory.value.filter(h => h.type === 'song')
)
const mostPlayedSongs = computed(() =>
trending.value.filter(t => t.type === 'song')
)
const hasAnySongs = computed(() =>
recentSongs.value.length > 0 || mostPlayedSongs.value.length > 0 || favoritesStore.getFavoritesByType('song').length > 0
)
const songsByGenre = computed(() => {
const songFavs = favoritesStore.getFavoritesByType('song')
const counts = new Map<string, number>()
for (const fav of songFavs) {
const data = fav.data as { genres?: string[] } | undefined
for (const g of data?.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([genre, count]) => ({ genre, count }))
})
const songsByDecade = computed(() => {
const songFavs = favoritesStore.getFavoritesByType('song')
const counts = new Map<number, number>()
for (const fav of songFavs) {
const data = fav.data as { year?: number } | undefined
if (data?.year) {
const decade = Math.floor(data.year / 10) * 10
counts.set(decade, (counts.get(decade) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => a[0] - b[0])
.map(([decade, count]) => ({ decade, count }))
})
// Helpers
function typeIcon(type: string): string {
const icons: Record<string, string> = {
film: 'F', song: 'S', podcast: 'P', book: 'B', tv: 'T', place: 'L', article: 'A',
}
return icons[type] ?? '?'
}
function typeStyle(type: string): string {
const colors: Record<string, string> = {
film: 'bg-blue-500/20 text-blue-400',
song: 'bg-green-500/20 text-green-400',
podcast: 'bg-orange-500/20 text-orange-400',
book: 'bg-yellow-500/20 text-yellow-400',
tv: 'bg-indigo-500/20 text-indigo-400',
place: 'bg-red-500/20 text-red-400',
article: 'bg-cyan-500/20 text-cyan-400',
}
return colors[type] ?? 'bg-white/10 text-white/40'
}
function timeAgo(ts: number): string {
const diff = Date.now() - ts
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
</script>
@@ -0,0 +1,162 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
Favorites
</h3>
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredItems.length }} saved
</span>
</div>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="filter in typeFilters"
:key="filter.id"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeType === filter.id
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="activeType = activeType === filter.id ? null : filter.id"
>
{{ filter.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<div
v-if="filteredItems.length === 0"
class="flex flex-col items-center justify-center py-12 gap-2"
>
<svg
class="w-8 h-8"
:class="isDark ? 'text-white/10' : 'text-gray-200'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No favorites yet
</p>
</div>
<div
v-for="item in filteredItems"
:key="item.id"
class="flex items-center gap-3 p-3 rounded-xl transition-all duration-150"
:class="isDark
? 'bg-white/[0.03] hover:bg-white/[0.07] border border-white/5'
: 'bg-black/[0.02] hover:bg-black/[0.05] border border-black/5'"
>
<span
class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0"
:class="typeStyle(item.type)"
>
{{ typeIcon(item.type) }}
</span>
<div class="flex-1 min-w-0">
<div
class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ item.title }}
</div>
<div
v-if="item.subtitle"
class="text-xs truncate"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
{{ item.subtitle }}
</div>
</div>
<button
class="shrink-0 text-accent/60 hover:text-accent transition-colors p-1"
aria-label="Remove from favorites"
@click="store.removeFavorite(item.id)"
>
<svg class="w-3.5 h-3.5" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useFavoritesStore, type FavoriteType } from '@/stores/favorites'
const { isDark } = useTheme()
const store = useFavoritesStore()
const activeType = ref<FavoriteType | null>(null)
const typeFilters: { id: FavoriteType; label: string }[] = [
{ id: 'film', label: 'Films' },
{ id: 'song', label: 'Songs' },
{ id: 'podcast', label: 'Podcasts' },
{ id: 'book', label: 'Books' },
{ id: 'tv', label: 'TV' },
{ id: 'place', label: 'Places' },
]
const filteredItems = computed(() => {
if (activeType.value) {
return store.getFavoritesByType(activeType.value)
}
return store.sortedItems
})
function typeIcon(type: string): string {
const icons: Record<string, string> = {
film: 'F', song: 'S', podcast: 'P', book: 'B', tv: 'T', place: 'L', article: 'A',
}
return icons[type] ?? '?'
}
function typeStyle(type: string): string {
if (isDark.value) {
const colors: Record<string, string> = {
film: 'bg-blue-500/20 text-blue-400',
song: 'bg-green-500/20 text-green-400',
podcast: 'bg-orange-500/20 text-orange-400',
book: 'bg-yellow-500/20 text-yellow-400',
tv: 'bg-indigo-500/20 text-indigo-400',
place: 'bg-red-500/20 text-red-400',
article: 'bg-cyan-500/20 text-cyan-400',
}
return colors[type] ?? 'bg-white/10 text-white/40'
}
const colors: Record<string, string> = {
film: 'bg-blue-50 text-blue-600',
song: 'bg-green-50 text-green-600',
podcast: 'bg-orange-50 text-orange-600',
book: 'bg-yellow-50 text-yellow-600',
tv: 'bg-indigo-50 text-indigo-600',
place: 'bg-red-50 text-red-600',
article: 'bg-cyan-50 text-cyan-600',
}
return colors[type] ?? 'bg-gray-50 text-gray-600'
}
</script>
@@ -0,0 +1,132 @@
<template>
<div class="group/node">
<div
class="w-full flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors cursor-pointer"
:class="[
isActive
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
]"
:style="{ paddingLeft: `${depth * 12 + 8}px` }"
@click="handleClick"
>
<!-- Expand/collapse for directories -->
<svg
v-if="entry.isDirectory"
class="w-3 h-3 shrink-0 transition-transform duration-150"
:class="expanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<!-- File/folder icon -->
<svg class="w-3.5 h-3.5 shrink-0"
:class="entry.isDirectory
? 'text-accent/70'
: isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="entry.isDirectory" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate flex-1">{{ entry.name }}</span>
<!-- Context selector checkbox (files: far right, visible on hover or when selected) -->
<button
v-if="!entry.isDirectory"
class="shrink-0 w-4 h-4 rounded-full border flex items-center justify-center transition-all ml-auto"
:class="[
isSelected
? 'bg-accent border-accent text-white'
: isDark
? 'border-white/20 opacity-0 group-hover/node:opacity-100 hover:border-white/40'
: 'border-black/15 opacity-0 group-hover/node:opacity-100 hover:border-black/30',
]"
aria-label="Toggle file for chat context"
@click.stop="handleToggleContext"
>
<svg v-if="isSelected" class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Context selector for directories (top-right, visible on hover or when selected) -->
<button
v-if="entry.isDirectory"
class="shrink-0 w-4 h-4 rounded-full border flex items-center justify-center transition-all ml-auto"
:class="[
isDirSelected
? 'bg-accent border-accent text-white'
: isDark
? 'border-white/20 opacity-0 group-hover/node:opacity-100 hover:border-white/40'
: 'border-black/15 opacity-0 group-hover/node:opacity-100 hover:border-black/30',
]"
aria-label="Add folder to chat context"
@click.stop="handleToggleDirContext"
>
<svg v-if="isDirSelected" class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
<!-- Children (when expanded) -->
<div v-if="entry.isDirectory && expanded && entry.children">
<FileTreeNode
v-for="child in entry.children"
:key="child.path"
:entry="child"
:active-file="activeFile"
:depth="depth + 1"
@select="$emit('select', $event)"
@toggle-context="$emit('toggle-context', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext, type FileEntry } from '@/composables/useCodeContext'
const props = defineProps<{
entry: FileEntry
activeFile: string | null
depth: number
}>()
const { isFileSelected } = useCodeContext()
const emit = defineEmits<{
select: [path: string]
'toggle-context': [path: string]
}>()
const { isDark } = useTheme()
const expanded = ref(props.depth < 1) // Auto-expand first level
const isActive = computed(() => !props.entry.isDirectory && props.activeFile === props.entry.path)
const isSelected = computed(() => !props.entry.isDirectory && isFileSelected(props.entry.path))
const isDirSelected = computed(() => props.entry.isDirectory && isFileSelected(props.entry.path))
function handleClick() {
if (props.entry.isDirectory) {
expanded.value = !expanded.value
} else {
// Click opens file in code viewer
emit('select', props.entry.path)
}
}
function handleToggleContext() {
emit('toggle-context', props.entry.path)
}
function handleToggleDirContext() {
emit('toggle-context', props.entry.path)
}
</script>
@@ -0,0 +1,88 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select', film)"
>
<div class="poster-card-sm shrink-0 w-12 aspect-[2/3] rounded-lg overflow-hidden">
<img
v-if="film.posterUrl"
:src="film.posterUrl"
:alt="film.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="(e) => handleImgError(e, film.title, film.year)"
/>
<div
v-else
class="w-full h-full rounded-[6px]"
:class="isDark ? 'bg-white/10' : 'bg-black/5'"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ film.title }}</p>
<p class="text-xs mt-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ film.year }}<template v-if="film.director"> · {{ film.director }}</template>
</p>
<p v-if="isExternal && film.synopsis"
class="text-xs mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/35' : 'text-gray-400'">
{{ film.synopsis }}
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="film.rating > 0"
class="text-xs font-semibold px-1.5 py-0.5 rounded"
:class="ratingClass">
{{ film.rating }}
</span>
<span v-if="isExternal"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-info/15 text-info/70' : 'bg-info/10 text-blue-600'">
not in library
</span>
<span
v-for="src in film.sources.slice(0, 3)"
:key="src.type"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ src.type }}
</span>
<FavoriteButton
class="ml-auto"
:favorited="favoritesStore.isFavorited(film.id)"
@toggle="favoritesStore.toggleFavorite({ id: film.id, type: 'film', title: film.title, subtitle: `${film.year} · ${film.director}`, data: film })"
/>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { handleImgError } from '@/composables/useImageFallback'
import FavoriteButton from '@/components/ui/FavoriteButton.vue'
import { useFavoritesStore } from '@/stores/favorites'
const props = defineProps<{ film: Film }>()
defineEmits<{ select: [film: Film] }>()
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
const ratingClass = computed(() => {
const r = props.film.rating
if (r >= 8.5) return isDark.value ? 'bg-success/20 text-success' : 'bg-success/10 text-green-700'
if (r >= 7.5) return isDark.value ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-amber-700'
return isDark.value ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'
})
</script>
@@ -0,0 +1,158 @@
<template>
<div class="film-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="bannerSrc"
:src="bannerSrc"
:alt="film.title"
class="absolute inset-0 w-full h-full object-cover object-center block"
@error="onBannerError"
/>
<div
v-else
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-if="playableSource"
class="absolute inset-0 flex items-center justify-center z-[5] group/play"
aria-label="Watch film"
@click="openVideo"
>
<span class="w-20 h-20 rounded-full flex items-center justify-center path-glass-icon group-hover/play:scale-110 transition-transform">
<svg class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</span>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ film.title }}</h2>
<div class="flex items-center gap-2 mt-1 text-xs text-white/60">
<span class="text-accent font-bold"> {{ film.rating }}</span>
<span>{{ film.year }}</span>
<span>{{ film.runtime }}m</span>
<span>{{ film.director }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<div v-if="film.genres.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in film.genres"
:key="genre"
class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div v-if="film.synopsis">
<h4 v-if="isExternal"
class="text-xs uppercase tracking-[0.2em] font-semibold mb-1.5"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
Why watch
</h4>
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ film.synopsis }}
</p>
</div>
<div v-if="film.cast.length">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Cast</h4>
<p class="text-sm" :class="isDark ? 'text-white/70' : 'text-gray-700'">
{{ film.cast.join(', ') }}
</p>
</div>
<div v-if="film.sources.length">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Watch on</h4>
<div class="space-y-2">
<a
v-for="src in film.sources"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
<p class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ src.quality }}</p>
</div>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchFilmImage } from '@/composables/useImageFallback'
import { useVideoPlayerStore } from '@/stores/videoPlayer'
const props = defineProps<{ film: Film }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const videoStore = useVideoPlayerStore()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
const playableSource = computed(() =>
props.film.sources.find(s => s.type === 'youtube' || s.url.includes('youtube.com'))
)
function openVideo() {
if (!playableSource.value) return
videoStore.open(playableSource.value.url, props.film.title, props.film.posterUrl || props.film.backdropUrl)
}
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.film.backdropUrl, props.film.posterUrl],
apiFetch: () => fetchFilmImage(props.film.title, props.film.year),
title: () => props.film.title,
})
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
plex: '🟧',
nextcloud: '☁️',
youtube: '▶️',
'free-web': '🌐',
}
return icons[type] ?? '📺'
}
</script>
@@ -0,0 +1,164 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredFilms.length }} films
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search films..."
class="w-full px-3 py-2 rounded-lg text-base outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="film in filteredFilms"
:key="film.id"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
:aria-label="`${film.title} (${film.year})`"
@click="$emit('selectFilm', film)"
>
<div class="poster-card flex-1 min-h-0">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(film) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="isLoading(film)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(film)"
:src="coverSrc(film)!"
:alt="`${film.title} (${film.year}) directed by ${film.director}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(film)"
/>
<img
v-else-if="!isLoading(film)"
:src="fallbackSrc(film)"
:alt="film.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(film)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ film.title }}
</p>
<div class="flex items-center gap-1 mt-0.5">
<span class="text-xs text-accent font-bold"> {{ film.rating }}</span>
<span class="text-xs text-white/40">{{ film.year }}</span>
</div>
</div>
<div class="absolute top-1.5 right-1.5 flex gap-0.5">
<span
v-for="src in film.sources.slice(0, 2)"
:key="src.type"
class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"
>
{{ src.type }}
</span>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredFilms.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No films match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
films: Film[]
title?: string
}>(), {
title: 'Recommended Films',
})
defineEmits<{ selectFilm: [film: Film] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'films'),
id: (f) => f.id,
existingUrl: (f) => f.posterUrl || f.backdropUrl,
fetch: (f) => fetchFilmImage(f.title, f.year).then((r) => r.posterUrl),
fallback: (f) => generatePosterFallback(f.title, f.year),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const f of props.films) {
for (const g of f.genres) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredFilms = computed(() => {
let result = props.films
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(f) =>
f.title.toLowerCase().includes(q) ||
f.director.toLowerCase().includes(q) ||
f.cast.some((c) => c.toLowerCase().includes(q))
)
}
if (activeGenre.value) {
result = result.filter((f) => f.genres.includes(activeGenre.value!))
}
return result
})
</script>
@@ -0,0 +1,58 @@
<template>
<button
class="flex items-start gap-3 w-full text-left p-2.5 rounded-xl transition-all duration-150"
:class="isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'"
@click="$emit('select', image)"
>
<div class="w-16 shrink-0 rounded-lg overflow-hidden">
<div class="aspect-[4/3] relative bg-black/10">
<img
v-if="!imgFailed"
:src="image.url"
:alt="image.alt || image.title || 'Image'"
class="w-full h-full object-cover"
loading="lazy"
@error="imgFailed = true"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackImg})` }"
/>
</div>
</div>
<div class="flex-1 min-w-0 py-0.5">
<p v-if="image.title" class="text-sm font-medium leading-snug line-clamp-1"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ image.title }}
</p>
<p v-if="image.source" class="text-xs mt-0.5 truncate"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ image.source }}
</p>
<p v-if="image.description" class="text-xs mt-1 line-clamp-2 leading-relaxed"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ image.description }}
</p>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateImageFallback } from '@/composables/useImageFallback'
const props = defineProps<{ image: ImageItem }>()
defineEmits<{ select: [image: ImageItem] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
const fallbackImg = computed(() =>
generateImageFallback(props.image.title || props.image.alt || 'Image')
)
</script>
@@ -0,0 +1,87 @@
<template>
<div class="image-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden bg-black/20">
<img
v-if="!imgFailed"
:src="image.url"
:alt="image.alt || image.title || 'Image'"
class="w-full block max-h-[60vh] object-contain bg-black/40"
@error="imgFailed = true"
/>
<div
v-else
class="w-full aspect-video flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
>
<svg class="w-12 h-12" :class="isDark ? 'text-white/15' : 'text-gray-300'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<div class="p-4 space-y-3">
<h2 v-if="image.title" class="text-base font-bold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ image.title }}
</h2>
<p v-if="image.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ image.description }}
</p>
<div v-if="image.attribution" class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ image.attribution }}
</div>
<div v-if="image.source" class="text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Source: {{ image.source }}
</div>
<div v-if="image.width && image.height" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ image.width }} &times; {{ image.height }}
</div>
<div class="pt-2">
<a
:href="image.url"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-4 min-h-[44px] rounded-xl text-xs font-medium transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10 text-white/80'
: 'bg-black/3 hover:bg-black/5 text-gray-800'"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open original
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
defineProps<{ image: ImageItem }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
</script>
@@ -0,0 +1,85 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ images.length }} images
</span>
<slot name="header-actions" />
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="columns-2 sm:columns-3 gap-3 space-y-3">
<button
v-for="img in images"
:key="img.id"
class="group w-full break-inside-avoid text-left rounded-xl overflow-hidden transition-all duration-200 hover:brightness-110 relative"
:class="isDark ? 'bg-white/5' : 'bg-black/3'"
:aria-label="img.alt || img.title || 'Image'"
@click="$emit('selectImage', img)"
>
<img
v-if="!failedIds.has(img.id)"
:src="img.url"
:alt="img.alt || img.title || 'Image'"
class="w-full block transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
@error="onError(img)"
/>
<div
v-else
class="w-full aspect-[4/3] flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
>
<svg class="w-8 h-8" :class="isDark ? 'text-white/15' : 'text-gray-300'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div v-if="img.title || img.source"
class="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
<p v-if="img.title" class="text-xs font-medium text-white/90 truncate">{{ img.title }}</p>
<p v-if="img.source" class="text-xs text-white/50 truncate">{{ img.source }}</p>
</div>
</button>
</div>
<div v-if="images.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No images found
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
withDefaults(defineProps<{
images: ImageItem[]
title?: string
}>(), {
title: 'Images',
})
defineEmits<{ selectImage: [image: ImageItem] }>()
const { isDark } = useTheme()
const failedIds = ref<Set<string>>(new Set())
function onError(img: ImageItem) {
failedIds.value.add(img.id)
failedIds.value = new Set(failedIds.value)
}
</script>
@@ -0,0 +1,105 @@
<template>
<div class="flex-1 overflow-y-auto p-4">
<!-- Poster grid: films, TV, books -->
<div v-if="variant === 'poster'" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<div
v-for="i in count"
:key="i"
class="aspect-[2/3] rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
</div>
<!-- Square grid: songs, podcasts, images -->
<div v-else-if="variant === 'square'" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div v-for="i in count" :key="i" class="space-y-2">
<div
class="aspect-square rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div
class="h-3 rounded animate-pulse w-3/4"
:class="isDark ? 'bg-white/8' : 'bg-black/5'"
/>
<div
class="h-2.5 rounded animate-pulse w-1/2"
:class="isDark ? 'bg-white/5' : 'bg-black/3'"
/>
</div>
</div>
<!-- List: news, websites -->
<div v-else-if="variant === 'list'" class="space-y-3">
<div
v-for="i in count"
:key="i"
class="flex gap-3 p-3 rounded-xl animate-pulse"
:class="isDark ? 'bg-white/[0.04]' : 'bg-black/[0.03]'"
>
<div
class="w-20 h-14 rounded-lg shrink-0"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div class="flex-1 space-y-2 py-1">
<div
class="h-3 rounded w-4/5"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div
class="h-2.5 rounded w-3/5"
:class="isDark ? 'bg-white/6' : 'bg-black/4'"
/>
</div>
</div>
</div>
<!-- Magazine: tile-style skeleton -->
<div v-else-if="variant === 'magazine'" class="space-y-0">
<!-- Hero skeleton -->
<div
class="h-44 animate-pulse mb-px"
:class="isDark ? 'bg-white/[0.04]' : 'bg-black/[0.03]'"
/>
<!-- Tile grid skeleton -->
<div class="grid grid-cols-2 gap-px"
:class="isDark ? 'bg-white/12' : 'bg-black/10'">
<div
v-for="i in count"
:key="i"
class="p-4 animate-pulse"
:class="[
isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]',
i <= 1 ? 'col-span-2' : ''
]"
>
<div
class="h-2.5 rounded w-1/3 mb-2"
:class="isDark ? 'bg-white/8' : 'bg-black/5'"
/>
<div
class="h-4 rounded w-4/5 mb-2"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div
class="h-2.5 rounded w-full"
:class="isDark ? 'bg-white/6' : 'bg-black/4'"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
withDefaults(
defineProps<{
variant?: 'poster' | 'square' | 'list' | 'magazine'
count?: number
}>(),
{ variant: 'poster', count: 8 }
)
const { isDark } = useTheme()
</script>
@@ -0,0 +1,12 @@
<template>
<div
class="aspect-[2/3] rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
</script>
@@ -0,0 +1,21 @@
<template>
<div class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<LoadingFilmCard
v-for="i in count"
:key="i"
/>
</div>
</div>
</template>
<script setup lang="ts">
import LoadingFilmCard from './LoadingFilmCard.vue'
withDefaults(
defineProps<{
count?: number
}>(),
{ count: 12 }
)
</script>
@@ -0,0 +1,330 @@
<template>
<div class="magazine h-full flex flex-col"
:class="isDark ? 'magazine-dark' : 'magazine-light'">
<!-- Masthead -->
<header class="shrink-0 px-5 py-4 flex items-center justify-between border-b"
:class="isDark ? 'border-white/10' : 'border-black/10'">
<h1 class="font-serif text-xl font-bold tracking-tight"
:class="isDark ? 'text-white' : 'text-black'">
AI Brief
</h1>
<div class="shrink-0">
<slot name="header-actions" />
</div>
</header>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- Query context hero -->
<div v-if="headlineText" class="relative overflow-hidden"
:style="{ minHeight: '180px' }">
<!-- Background image or gradient -->
<div class="absolute inset-0">
<img v-if="heroImageUrl"
:src="heroImageUrl"
alt=""
class="w-full h-full object-cover"
style="filter: saturate(0.3) contrast(1.1);" />
<div v-else class="w-full h-full"
:class="isDark
? 'bg-gradient-to-br from-white/[0.04] via-white/[0.02] to-transparent'
: 'bg-gradient-to-br from-black/[0.06] via-black/[0.03] to-transparent'" />
</div>
<!-- Dark overlay -->
<div class="absolute inset-0"
:class="isDark
? 'bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/80 to-[#0a0a0a]/60'
: 'bg-gradient-to-t from-[#faf9f6] via-[#faf9f6]/85 to-[#faf9f6]/65'" />
<!-- Content -->
<div class="relative z-10 flex flex-col justify-end h-full px-5 pb-5 pt-12"
style="min-height: 180px;">
<p class="text-xs uppercase tracking-[0.3em] font-medium mb-2"
:class="isDark ? 'text-white/40' : 'text-black/40'">
In response to
</p>
<p class="font-serif text-2xl italic leading-tight"
:class="isDark ? 'text-white/70' : 'text-black/60'">
{{ headlineText }}
</p>
</div>
</div>
<!-- Tile grid -->
<div class="px-3 pt-2 pb-8">
<div class="grid grid-cols-2 gap-px"
:class="isDark ? 'bg-white/12' : 'bg-black/10'">
<template v-for="(tile, i) in tiles" :key="i">
<!-- Banner tile: full width with icon -->
<div v-if="tile.type === 'banner'"
class="col-span-2 flex flex-col items-center justify-center py-8 px-5"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'">
<svg class="w-5 h-5 mb-2.5" :class="isDark ? 'text-white/20' : 'text-black/15'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path v-if="tile.icon === 'compass'" stroke-linecap="round" stroke-linejoin="round"
d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 0v2m0 16v2m10-10h-2M4 12H2m15.07-5.07l-1.41 1.41M8.34 15.66l-1.41 1.41m0-11.14l1.41 1.41m7.32 7.32l1.41 1.41" />
<path v-else-if="tile.icon === 'bookmark'" stroke-linecap="round" stroke-linejoin="round"
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
<path v-else-if="tile.icon === 'lightning'" stroke-linecap="round" stroke-linejoin="round"
d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
<path v-else stroke-linecap="round" stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h7" />
</svg>
<p class="text-xs uppercase tracking-[0.3em] font-semibold text-center"
:class="isDark ? 'text-white/30' : 'text-black/30'">
{{ tile.label }}
</p>
</div>
<!-- Wide tile: full width, for lead/summary -->
<button v-else-if="tile.type === 'wide'"
class="col-span-2 text-left px-5 py-5 transition-colors cursor-pointer"
:class="isDark
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
: 'bg-[#faf9f6] hover:bg-black/[0.02]'"
@click="tile.section && openTile(tile.section)">
<p v-if="tile.label"
class="text-xs uppercase tracking-[0.3em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-black/35'">
{{ tile.label }}
</p>
<h2 class="font-serif text-lg font-bold leading-snug mb-2"
:class="isDark ? 'text-white/95' : 'text-black/90'">
{{ tile.title }}
</h2>
<p v-if="tile.author"
class="text-xs mb-2"
:class="isDark ? 'text-white/40' : 'text-black/40'">
By {{ tile.author }}
</p>
<p class="font-serif text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-black/60'">
{{ tile.text }}
</p>
</button>
<!-- Standard tile: half width -->
<button v-else
class="text-left px-4 py-4 transition-colors flex flex-col cursor-pointer"
:class="[
isDark
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
: 'bg-[#faf9f6] hover:bg-black/[0.02]',
tile.type === 'dark'
? isDark ? 'bg-white/[0.04]' : 'bg-black/[0.04]'
: ''
]"
@click="tile.section && openTile(tile.section)">
<p v-if="tile.label"
class="text-xs uppercase tracking-[0.25em] font-semibold mb-1.5"
:class="isDark ? 'text-white/25' : 'text-black/30'">
{{ tile.label }}
</p>
<h3 v-if="tile.title"
class="font-serif text-sm font-bold leading-snug mb-1"
:class="isDark ? 'text-white/90' : 'text-black/85'">
{{ tile.title }}
</h3>
<p class="font-serif text-xs leading-relaxed flex-1"
:class="[
isDark ? 'text-white/55' : 'text-black/50',
!tile.title ? 'italic' : ''
]">
{{ tile.text }}
</p>
</button>
</template>
</div>
</div>
<!-- Empty state -->
<div v-if="sections.length === 0" class="flex items-center justify-center py-16 px-4">
<p class="text-sm" :class="isDark ? 'text-white/40' : 'text-gray-400'">
No sections to display
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
interface Tile {
type: 'wide' | 'half' | 'dark' | 'banner'
title: string
text: string
label?: string
author?: string
icon?: string
section?: MagazineSection
}
const props = withDefaults(defineProps<{
sections: MagazineSection[]
heroImageUrl?: string | null
title?: string
query?: string
}>(), {
heroImageUrl: null,
title: 'Brief',
query: '',
})
const { isDark } = useTheme()
const { openWebsiteDetail, openMagazineSectionDetail } = useContentPanel()
const bannerIcons = ['compass', 'bookmark', 'lightning', 'lines'] as const
const bannerLabels = ['Perspectives', 'Worth Noting', 'Key Signals', 'Analysis']
function cleanText(text: string): string {
return text
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1') // [text](url) text
.replace(/https?:\/\/\S+/g, '') // bare URLs
.replace(/\uFE0F/g, '') // variation selectors
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis
.replace(/---+/g, '') // horizontal rules
.replace(/^#+\s*/gm, '')
.replace(/\*\*/g, '')
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* italic
.replace(/\|/g, ', ') // pipes comma-space
.replace(/,\s*,+/g, ',') // collapse multiple commas
.replace(/^\s*[-•]\s+/gm, '') // bullets at line start only
.replace(/\n+/g, ' ')
.replace(/(^|\s),\s*/g, '$1') // trim stray leading commas
.trim()
}
function truncate(text: string, max: number): string {
const clean = cleanText(text)
if (clean.length <= max) return clean
return clean.slice(0, max).replace(/\s+\S*$/, '') + '\u2009...'
}
/** Break a section's content into individual points (split on bullets/newlines) */
function splitIntoBullets(content: string): string[] {
return content
.split(/\n\s*[-•]\s*|\n{2,}/)
.map(s => s.replace(/^[-•]\s*/, '').replace(/\*\*/g, '').replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').trim())
.filter(s => s.length > 10)
}
const tiles = computed<Tile[]>(() => {
const result: Tile[] = []
const secs = props.sections
if (!secs.length) return result
// Seeded pseudo-random based on query for consistent layout
let seed = 0
for (const c of (props.query || 'brief')) seed = ((seed << 5) - seed + c.charCodeAt(0)) | 0
const rand = () => { seed = (seed * 16807 + 0) % 2147483647; return (seed & 0x7fffffff) / 2147483647 }
let lastGroup = ''
let bannerIdx = 0
let pairToggle = false // track half-tile pairing
secs.forEach((section, i) => {
if (i === 0 && !section.group) {
// Lead section: always wide
result.push({
type: 'wide',
title: section.title,
text: truncate(section.content, 200),
label: 'The Lead',
author: section.author,
section,
})
return
}
// Insert a banner when entering a new heading group
const group = section.group || ''
if (group && group !== lastGroup) {
// Pad any unpaired half tile before the banner
if (pairToggle) {
result.push({ type: 'dark', title: '', text: '' })
pairToggle = false
}
result.push({
type: 'banner',
title: '',
text: '',
icon: bannerIcons[bannerIdx % bannerIcons.length],
label: group,
})
bannerIdx++
lastGroup = group
}
// Sections within a group get alternating half/dark tiles
if (group) {
const variant = pairToggle ? 'dark' : 'half'
// If title is basically the same as content start, skip the title and just show content
const contentClean = cleanText(section.content)
const titleClean = cleanText(section.title)
const titleIsContent = contentClean.toLowerCase().startsWith(titleClean.toLowerCase().slice(0, 30))
result.push({
type: variant,
title: titleIsContent ? '' : section.title,
text: truncate(section.content, titleIsContent ? 160 : 100),
section,
})
pairToggle = !pairToggle
} else {
// Non-grouped sections: use wide layout
// Pad any unpaired half tile
if (pairToggle) {
result.push({ type: 'dark', title: '', text: '' })
pairToggle = false
}
result.push({
type: 'wide',
title: section.title,
text: truncate(section.content, 180),
author: section.author,
section,
})
}
})
// Pad final unpaired half tile
if (pairToggle) {
result.push({ type: 'dark', title: '', text: '' })
}
return result
})
/** Extract bold title from a bullet point like "**Title** - rest" */
function extractBulletTitle(text: string): string {
const m = /^\*\*([^*]+)\*\*/.exec(text)
return m ? m[1].trim() : ''
}
function cleanBulletTitle(text: string): string {
return text.replace(/^\*\*[^*]+\*\*\s*[-–—:]\s*/, '').trim()
}
function openTile(section: MagazineSection) {
const idx = props.sections.indexOf(section)
openMagazineSectionDetail(section, idx >= 0 ? idx : 0)
}
const headlineText = computed(() => {
const q = (props.query ?? '').trim()
if (!q) return props.title
return q.length > 100 ? q.slice(0, 97) + '...' : q
})
</script>
<style scoped>
.magazine {
font-family: Georgia, 'Times New Roman', Times, serif;
}
.magazine-light {
background-color: #faf9f6;
}
.magazine-dark {
background-color: #0a0a0a;
}
</style>
@@ -0,0 +1,160 @@
<template>
<div class="magazine-section-detail h-full flex flex-col overflow-hidden"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'"
style="font-family: Georgia, 'Times New Roman', Times, serif;">
<!-- Header with back + nav counter -->
<div class="shrink-0 flex items-center justify-between px-4 py-3"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4" :class="isDark ? 'text-white/70' : 'text-gray-600'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex-1 text-center pl-8">
<span class="text-xs uppercase tracking-[0.3em] font-semibold"
:class="isDark ? 'text-white/30' : 'text-black/30'">
AI Brief
</span>
</div>
<span class="text-xs font-mono tabular-nums shrink-0"
:class="isDark ? 'text-white/25' : 'text-black/25'">
{{ currentIndex + 1 }}/{{ totalSections }}
</span>
</div>
<!-- Content area -->
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar flex flex-col">
<div class="px-6 py-8 md:px-8 md:py-10 max-w-lg mx-auto my-auto">
<!-- Group label -->
<p v-if="section.group"
class="text-xs uppercase tracking-[0.3em] font-semibold mb-4"
:class="isDark ? 'text-white/25' : 'text-black/30'">
{{ section.group }}
</p>
<!-- Title -->
<h2 class="text-2xl md:text-3xl font-bold leading-tight mb-4"
:class="isDark ? 'text-white/95' : 'text-black/90'">
{{ section.title }}
</h2>
<!-- Author -->
<p v-if="section.author"
class="text-xs mb-6"
:class="isDark ? 'text-white/40' : 'text-black/40'">
By {{ section.author }}
</p>
<!-- Decorative rule -->
<div class="w-12 h-px mb-6"
:class="isDark ? 'bg-white/15' : 'bg-black/15'" />
<!-- Content as quote-style paragraphs -->
<div class="space-y-4">
<p v-for="(paragraph, i) in paragraphs" :key="i"
class="text-base md:text-lg leading-relaxed"
:class="isDark ? 'text-white/75' : 'text-black/65'">
{{ paragraph }}
</p>
</div>
<!-- Source link -->
<a v-if="section.url"
:href="section.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 mt-6 min-h-[44px] text-xs transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/70' : 'text-black/40 hover:text-black/70'">
<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 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Source
</a>
</div>
</div>
<!-- Navigation footer -->
<div class="shrink-0 flex items-center justify-between px-4 py-3"
:style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="flex items-center gap-1.5 px-3 min-h-[44px] rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="$emit('navigate', 'prev')"
>
<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="M15 19l-7-7 7-7" />
</svg>
Prev
</button>
<!-- Dot indicators -->
<div class="flex items-center gap-1">
<div v-for="n in totalSections" :key="n"
class="w-1.5 h-1.5 rounded-full transition-all duration-200"
:class="n - 1 === currentIndex
? isDark ? 'bg-white/70 scale-125' : 'bg-black/60 scale-125'
: isDark ? 'bg-white/15' : 'bg-black/15'" />
</div>
<button
class="flex items-center gap-1.5 px-3 min-h-[44px] rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="$emit('navigate', 'next')"
>
Next
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { MagazineSection } from '@/composables/useContentPanel'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{
section: MagazineSection
currentIndex: number
totalSections: number
}>()
defineEmits<{
back: []
navigate: [direction: 'prev' | 'next']
}>()
const { isDark } = useTheme()
const paragraphs = computed(() => {
const text = props.section.content
return text
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1') // [text](url) text
.replace(/https?:\/\/\S+/g, '') // bare URLs
.replace(/\uFE0F/g, '') // variation selectors
.replace(/\*\*/g, '') // bold markers
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* italic
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis
.replace(/---+/g, '') // horizontal rules
.replace(/^#+\s*/gm, '') // heading markers
.replace(/\|/g, ', ') // pipes comma-space
.replace(/,\s*,+/g, ',') // collapse multiple commas
.split(/\n{2,}|\n\s*[-•]\s+/)
.map(p => p.replace(/^\s*[-•]\s+/, '').replace(/(^|\n)\s*,\s*/g, '$1').trim())
.filter(p => p.length > 0)
})
</script>
@@ -0,0 +1,69 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select-article', article)"
>
<div class="cover-card-sm shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="imgSrc"
:src="imgSrc"
:alt="article.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="imgFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackImg})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ article.title }}</p>
<p v-if="article.content"
class="text-xs mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ article.content }}
</p>
<p class="text-xs mt-1 truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ formatDomain(article.url) }}
</p>
</div>
<svg class="w-4 h-4 shrink-0 self-center opacity-50"
:class="isDark ? 'text-white/50' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { isSafeUrl, formatDomain } from '@/utils/html'
import { generateNewsFallback } from '@/composables/useImageFallback'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ 'select-article': [article: WebSearchResult] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
const imgSrc = computed(() => {
if (imgFailed.value) return null
const u = props.article.imgSrc
return isSafeUrl(u) ? u : null
})
const fallbackImg = computed(() =>
generateNewsFallback(props.article.title, formatDomain(props.article.url))
)
</script>

Some files were not shown because too many files have changed in this diff Show More