feat(app): add design system viewer, nostr feed, stop generation, and content refactor

- Design system browser with grid/detail views for tokens and components
- Nostr feed tab with note/article/zap filtering and relay status
- Stop generation button to abort AI streaming mid-response
- Paste & extract content without sending to AI
- Refactor useContentPanel into contentExtraction.ts and contentFiltering.ts
- Banner fallback composable for 3-stage image loading
- Wikipedia and Google Books as fallback image sources
- Loading skeletons with variant-specific shapes
- Mobile UX: auto-switch to content, back button, detail flow
- Project grid with breadcrumb nav and inline creation
- Filesystem Vite plugin for local project browsing
- Magazine text cleanup and song grid polish

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 13:08:32 +00:00
co-authored by Claude Opus 4.6
parent e8fc54cade
commit 00bdc055ba
31 changed files with 3175 additions and 1555 deletions
+50 -20
View File
@@ -114,6 +114,7 @@ interface ChatMessage {
async function streamMock(
messages: ChatMessage[],
onToken: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const lastUser = messages.filter((m) => m.role === 'user').pop()
const text = lastUser
@@ -121,6 +122,7 @@ async function streamMock(
: 'Hello! I am AIUI running in mock mode.'
for (const char of text) {
if (signal?.aborted) return
onToken(char)
await new Promise((r) => setTimeout(r, 12))
}
@@ -132,6 +134,7 @@ async function streamClaude(
onError: (err: string) => void,
systemPrompt: string,
webSearch: boolean,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch(CLAUDE_PATH, {
method: 'POST',
@@ -143,6 +146,7 @@ async function streamClaude(
stream: true,
webSearch,
}),
signal,
})
if (!res.ok) {
@@ -158,7 +162,7 @@ async function streamClaude(
} else if (parsed.type === 'error') {
onError(parsed.error?.message ?? 'Claude stream error')
}
}, onError)
}, onError, signal)
}
async function streamOpenRouter(
@@ -166,6 +170,7 @@ async function streamOpenRouter(
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const orMessages = [
{ role: 'system' as const, content: systemPrompt },
@@ -184,6 +189,7 @@ async function streamOpenRouter(
messages: orMessages,
stream: true,
}),
signal,
})
if (!res.ok) {
@@ -197,13 +203,14 @@ async function streamOpenRouter(
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) onToken(delta)
}, onError)
}, onError, 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) {
@@ -214,25 +221,33 @@ async function readSSE(
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
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() ?? ''
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
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
}
}
}
} finally {
reader.cancel().catch(() => {})
}
}
@@ -247,11 +262,23 @@ function formatWebSearchContext(results: { title: string; url: string; content?:
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
}
let currentAbort: AbortController | null = null
export function useAI() {
const chatStore = useChatStore()
function stopGeneration() {
if (currentAbort) {
currentAbort.abort()
currentAbort = null
}
chatStore.isStreaming = false
}
async function sendMessage(userText: string) {
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
let convId = chatStore.activeConversationId
if (!convId) {
@@ -294,23 +321,26 @@ export function useAI() {
try {
if (provider === 'claude') {
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled)
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, systemPrompt)
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
} else {
await streamMock(history, onToken)
await streamMock(history, onToken, signal)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : String(err)
console.error(`[AIUI] Connection error:`, err)
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
} finally {
currentAbort = null
chatStore.isStreaming = false
}
}
return {
sendMessage,
stopGeneration,
activeProvider,
activeModel,
availableProviders,