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:
co-authored by
Claude Opus 4.6
parent
e8fc54cade
commit
00bdc055ba
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user