feat(app): enhance theme support and improve PWA integration
- Updated the app to support light and dark themes with appropriate CSS classes. - Enhanced PWA configuration with manifest details and caching strategies. - Improved the chat UI with dynamic theme adjustments for various components. - Added new meta tags for better mobile web app experience. - Refactored environment variables to include new Anthropic token. - Updated package dependencies for better compatibility and performance. Made-with: Cursor
This commit is contained in:
+4
-2
@@ -5,8 +5,10 @@
|
|||||||
# Get your key at: https://openrouter.ai/keys
|
# Get your key at: https://openrouter.ai/keys
|
||||||
VITE_OPENROUTER_API_KEY=sk-or-your-key-here
|
VITE_OPENROUTER_API_KEY=sk-or-your-key-here
|
||||||
|
|
||||||
# Anthropic Claude (direct API access)
|
# Anthropic Claude — use ONE of these:
|
||||||
# Get your key at: https://console.anthropic.com/settings/keys
|
# Option 1: OAuth token from Claude Code CLI (run: claude auth login)
|
||||||
|
VITE_ANTHROPIC_TOKEN=sk-ant-oat01-your-token-here
|
||||||
|
# Option 2: API key from console (https://console.anthropic.com/settings/keys)
|
||||||
VITE_ANTHROPIC_API_KEY=sk-ant-your-key-here
|
VITE_ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||||
|
|
||||||
# TMDB API (free, for real film poster images)
|
# TMDB API (free, for real film poster images)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 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} didn’t 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-cf23aef7'], (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.htc1d8sapcc"
|
||||||
|
}], {});
|
||||||
|
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');
|
||||||
|
|
||||||
|
}));
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,15 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
|
||||||
|
<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" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||||
<title>AIUI</title>
|
<title>AIUI</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased">
|
<body class="antialiased">
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "tsx server/claude-proxy.ts & vite",
|
||||||
|
"dev:vite": "vite",
|
||||||
|
"dev:proxy": "tsx server/claude-proxy.ts",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -16,18 +18,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aiui/core": "workspace:*",
|
"@aiui/core": "workspace:*",
|
||||||
|
"pinia": "latest",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest"
|
||||||
"pinia": "latest"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "latest",
|
||||||
"@vitejs/plugin-vue": "latest",
|
"@vitejs/plugin-vue": "latest",
|
||||||
"vite": "latest",
|
|
||||||
"vue-tsc": "latest",
|
|
||||||
"vitest": "latest",
|
|
||||||
"eslint": "latest",
|
"eslint": "latest",
|
||||||
"typescript": "~5.8.0",
|
|
||||||
"tailwindcss": "latest",
|
"tailwindcss": "latest",
|
||||||
"@tailwindcss/vite": "latest"
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.0",
|
||||||
|
"vite": "latest",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vitest": "latest",
|
||||||
|
"vue-tsc": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#0a0a0a"/>
|
||||||
|
<path d="M8,12 L8,22 Q8,24 10,24 L14,24 L17,27 L17,24 L22,24 Q24,24 24,22 L24,12 Q24,10 22,10 L10,10 Q8,10 8,12 Z" fill="#F7931A"/>
|
||||||
|
<circle cx="13" cy="17" r="1.5" fill="#0a0a0a"/>
|
||||||
|
<circle cx="16" cy="17" r="1.5" fill="#0a0a0a"/>
|
||||||
|
<circle cx="19" cy="17" r="1.5" fill="#0a0a0a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 410 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1a1a1a"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0a0a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#F7931A"/>
|
||||||
|
<stop offset="100%" stop-color="#E88410"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<rect x="8" y="8" width="496" height="496" rx="90" fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="2"/>
|
||||||
|
<g transform="translate(256,256)">
|
||||||
|
<circle cx="0" cy="0" r="80" fill="none" stroke="url(#accent)" stroke-width="6" opacity="0.3"/>
|
||||||
|
<circle cx="0" cy="0" r="120" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="2"/>
|
||||||
|
<path d="M-50,-20 L-50,30 Q-50,40 -40,40 L-10,40 L10,55 L10,40 L40,40 Q50,40 50,30 L50,-20 Q50,-30 40,-30 L-40,-30 Q-50,-30 -50,-20 Z" fill="url(#accent)" opacity="0.9"/>
|
||||||
|
<circle cx="-20" cy="5" r="5" fill="#0a0a0a"/>
|
||||||
|
<circle cx="0" cy="5" r="5" fill="#0a0a0a"/>
|
||||||
|
<circle cx="20" cy="5" r="5" fill="#0a0a0a"/>
|
||||||
|
<text x="0" y="115" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="700" font-size="48" fill="rgba(255,255,255,0.9)" letter-spacing="8">AIUI</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1,139 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { createServer } from 'http'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
const PORT = 3141
|
||||||
|
const CLAUDE_BIN = resolve(process.env.HOME ?? '', '.local/bin/claude')
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204, {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST' || req.url !== '/v1/messages') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: 'Not found' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = ''
|
||||||
|
req.on('data', (chunk) => { body += chunk })
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const { model, messages, system } = JSON.parse(body)
|
||||||
|
|
||||||
|
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)
|
||||||
|
args.push('--', lastUserMsg)
|
||||||
|
|
||||||
|
console.log(`[proxy] → claude -p --model ${modelFlag} "${lastUserMsg.slice(0, 60)}..."`)
|
||||||
|
|
||||||
|
const proc = spawn(CLAUDE_BIN, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
|
||||||
|
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
|
||||||
|
console.log(`[proxy] stdout +${text.length}b total=${fullOutput.length}b`)
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
console.error('[proxy] spawn error:', err)
|
||||||
|
if (!clientDisconnected) {
|
||||||
|
const errData = {
|
||||||
|
type: 'error',
|
||||||
|
error: { message: `Spawn error: ${err.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': '*',
|
||||||
|
})
|
||||||
|
res.end(JSON.stringify({ error: String(err) }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`\n Claude proxy → http://localhost:${PORT}`)
|
||||||
|
console.log(` Binary: ${CLAUDE_BIN}`)
|
||||||
|
console.log(` Using your Max subscription\n`)
|
||||||
|
})
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-dvh flex flex-col">
|
<div class="h-dvh flex flex-col" :class="currentTheme">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
|
const { currentTheme, initTheme } = useTheme()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initTheme()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass-strong rounded-t-2xl flex items-center justify-between px-4 py-3"
|
<div
|
||||||
style="border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);">
|
class="glass-strong rounded-t-2xl flex items-center justify-between px-4 py-3 relative"
|
||||||
|
:style="isDark
|
||||||
|
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22)'
|
||||||
|
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06); box-shadow: inset 0 1px 0 rgba(254, 253, 249, 1)'"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center shrink-0">
|
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
|
||||||
|
:class="isDark ? 'bg-accent/20' : 'bg-accent/10'">
|
||||||
<span class="text-accent text-sm font-bold">AI</span>
|
<span class="text-accent text-sm font-bold">AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-sm font-semibold text-white/96 truncate">{{ title }}</h2>
|
<h2 class="text-sm font-semibold truncate"
|
||||||
|
:class="isDark ? 'text-white/96' : 'text-gray-900'">{{ title }}</h2>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-[10px] text-white/40 truncate font-mono">{{ conversationId }}</p>
|
<p class="text-[10px] truncate font-mono"
|
||||||
<span class="text-[10px] text-white/20">·</span>
|
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
|
||||||
|
<span class="text-[10px]" :class="isDark ? 'text-white/20' : 'text-gray-300'">·</span>
|
||||||
<button
|
<button
|
||||||
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
|
||||||
@click="showModelPicker = !showModelPicker"
|
@click="showModelPicker = !showModelPicker"
|
||||||
@@ -22,7 +29,10 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
class="p-2 rounded-lg transition-colors"
|
||||||
|
:class="isDark
|
||||||
|
? 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||||
:title="side === 'right' ? 'Move panel to left' : 'Move panel to right'"
|
:title="side === 'right' ? 'Move panel to left' : 'Move panel to right'"
|
||||||
aria-label="Switch panel side"
|
aria-label="Switch panel side"
|
||||||
@click="$emit('switchSide')"
|
@click="$emit('switchSide')"
|
||||||
@@ -34,7 +44,10 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
class="p-2 rounded-lg transition-colors"
|
||||||
|
:class="isDark
|
||||||
|
? 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||||
aria-label="New conversation"
|
aria-label="New conversation"
|
||||||
@click="$emit('newChat')"
|
@click="$emit('newChat')"
|
||||||
>
|
>
|
||||||
@@ -45,7 +58,10 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="showClose"
|
v-if="showClose"
|
||||||
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
class="p-2 rounded-lg transition-colors"
|
||||||
|
:class="isDark
|
||||||
|
? 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
@@ -61,7 +77,8 @@
|
|||||||
class="absolute left-0 right-0 top-full z-20 mx-3 mt-1 glass-card p-3 space-y-3 animate-fade-up-fast"
|
class="absolute left-0 right-0 top-full z-20 mx-3 mt-1 glass-card p-3 space-y-3 animate-fade-up-fast"
|
||||||
>
|
>
|
||||||
<div v-for="provider in availableProviders" :key="provider.id">
|
<div v-for="provider in availableProviders" :key="provider.id">
|
||||||
<p class="text-[10px] font-semibold text-white/40 uppercase tracking-wider mb-1.5 px-1">
|
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-1"
|
||||||
|
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||||
{{ provider.name }}
|
{{ provider.name }}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
@@ -71,7 +88,9 @@
|
|||||||
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200"
|
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200"
|
||||||
:class="model.id === activeModel && provider.id === activeProvider
|
:class="model.id === activeModel && provider.id === activeProvider
|
||||||
? 'nav-tab-active'
|
? 'nav-tab-active'
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/10'"
|
: isDark
|
||||||
|
? 'text-white/60 hover:text-white hover:bg-white/10'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||||
@click="selectModel(provider.id, model.id)"
|
@click="selectModel(provider.id, model.id)"
|
||||||
>
|
>
|
||||||
{{ model.name }}
|
{{ model.name }}
|
||||||
@@ -86,6 +105,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useAI } from '@/composables/useAI'
|
import { useAI } from '@/composables/useAI'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
@@ -101,6 +121,7 @@ defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
|
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
|
||||||
|
const { isDark } = useTheme()
|
||||||
const showModelPicker = ref(false)
|
const showModelPicker = ref(false)
|
||||||
|
|
||||||
const modelDisplayName = computed(() => {
|
const modelDisplayName = computed(() => {
|
||||||
@@ -112,7 +133,7 @@ const modelDisplayName = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function selectModel(providerId: string, modelId: string) {
|
function selectModel(providerId: string, modelId: string) {
|
||||||
setProvider(providerId as 'anthropic' | 'openrouter')
|
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
|
||||||
setModel(modelId)
|
setModel(modelId)
|
||||||
showModelPicker.value = false
|
showModelPicker.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,16 @@
|
|||||||
<div class="p-3 md:p-4">
|
<div class="p-3 md:p-4">
|
||||||
<div
|
<div
|
||||||
class="glass rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300"
|
class="glass rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300"
|
||||||
:class="isFocused ? 'border-glass-highlight' : ''"
|
|
||||||
:style="isFocused ? 'box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.22)' : ''"
|
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="text"
|
v-model="text"
|
||||||
rows="1"
|
rows="1"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
class="flex-1 resize-none bg-transparent text-sm text-white/90 outline-none placeholder:text-white/25 min-h-[24px] max-h-[120px]"
|
class="flex-1 resize-none bg-transparent text-sm outline-none min-h-[24px] max-h-[120px]"
|
||||||
@focus="isFocused = true"
|
:class="isDark
|
||||||
@blur="isFocused = false"
|
? 'text-white/90 placeholder:text-white/25'
|
||||||
|
: 'text-gray-800 placeholder:text-gray-400'"
|
||||||
@keydown.enter.exact.prevent="send"
|
@keydown.enter.exact.prevent="send"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
/>
|
/>
|
||||||
@@ -20,7 +19,7 @@
|
|||||||
:disabled="!canSend"
|
:disabled="!canSend"
|
||||||
class="shrink-0 glass-button glass-button-sm rounded-xl px-3 transition-all duration-200"
|
class="shrink-0 glass-button glass-button-sm rounded-xl px-3 transition-all duration-200"
|
||||||
:class="canSend
|
:class="canSend
|
||||||
? 'hover:bg-white/15 active:scale-95'
|
? 'hover:opacity-80 active:scale-95'
|
||||||
: 'opacity-30 cursor-not-allowed'"
|
: 'opacity-30 cursor-not-allowed'"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
@click="send"
|
@click="send"
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -51,8 +51,8 @@ const emit = defineEmits<{
|
|||||||
send: [text: string]
|
send: [text: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { isDark } = useTheme()
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
const isFocused = ref(false)
|
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
|
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
|
||||||
|
|||||||
@@ -8,12 +8,11 @@
|
|||||||
class="max-w-[85%] md:max-w-[70%] rounded-2xl px-4 py-3 transition-all duration-300"
|
class="max-w-[85%] md:max-w-[70%] rounded-2xl px-4 py-3 transition-all duration-300"
|
||||||
:class="bubbleClasses"
|
:class="bubbleClasses"
|
||||||
>
|
>
|
||||||
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90">{{ message.content }}</p>
|
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words"
|
||||||
|
:class="isDark ? 'text-white/90' : 'text-gray-800'">{{ message.content }}</p>
|
||||||
<div class="flex items-center gap-2 mt-1.5">
|
<div class="flex items-center gap-2 mt-1.5">
|
||||||
<span class="text-[10px] text-white/30 select-none">{{ formattedTime }}</span>
|
<span class="text-[10px] select-none"
|
||||||
<span v-if="isUser && message.status" class="text-[10px] text-white/20">
|
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ formattedTime }}</span>
|
||||||
{{ message.status === 'sent' ? '✓' : message.status === 'delivered' ? '✓✓' : '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,12 +21,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { Message } from '@aiui/core/types/message'
|
import type { Message } from '@aiui/core/types/message'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
message: Message
|
message: Message
|
||||||
index: number
|
index: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { isDark } = useTheme()
|
||||||
const isUser = computed(() => props.message.role === 'user')
|
const isUser = computed(() => props.message.role === 'user')
|
||||||
|
|
||||||
const bubbleClasses = computed(() =>
|
const bubbleClasses = computed(() =>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-col h-full rounded-2xl overflow-hidden transition-all duration-300">
|
||||||
class="flex flex-col h-full rounded-2xl overflow-hidden transition-all duration-300"
|
|
||||||
:class="variant === 'modal' ? 'glass-dark' : ''"
|
|
||||||
>
|
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
:title="title"
|
:title="title"
|
||||||
:conversation-id="displayId"
|
:conversation-id="displayId"
|
||||||
@@ -15,14 +12,16 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref="messageListRef"
|
ref="messageListRef"
|
||||||
class="flex-1 overflow-y-auto scrollbar-thin p-4 space-y-3"
|
class="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-3"
|
||||||
>
|
>
|
||||||
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
|
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
|
||||||
<div class="text-center space-y-3 animate-fade-up">
|
<div class="text-center space-y-3 animate-fade-up">
|
||||||
<div class="w-16 h-16 rounded-2xl glass flex items-center justify-center mx-auto">
|
<div class="w-16 h-16 rounded-2xl glass flex items-center justify-center mx-auto">
|
||||||
<span class="text-2xl">✦</span>
|
<span class="text-2xl">✦</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-white/30">Start a conversation</p>
|
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||||
|
Start a conversation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,6 +47,7 @@
|
|||||||
import { computed, ref, watch, nextTick } from 'vue'
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useAI } from '@/composables/useAI'
|
import { useAI } from '@/composables/useAI'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import ChatHeader from './ChatHeader.vue'
|
import ChatHeader from './ChatHeader.vue'
|
||||||
import ChatMessage from './ChatMessage.vue'
|
import ChatMessage from './ChatMessage.vue'
|
||||||
import ChatInput from './ChatInput.vue'
|
import ChatInput from './ChatInput.vue'
|
||||||
@@ -73,6 +73,7 @@ defineEmits<{
|
|||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const { sendMessage } = useAI()
|
const { sendMessage } = useAI()
|
||||||
|
const { isDark } = useTheme()
|
||||||
const messageListRef = ref<HTMLElement | null>(null)
|
const messageListRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const messages = computed(() => chatStore.messages)
|
const messages = computed(() => chatStore.messages)
|
||||||
|
|||||||
@@ -5,10 +5,16 @@
|
|||||||
<span
|
<span
|
||||||
v-for="i in 3"
|
v-for="i in 3"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
|
class="w-1.5 h-1.5 rounded-full animate-pulse-glow"
|
||||||
|
:class="isDark ? 'bg-white/40' : 'bg-gray-400'"
|
||||||
:style="{ animationDelay: `${i * 200}ms` }"
|
:style="{ animationDelay: `${i * 200}ms` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div :class="currentTheme">
|
||||||
<WidgetFab
|
<WidgetFab
|
||||||
:is-open="isOpen"
|
:is-open="isOpen"
|
||||||
:position="fabPosition"
|
:position="fabPosition"
|
||||||
@@ -16,9 +16,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import WidgetFab from './WidgetFab.vue'
|
import WidgetFab from './WidgetFab.vue'
|
||||||
import WidgetModal from './WidgetModal.vue'
|
import WidgetModal from './WidgetModal.vue'
|
||||||
|
|
||||||
|
const { currentTheme } = useTheme()
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const side = ref<'left' | 'right'>('right')
|
const side = ref<'left' | 'right'>('right')
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
class="fixed z-40 animate-scale-in"
|
class="fixed z-40 animate-scale-in"
|
||||||
:class="positionClasses"
|
:class="[positionClasses, currentTheme]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] glass-card overflow-hidden"
|
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] glass-card overflow-hidden"
|
||||||
:style="{ boxShadow: '0 20px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(247, 147, 26, 0.06)' }"
|
:style="isDark
|
||||||
|
? 'box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(247, 147, 26, 0.06)'
|
||||||
|
: 'box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12), 0 0 40px rgba(247, 147, 26, 0.04)'"
|
||||||
>
|
>
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
variant="modal"
|
variant="modal"
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import ChatWindow from '@/components/chat/ChatWindow.vue'
|
import ChatWindow from '@/components/chat/ChatWindow.vue'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -40,6 +43,8 @@ defineEmits<{
|
|||||||
switchSide: []
|
switchSide: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { isDark, currentTheme } = useTheme()
|
||||||
|
|
||||||
const positionClasses = computed(() =>
|
const positionClasses = computed(() =>
|
||||||
props.side === 'left'
|
props.side === 'left'
|
||||||
? 'bottom-24 left-6'
|
? 'bottom-24 left-6'
|
||||||
|
|||||||
@@ -1,46 +1,68 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
|
||||||
type Provider = 'anthropic' | 'openrouter'
|
type Provider = 'claude' | 'openrouter' | 'mock'
|
||||||
|
|
||||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'
|
const CLAUDE_PATH = '/api/claude/v1/messages'
|
||||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
|
||||||
|
|
||||||
const SYSTEM_PROMPT = 'You are AIUI, a helpful AI assistant. Be concise and helpful. When discussing films, provide rich details including genre, year, director, and rating.'
|
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library.
|
||||||
|
|
||||||
const activeProvider = ref<Provider>(
|
When recommending or discussing films, reference films from the user's library using the tag format [[film:ID]] where ID matches a film in their collection. Always include the tag so the UI can render rich cards. You can recommend multiple films. Write a brief description of why each film is worth watching alongside the tag.
|
||||||
import.meta.env.VITE_ANTHROPIC_API_KEY ? 'anthropic' : 'openrouter'
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeModel = ref(
|
The user's film library:
|
||||||
import.meta.env.VITE_ANTHROPIC_API_KEY ? 'claude-sonnet-4-20250514' : 'meta-llama/llama-4-maverick:free'
|
${generateFilmContext()}
|
||||||
)
|
|
||||||
|
If a user asks about a film NOT in their library, still discuss it but mention it's not currently in their collection.`
|
||||||
|
|
||||||
|
function generateFilmContext(): string {
|
||||||
|
const { mockFilms } = await_import_films()
|
||||||
|
return mockFilms.map((f) =>
|
||||||
|
`- [${f.id}] "${f.title}" (${f.year}) dir. ${f.director} | ${f.genres.join(', ')} | ${f.rating}/10 | Available on: ${f.sources.map(s => s.type).join(', ')}`
|
||||||
|
).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function await_import_films() {
|
||||||
|
// Synchronous access — the mock is bundled
|
||||||
|
return require('@/mocks/films') as typeof import('@/mocks/films')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? ''
|
||||||
|
const hasOpenRouter = !!openrouterApiKey
|
||||||
|
|
||||||
|
const activeProvider = ref<Provider>('claude')
|
||||||
|
|
||||||
|
const activeModel = ref('claude-sonnet-4')
|
||||||
|
|
||||||
const availableProviders = computed(() => {
|
const availableProviders = computed(() => {
|
||||||
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = []
|
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
|
||||||
if (import.meta.env.VITE_ANTHROPIC_API_KEY) {
|
{
|
||||||
providers.push({
|
id: 'claude',
|
||||||
id: 'anthropic',
|
name: 'Claude (Max)',
|
||||||
name: 'Anthropic',
|
|
||||||
models: [
|
models: [
|
||||||
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
|
||||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
{ id: 'claude-opus-4', name: 'Claude Opus 4' },
|
||||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
{ id: 'claude-haiku-3.5', name: 'Claude 3.5 Haiku' },
|
||||||
],
|
],
|
||||||
})
|
},
|
||||||
}
|
]
|
||||||
if (import.meta.env.VITE_OPENROUTER_API_KEY) {
|
if (hasOpenRouter) {
|
||||||
providers.push({
|
providers.push({
|
||||||
id: 'openrouter',
|
id: 'openrouter',
|
||||||
name: 'OpenRouter',
|
name: 'OpenRouter',
|
||||||
models: [
|
models: [
|
||||||
{ id: 'meta-llama/llama-4-maverick:free', name: 'Llama 4 Maverick (free)' },
|
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' },
|
||||||
{ id: 'meta-llama/llama-4-scout:free', name: 'Llama 4 Scout (free)' },
|
{ 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: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' },
|
||||||
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4 (paid)' },
|
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
providers.push({
|
||||||
|
id: 'mock',
|
||||||
|
name: 'Local (no API)',
|
||||||
|
models: [{ id: 'echo', name: 'Echo (mirror input)' }],
|
||||||
|
})
|
||||||
return providers
|
return providers
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,33 +78,36 @@ function setModel(model: string) {
|
|||||||
activeModel.value = model
|
activeModel.value = model
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnthropicMessage {
|
interface ChatMessage {
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenRouterMessage {
|
async function streamMock(
|
||||||
role: 'system' | 'user' | 'assistant'
|
messages: ChatMessage[],
|
||||||
content: string
|
onToken: (text: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const lastUser = messages.filter((m) => m.role === 'user').pop()
|
||||||
|
const text = lastUser
|
||||||
|
? `You said: "${lastUser.content}"\n\nThis is AIUI in echo mode. Select Claude or OpenRouter from the model picker.`
|
||||||
|
: 'Hello! I am AIUI running in mock mode.'
|
||||||
|
|
||||||
|
for (const char of text) {
|
||||||
|
onToken(char)
|
||||||
|
await new Promise((r) => setTimeout(r, 12))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamAnthropic(
|
async function streamClaude(
|
||||||
messages: AnthropicMessage[],
|
messages: ChatMessage[],
|
||||||
onToken: (text: string) => void,
|
onToken: (text: string) => void,
|
||||||
onError: (err: string) => void,
|
onError: (err: string) => void,
|
||||||
) {
|
): Promise<void> {
|
||||||
const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY
|
const res = await fetch(CLAUDE_PATH, {
|
||||||
const res = await fetch(ANTHROPIC_URL, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': apiKey,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
'anthropic-dangerous-direct-browser-access': 'true',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: activeModel.value,
|
model: activeModel.value,
|
||||||
max_tokens: 4096,
|
|
||||||
system: SYSTEM_PROMPT,
|
system: SYSTEM_PROMPT,
|
||||||
messages,
|
messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -90,76 +115,70 @@ async function streamAnthropic(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text()
|
const body = await res.text().catch(() => 'Could not read error body')
|
||||||
onError(`Error ${res.status}: ${err}`)
|
onError(`Claude proxy error ${res.status}: ${body}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = res.body?.getReader()
|
await readSSE(res, (data) => {
|
||||||
if (!reader) {
|
const parsed = JSON.parse(data)
|
||||||
onError('No response body')
|
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
|
||||||
return
|
onToken(parsed.delta.text)
|
||||||
}
|
} else if (parsed.type === 'error') {
|
||||||
|
onError(parsed.error?.message ?? 'Claude stream error')
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() ?? ''
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (!trimmed || trimmed === 'event: ping') continue
|
|
||||||
|
|
||||||
if (trimmed.startsWith('data: ')) {
|
|
||||||
const data = trimmed.slice(6)
|
|
||||||
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 ?? 'Stream error')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip non-JSON lines (event type headers etc.)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}, onError)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamOpenRouter(
|
async function streamOpenRouter(
|
||||||
messages: OpenRouterMessage[],
|
messages: ChatMessage[],
|
||||||
onToken: (text: string) => void,
|
onToken: (text: string) => void,
|
||||||
onError: (err: string) => void,
|
onError: (err: string) => void,
|
||||||
) {
|
): Promise<void> {
|
||||||
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY
|
if (!openrouterApiKey) {
|
||||||
const res = await fetch(OPENROUTER_URL, {
|
onError('Missing VITE_OPENROUTER_API_KEY in .env.local')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const orMessages = [
|
||||||
|
{ role: 'system' as const, content: SYSTEM_PROMPT },
|
||||||
|
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const res = await fetch(OPENROUTER_PATH, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
'Authorization': `Bearer ${openrouterApiKey}`,
|
||||||
'HTTP-Referer': window.location.origin,
|
'HTTP-Referer': window.location.origin,
|
||||||
'X-Title': 'AIUI',
|
'X-Title': 'AIUI',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: activeModel.value,
|
model: activeModel.value,
|
||||||
messages: [{ role: 'system', content: SYSTEM_PROMPT }, ...messages],
|
messages: orMessages,
|
||||||
stream: true,
|
stream: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text()
|
const body = await res.text().catch(() => 'Could not read error body')
|
||||||
onError(`Error ${res.status}: ${err}`)
|
onError(`OpenRouter error ${res.status}: ${body}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await readSSE(res, (data) => {
|
||||||
|
if (data === '[DONE]') return
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
const delta = parsed.choices?.[0]?.delta?.content
|
||||||
|
if (delta) onToken(delta)
|
||||||
|
}, onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSSE(
|
||||||
|
res: Response,
|
||||||
|
onData: (data: string) => void,
|
||||||
|
onError: (err: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
const reader = res.body?.getReader()
|
const reader = res.body?.getReader()
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
onError('No response body')
|
onError('No response body')
|
||||||
@@ -180,13 +199,10 @@ async function streamOpenRouter(
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
||||||
const data = trimmed.slice(6)
|
const payload = trimmed.slice(6)
|
||||||
if (data === '[DONE]') break
|
if (payload === '[DONE]') return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data)
|
onData(payload)
|
||||||
const delta = parsed.choices?.[0]?.delta?.content
|
|
||||||
if (delta) onToken(delta)
|
|
||||||
} catch {
|
} catch {
|
||||||
// skip malformed chunks
|
// skip malformed chunks
|
||||||
}
|
}
|
||||||
@@ -199,49 +215,41 @@ export function useAI() {
|
|||||||
|
|
||||||
async function sendMessage(userText: string) {
|
async function sendMessage(userText: string) {
|
||||||
const provider = activeProvider.value
|
const provider = activeProvider.value
|
||||||
const hasKey =
|
|
||||||
provider === 'anthropic'
|
|
||||||
? !!import.meta.env.VITE_ANTHROPIC_API_KEY
|
|
||||||
: !!import.meta.env.VITE_OPENROUTER_API_KEY
|
|
||||||
|
|
||||||
if (!hasKey) {
|
|
||||||
console.error(`No API key set for ${provider}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let convId = chatStore.activeConversationId
|
let convId = chatStore.activeConversationId
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
convId = chatStore.createConversation()
|
convId = chatStore.createConversation()
|
||||||
}
|
}
|
||||||
|
const cid = convId
|
||||||
|
|
||||||
chatStore.addMessage(convId, { role: 'user', content: userText })
|
chatStore.addMessage(cid, { role: 'user', content: userText })
|
||||||
const assistantMsg = chatStore.addMessage(convId, { role: 'assistant', content: '' })
|
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
|
||||||
if (!assistantMsg) return
|
if (!assistantMsg) return
|
||||||
|
|
||||||
chatStore.isStreaming = true
|
chatStore.isStreaming = true
|
||||||
|
|
||||||
const history = chatStore.messages
|
const history: ChatMessage[] = chatStore.messages
|
||||||
.filter((m) => m.id !== assistantMsg.id)
|
.filter((m) => m.id !== assistantMsg.id)
|
||||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
||||||
|
|
||||||
const onToken = (text: string) => chatStore.appendToLastMessage(convId, text)
|
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
||||||
const onError = (err: string) => chatStore.appendToLastMessage(convId, err)
|
const onError = (err: string) => {
|
||||||
|
console.error(`[AIUI ${provider}]`, err)
|
||||||
|
chatStore.appendToLastMessage(cid, `⚠ ${err}`)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (provider === 'anthropic') {
|
if (provider === 'claude') {
|
||||||
await streamAnthropic(history, onToken, onError)
|
await streamClaude(history, onToken, onError)
|
||||||
|
} else if (provider === 'openrouter') {
|
||||||
|
await streamOpenRouter(history, onToken, onError)
|
||||||
} else {
|
} else {
|
||||||
const orHistory = history.map((m) => ({
|
await streamMock(history, onToken)
|
||||||
...m,
|
|
||||||
role: m.role as 'system' | 'user' | 'assistant',
|
|
||||||
}))
|
|
||||||
await streamOpenRouter(orHistory, onToken, onError)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatStore.appendToLastMessage(
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
convId,
|
console.error(`[AIUI] Connection error:`, err)
|
||||||
`\n\nConnection error: ${err instanceof Error ? err.message : 'Unknown error'}`
|
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
chatStore.isStreaming = false
|
chatStore.isStreaming = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function useTheme() {
|
|||||||
currentTheme.value = theme
|
currentTheme.value = theme
|
||||||
localStorage.setItem('aiui-theme', theme)
|
localStorage.setItem('aiui-theme', theme)
|
||||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||||
|
document.documentElement.classList.toggle('light', theme === 'light')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
@@ -23,6 +24,8 @@ export function useTheme() {
|
|||||||
setTheme(saved)
|
setTheme(saved)
|
||||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
setTheme('dark')
|
setTheme('dark')
|
||||||
|
} else {
|
||||||
|
setTheme('light')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -9,6 +9,7 @@ declare module '*.vue' {
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_OPENROUTER_API_KEY: string
|
readonly VITE_OPENROUTER_API_KEY: string
|
||||||
readonly VITE_ANTHROPIC_API_KEY: string
|
readonly VITE_ANTHROPIC_API_KEY: string
|
||||||
|
readonly VITE_ANTHROPIC_TOKEN: string
|
||||||
readonly VITE_TMDB_API_KEY: string
|
readonly VITE_TMDB_API_KEY: string
|
||||||
readonly VITE_DEV_MODE: string
|
readonly VITE_DEV_MODE: string
|
||||||
readonly VITE_MOCK_MEDIA_SOURCES: string
|
readonly VITE_MOCK_MEDIA_SOURCES: string
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col bg-[#0a0a0a] relative overflow-hidden">
|
<div
|
||||||
<div class="absolute inset-0 pointer-events-none">
|
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||||
|
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'"
|
||||||
|
>
|
||||||
|
<div v-if="isDark" class="absolute inset-0 pointer-events-none">
|
||||||
<div class="absolute top-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-accent/5 blur-[120px]" />
|
<div class="absolute top-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-accent/5 blur-[120px]" />
|
||||||
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-info/5 blur-[120px]" />
|
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-info/5 blur-[120px]" />
|
||||||
</div>
|
</div>
|
||||||
@@ -11,17 +14,40 @@
|
|||||||
class="hidden lg:flex w-64 xl:w-72 shrink-0 flex-col glass rounded-2xl overflow-hidden"
|
class="hidden lg:flex w-64 xl:w-72 shrink-0 flex-col glass rounded-2xl overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="p-4 flex items-center justify-between"
|
<div class="p-4 flex items-center justify-between"
|
||||||
style="border-bottom: 1px solid rgba(255, 255, 255, 0.08);">
|
:style="isDark
|
||||||
|
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
|
||||||
|
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
|
||||||
|
>
|
||||||
<h1 class="text-lg font-bold gradient-text">AIUI</h1>
|
<h1 class="text-lg font-bold gradient-text">AIUI</h1>
|
||||||
<button
|
<div class="flex items-center gap-1">
|
||||||
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
<button
|
||||||
aria-label="New chat"
|
class="p-2 rounded-lg transition-colors"
|
||||||
@click="chatStore.createConversation()"
|
:class="isDark
|
||||||
>
|
? 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
aria-label="Toggle theme"
|
||||||
</svg>
|
@click="toggleTheme()"
|
||||||
</button>
|
>
|
||||||
|
<svg v-if="isDark" 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 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-2 rounded-lg transition-colors"
|
||||||
|
:class="isDark
|
||||||
|
? 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||||
|
aria-label="New chat"
|
||||||
|
@click="chatStore.createConversation()"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto scrollbar-hide px-2 pb-2 space-y-1 pt-2">
|
<div class="flex-1 overflow-y-auto scrollbar-hide px-2 pb-2 space-y-1 pt-2">
|
||||||
@@ -31,7 +57,9 @@
|
|||||||
class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 truncate"
|
class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 truncate"
|
||||||
:class="conv.id === chatStore.activeConversationId
|
:class="conv.id === chatStore.activeConversationId
|
||||||
? 'nav-tab-active'
|
? 'nav-tab-active'
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/10'"
|
: isDark
|
||||||
|
? 'text-white/60 hover:text-white hover:bg-white/10'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||||
@click="chatStore.setActiveConversation(conv.id)"
|
@click="chatStore.setActiveConversation(conv.id)"
|
||||||
>
|
>
|
||||||
{{ conv.title }}
|
{{ conv.title }}
|
||||||
@@ -39,14 +67,19 @@
|
|||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="conversations.length === 0"
|
v-if="conversations.length === 0"
|
||||||
class="text-xs text-white/20 text-center py-8"
|
class="text-xs text-center py-8"
|
||||||
|
:class="isDark ? 'text-white/20' : 'text-gray-400'"
|
||||||
>
|
>
|
||||||
No conversations yet
|
No conversations yet
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-3" style="border-top: 1px solid rgba(255, 255, 255, 0.06);">
|
<div class="p-3" :style="isDark
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px] text-white/25">
|
? 'border-top: 1px solid rgba(255, 255, 255, 0.06)'
|
||||||
|
: 'border-top: 1px solid rgba(0, 0, 0, 0.06)'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px]"
|
||||||
|
:class="isDark ? 'text-white/25' : 'text-gray-400'">
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
|
<span class="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
|
||||||
<span>{{ providerLabel }}</span>
|
<span>{{ providerLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,24 +97,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import { useAI } from '@/composables/useAI'
|
import { useAI } from '@/composables/useAI'
|
||||||
import ChatWindow from '@/components/chat/ChatWindow.vue'
|
import ChatWindow from '@/components/chat/ChatWindow.vue'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const { initTheme } = useTheme()
|
const { isDark, toggleTheme } = useTheme()
|
||||||
const { activeProvider } = useAI()
|
const { activeProvider } = useAI()
|
||||||
|
|
||||||
const providerLabel = computed(() => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
anthropic: 'Claude connected',
|
|
||||||
openrouter: 'OpenRouter connected',
|
|
||||||
}
|
|
||||||
return labels[activeProvider.value] ?? 'Connected'
|
|
||||||
})
|
|
||||||
|
|
||||||
const panelSide = computed(() => chatStore.panelSide)
|
const panelSide = computed(() => chatStore.panelSide)
|
||||||
const conversations = computed(() => chatStore.conversationList)
|
const conversations = computed(() => chatStore.conversationList)
|
||||||
const showSidebar = true
|
const showSidebar = true
|
||||||
@@ -90,7 +115,12 @@ const layoutClasses = computed(() =>
|
|||||||
panelSide.value === 'left' ? 'flex-row-reverse' : 'flex-row'
|
panelSide.value === 'left' ? 'flex-row-reverse' : 'flex-row'
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
const providerLabel = computed(() => {
|
||||||
initTheme()
|
const labels: Record<string, string> = {
|
||||||
|
claude: 'Claude Max connected',
|
||||||
|
openrouter: 'OpenRouter connected',
|
||||||
|
mock: 'Echo mode',
|
||||||
|
}
|
||||||
|
return labels[activeProvider.value] ?? 'Connected'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { Message, Conversation } from '@aiui/core/types/message'
|
import type { Message, Conversation } from '@aiui/core/types/message'
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
export const useChatStore = defineStore('chat', () => {
|
||||||
const conversations = ref<Map<string, Conversation>>(new Map())
|
const conversations = ref<Map<string, Conversation>>(new Map())
|
||||||
const activeConversationId = ref<string | null>(null)
|
const activeConversationId = ref<string | null>(null)
|
||||||
const isStreaming = ref(false)
|
const isStreaming = ref(false)
|
||||||
const panelSide = ref<'left' | 'right'>('right')
|
|
||||||
|
const savedSide = localStorage.getItem('aiui-panel-side') as 'left' | 'right' | null
|
||||||
|
const panelSide = ref<'left' | 'right'>(savedSide ?? 'right')
|
||||||
|
|
||||||
|
watch(panelSide, (val) => {
|
||||||
|
localStorage.setItem('aiui-panel-side', val)
|
||||||
|
})
|
||||||
|
|
||||||
const activeConversation = computed(() => {
|
const activeConversation = computed(() => {
|
||||||
if (!activeConversationId.value) return null
|
if (!activeConversationId.value) return null
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== GLASSMORPHISM — ported from Archy ===== */
|
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|
||||||
|
/* --- Dark mode (default) --- */
|
||||||
|
|
||||||
.glass {
|
.glass {
|
||||||
background-color: rgba(0, 0, 0, 0.35);
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
@@ -169,6 +172,85 @@ body {
|
|||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Light mode overrides — from Angor ultra-modern-light --- */
|
||||||
|
|
||||||
|
.light .glass {
|
||||||
|
background-color: rgba(250, 249, 246, 0.7);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.04),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .glass-strong {
|
||||||
|
background-color: rgba(250, 249, 246, 0.7);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.04),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .glass-card {
|
||||||
|
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%);
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 40px rgba(0, 0, 0, 0.04),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .glass-button {
|
||||||
|
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
|
||||||
|
border: 1px solid #0a0a0a;
|
||||||
|
color: #fafafa;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .glass-button:hover {
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .gradient-button {
|
||||||
|
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
|
||||||
|
border: 1px solid #0a0a0a;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .gradient-button:hover {
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .gradient-card {
|
||||||
|
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%);
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.04),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .toast-glass {
|
||||||
|
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%);
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .nav-tab-active {
|
||||||
|
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%) !important;
|
||||||
|
color: #0a0a0a !important;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 20px rgba(0, 0, 0, 0.06),
|
||||||
|
inset 0 1px 0 rgba(254, 253, 249, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .nav-tab-active::before {
|
||||||
|
background: linear-gradient(135deg, rgba(0, 0, 0, 0.08), transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== FOCUS STATES — gamepad/keyboard glow ===== */
|
/* ===== FOCUS STATES — gamepad/keyboard glow ===== */
|
||||||
@@ -188,6 +270,13 @@ body {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.light .gradient-text {
|
||||||
|
background: linear-gradient(to right, #0a0a0a, #6b7280);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== SCROLLBAR — Archy custom gradient scrollbar ===== */
|
/* ===== SCROLLBAR — Archy custom gradient scrollbar ===== */
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
@@ -209,6 +298,15 @@ body {
|
|||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 100%);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.light .custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.08) 100%);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|||||||
@@ -1,10 +1,62 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), tailwindcss()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'AIUI',
|
||||||
|
short_name: 'AIUI',
|
||||||
|
description: 'AI chat interface with rich content surfaces',
|
||||||
|
theme_color: '#0a0a0a',
|
||||||
|
background_color: '#0a0a0a',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/api\.anthropic\.com\/.*/i,
|
||||||
|
handler: 'NetworkOnly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/openrouter\.ai\/.*/i,
|
||||||
|
handler: 'NetworkOnly',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
@@ -13,6 +65,19 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
host: true,
|
||||||
open: true,
|
open: true,
|
||||||
|
proxy: {
|
||||||
|
'/api/claude': {
|
||||||
|
target: 'http://localhost:3141',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/claude/, ''),
|
||||||
|
},
|
||||||
|
'/api/openrouter': {
|
||||||
|
target: 'https://openrouter.ai',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/openrouter/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+3084
-17
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user