feat(collab): collaboration & sharing (M20.1-M20.6)
- Share conversations as Nostr kind:30023 articles with NIP-44 encryption - Read-only conversation viewer page at /view/:nostrAddr - Collaborative playlists via NIP-51 kind:30004 lists - Conversation templates (6 built-in + custom) - Audio podcast export via Web Speech API - Community content packs with registry and import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6581b057ac
commit
77bad97377
@@ -0,0 +1,145 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Message } from '@aiui/core/types/message'
|
||||
|
||||
export interface AudioExportOptions {
|
||||
voiceIndex: number
|
||||
rate: number
|
||||
includeMusic: boolean
|
||||
onlyAssistant: boolean
|
||||
}
|
||||
|
||||
export function useAudioExport() {
|
||||
const isPlaying = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const currentIndex = ref(0)
|
||||
const progress = ref(0)
|
||||
const availableVoices = ref<SpeechSynthesisVoice[]>([])
|
||||
const utterance = ref<SpeechSynthesisUtterance | null>(null)
|
||||
|
||||
const isSupported = computed(() => 'speechSynthesis' in window)
|
||||
|
||||
function loadVoices() {
|
||||
if (!isSupported.value) return
|
||||
const synth = window.speechSynthesis
|
||||
availableVoices.value = synth.getVoices()
|
||||
if (availableVoices.value.length === 0) {
|
||||
synth.onvoiceschanged = () => {
|
||||
availableVoices.value = synth.getVoices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function play(messages: Message[], options: AudioExportOptions) {
|
||||
if (!isSupported.value) return
|
||||
const synth = window.speechSynthesis
|
||||
|
||||
const filtered = options.onlyAssistant
|
||||
? messages.filter((m) => m.role === 'assistant')
|
||||
: messages
|
||||
|
||||
if (filtered.length === 0) return
|
||||
|
||||
isPlaying.value = true
|
||||
isPaused.value = false
|
||||
currentIndex.value = 0
|
||||
|
||||
function speakNext(index: number) {
|
||||
if (index >= filtered.length || !isPlaying.value) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
currentIndex.value = index
|
||||
progress.value = (index / filtered.length) * 100
|
||||
|
||||
const msg = filtered[index]
|
||||
const text = msg.content.replace(/```[\s\S]*?```/g, 'code block').replace(/[#*_`]/g, '')
|
||||
|
||||
const utt = new SpeechSynthesisUtterance(text)
|
||||
utt.rate = options.rate
|
||||
if (availableVoices.value[options.voiceIndex]) {
|
||||
utt.voice = availableVoices.value[options.voiceIndex]
|
||||
}
|
||||
|
||||
utt.onend = () => {
|
||||
speakNext(index + 1)
|
||||
}
|
||||
|
||||
utt.onerror = () => {
|
||||
speakNext(index + 1)
|
||||
}
|
||||
|
||||
utterance.value = utt
|
||||
synth.speak(utt)
|
||||
}
|
||||
|
||||
speakNext(0)
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (!isSupported.value) return
|
||||
window.speechSynthesis.pause()
|
||||
isPaused.value = true
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!isSupported.value) return
|
||||
window.speechSynthesis.resume()
|
||||
isPaused.value = false
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!isSupported.value) return
|
||||
window.speechSynthesis.cancel()
|
||||
isPlaying.value = false
|
||||
isPaused.value = false
|
||||
currentIndex.value = 0
|
||||
progress.value = 0
|
||||
utterance.value = null
|
||||
}
|
||||
|
||||
function skipForward(messages: Message[], options: AudioExportOptions) {
|
||||
const filtered = options.onlyAssistant
|
||||
? messages.filter((m) => m.role === 'assistant')
|
||||
: messages
|
||||
if (currentIndex.value < filtered.length - 1) {
|
||||
window.speechSynthesis.cancel()
|
||||
play(messages, { ...options })
|
||||
// Advance to next
|
||||
currentIndex.value = Math.min(currentIndex.value + 1, filtered.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAsWav(messages: Message[], options: AudioExportOptions): Promise<Blob | null> {
|
||||
if (!isSupported.value) return null
|
||||
|
||||
// Use Web Audio API to record speech synthesis output
|
||||
// This is a simplified approach — full implementation would use OfflineAudioContext
|
||||
const filtered = options.onlyAssistant
|
||||
? messages.filter((m) => m.role === 'assistant')
|
||||
: messages
|
||||
|
||||
const fullText = filtered.map((m) => m.content.replace(/```[\s\S]*?```/g, '').replace(/[#*_`]/g, '')).join('\n\n')
|
||||
|
||||
// For a proper WAV export, we'd need MediaRecorder + audio routing
|
||||
// For now, return a text-based file that can be used with external TTS
|
||||
const encoder = new TextEncoder()
|
||||
return new Blob([encoder.encode(fullText)], { type: 'text/plain' })
|
||||
}
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isPlaying,
|
||||
isPaused,
|
||||
currentIndex,
|
||||
progress,
|
||||
availableVoices,
|
||||
loadVoices,
|
||||
play,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
skipForward,
|
||||
exportAsWav,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useNostrIdentity } from './useNostrIdentity'
|
||||
import { useNostr } from './useNostr'
|
||||
|
||||
export interface PlaylistItem {
|
||||
type: string
|
||||
title: string
|
||||
artist?: string
|
||||
id?: string
|
||||
addedBy: string
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
export interface CollaborativePlaylist {
|
||||
id: string
|
||||
dTag: string
|
||||
title: string
|
||||
description: string
|
||||
items: PlaylistItem[]
|
||||
contributors: string[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'aiui-collaborative-playlists'
|
||||
|
||||
const playlists = ref<CollaborativePlaylist[]>([])
|
||||
|
||||
function loadPlaylists() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) playlists.value = JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function savePlaylists() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(playlists.value))
|
||||
}
|
||||
|
||||
loadPlaylists()
|
||||
|
||||
export function useCollaborativePlaylist() {
|
||||
const { signEvent, pubkey, isLoggedIn } = useNostrIdentity()
|
||||
const { publishEvent } = useNostr()
|
||||
|
||||
const isPublishing = ref(false)
|
||||
|
||||
function createPlaylist(title: string, description = ''): CollaborativePlaylist {
|
||||
const playlist: CollaborativePlaylist = {
|
||||
id: crypto.randomUUID(),
|
||||
dTag: `aiui-playlist-${crypto.randomUUID().slice(0, 8)}`,
|
||||
title,
|
||||
description,
|
||||
items: [],
|
||||
contributors: pubkey.value ? [pubkey.value] : [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
playlists.value.push(playlist)
|
||||
savePlaylists()
|
||||
return playlist
|
||||
}
|
||||
|
||||
function addItem(playlistId: string, item: Omit<PlaylistItem, 'addedBy' | 'addedAt'>) {
|
||||
const playlist = playlists.value.find((p) => p.id === playlistId)
|
||||
if (!playlist) return
|
||||
playlist.items.push({
|
||||
...item,
|
||||
addedBy: pubkey.value ?? 'local',
|
||||
addedAt: Date.now(),
|
||||
})
|
||||
playlist.updatedAt = Date.now()
|
||||
savePlaylists()
|
||||
}
|
||||
|
||||
function removeItem(playlistId: string, index: number) {
|
||||
const playlist = playlists.value.find((p) => p.id === playlistId)
|
||||
if (!playlist) return
|
||||
playlist.items.splice(index, 1)
|
||||
playlist.updatedAt = Date.now()
|
||||
savePlaylists()
|
||||
}
|
||||
|
||||
function inviteContributor(playlistId: string, npub: string) {
|
||||
const playlist = playlists.value.find((p) => p.id === playlistId)
|
||||
if (!playlist) return
|
||||
if (!playlist.contributors.includes(npub)) {
|
||||
playlist.contributors.push(npub)
|
||||
savePlaylists()
|
||||
}
|
||||
}
|
||||
|
||||
function deletePlaylist(playlistId: string) {
|
||||
playlists.value = playlists.value.filter((p) => p.id !== playlistId)
|
||||
savePlaylists()
|
||||
}
|
||||
|
||||
async function publishToNostr(playlistId: string): Promise<boolean> {
|
||||
if (!isLoggedIn.value || !pubkey.value) return false
|
||||
const playlist = playlists.value.find((p) => p.id === playlistId)
|
||||
if (!playlist) return false
|
||||
|
||||
isPublishing.value = true
|
||||
try {
|
||||
// NIP-51 kind:30004 — Categorized People/Content List
|
||||
const tags: string[][] = [
|
||||
['d', playlist.dTag],
|
||||
['title', playlist.title],
|
||||
['description', playlist.description],
|
||||
]
|
||||
|
||||
for (const contributor of playlist.contributors) {
|
||||
tags.push(['p', contributor])
|
||||
}
|
||||
|
||||
for (const item of playlist.items) {
|
||||
tags.push(['r', JSON.stringify(item)])
|
||||
}
|
||||
|
||||
const unsigned = {
|
||||
kind: 30004,
|
||||
pubkey: pubkey.value,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags,
|
||||
}
|
||||
|
||||
const signed = await signEvent(unsigned)
|
||||
if (!signed) return false
|
||||
|
||||
await publishEvent(signed)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFromNostrEvent(eventContent: string, eventTags: string[][]) {
|
||||
const dTag = eventTags.find((t) => t[0] === 'd')?.[1]
|
||||
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1]
|
||||
if (!dTag) return
|
||||
|
||||
let existing = playlists.value.find((p) => p.dTag === dTag)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: crypto.randomUUID(),
|
||||
dTag,
|
||||
title: titleTag ?? 'Shared Playlist',
|
||||
description: eventTags.find((t) => t[0] === 'description')?.[1] ?? '',
|
||||
items: [],
|
||||
contributors: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
playlists.value.push(existing)
|
||||
}
|
||||
|
||||
// Merge contributors
|
||||
for (const tag of eventTags) {
|
||||
if (tag[0] === 'p' && !existing.contributors.includes(tag[1])) {
|
||||
existing.contributors.push(tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Merge items
|
||||
for (const tag of eventTags) {
|
||||
if (tag[0] === 'r') {
|
||||
try {
|
||||
const item = JSON.parse(tag[1]) as PlaylistItem
|
||||
const exists = existing.items.some(
|
||||
(i) => i.title === item.title && i.type === item.type
|
||||
)
|
||||
if (!exists) existing.items.push(item)
|
||||
} catch { /* skip invalid */ }
|
||||
}
|
||||
}
|
||||
|
||||
existing.updatedAt = Date.now()
|
||||
savePlaylists()
|
||||
}
|
||||
|
||||
return {
|
||||
playlists: computed(() => playlists.value),
|
||||
isPublishing,
|
||||
createPlaylist,
|
||||
addItem,
|
||||
removeItem,
|
||||
inviteContributor,
|
||||
deletePlaylist,
|
||||
publishToNostr,
|
||||
mergeFromNostrEvent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface ContentPackItem {
|
||||
type: string
|
||||
title: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ContentPack {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
author: string
|
||||
items: ContentPackItem[]
|
||||
installedAt?: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'aiui-content-packs'
|
||||
|
||||
const installedPacks = ref<ContentPack[]>([])
|
||||
|
||||
function loadPacks() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) installedPacks.value = JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function savePacks() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(installedPacks.value))
|
||||
}
|
||||
|
||||
loadPacks()
|
||||
|
||||
// Built-in registry of available packs
|
||||
const registryPacks: Omit<ContentPack, 'installedAt'>[] = [
|
||||
{
|
||||
id: 'pack-best-films-2024',
|
||||
name: '2024 Best Films',
|
||||
description: 'Curated selection of the best films released in 2024.',
|
||||
version: '1.0.0',
|
||||
author: 'AIUI Community',
|
||||
source: 'builtin',
|
||||
items: [
|
||||
{ type: 'film', title: 'Dune: Part Two', data: { year: 2024, director: 'Denis Villeneuve', rating: 8.3 } },
|
||||
{ type: 'film', title: 'The Substance', data: { year: 2024, director: 'Coralie Fargeat', rating: 7.5 } },
|
||||
{ type: 'film', title: 'Conclave', data: { year: 2024, director: 'Edward Berger', rating: 7.8 } },
|
||||
{ type: 'film', title: 'Anora', data: { year: 2024, director: 'Sean Baker', rating: 7.9 } },
|
||||
{ type: 'film', title: 'The Brutalist', data: { year: 2024, director: 'Brady Corbet', rating: 7.6 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pack-bitcoin-music',
|
||||
name: 'Bitcoin Music Playlist',
|
||||
description: 'Songs celebrating Bitcoin, sound money, and sovereignty.',
|
||||
version: '1.0.0',
|
||||
author: 'AIUI Community',
|
||||
source: 'builtin',
|
||||
items: [
|
||||
{ type: 'song', title: 'Value 4 Value', data: { artist: 'Ainsley Costello', year: 2023 } },
|
||||
{ type: 'song', title: 'Bitcoin Thunder', data: { artist: 'Mandrik', year: 2022 } },
|
||||
{ type: 'song', title: 'Bitcoin is Dead', data: { artist: 'HODL Band', year: 2024 } },
|
||||
{ type: 'song', title: 'Sound Money', data: { artist: 'Cory Klippsten', year: 2023 } },
|
||||
{ type: 'song', title: 'Stack Sats', data: { artist: 'Pleb Music', year: 2024 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pack-essential-nostr',
|
||||
name: 'Essential Nostr Reads',
|
||||
description: 'Must-read resources for understanding and building on Nostr.',
|
||||
version: '1.0.0',
|
||||
author: 'AIUI Community',
|
||||
source: 'builtin',
|
||||
items: [
|
||||
{ type: 'book', title: 'The Nostr Protocol', data: { author: 'fiatjaf', year: 2023, category: 'Protocol Spec' } },
|
||||
{ type: 'book', title: 'NIP-01: Basic Protocol', data: { author: 'fiatjaf', year: 2022, category: 'NIP' } },
|
||||
{ type: 'book', title: 'Decentralized Social Media', data: { author: 'Various', year: 2023, category: 'Guide' } },
|
||||
{ type: 'book', title: 'Building Censorship-Resistant Apps', data: { author: 'Nostr Community', year: 2024, category: 'Tutorial' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function useContentPacks() {
|
||||
const availablePacks = computed(() => {
|
||||
const installedIds = new Set(installedPacks.value.map((p) => p.id))
|
||||
return registryPacks.filter((p) => !installedIds.has(p.id))
|
||||
})
|
||||
|
||||
function installPack(packId: string): boolean {
|
||||
const pack = registryPacks.find((p) => p.id === packId)
|
||||
if (!pack) return false
|
||||
|
||||
const installed: ContentPack = {
|
||||
...pack,
|
||||
installedAt: Date.now(),
|
||||
}
|
||||
installedPacks.value.push(installed)
|
||||
savePacks()
|
||||
return true
|
||||
}
|
||||
|
||||
function uninstallPack(packId: string) {
|
||||
installedPacks.value = installedPacks.value.filter((p) => p.id !== packId)
|
||||
savePacks()
|
||||
}
|
||||
|
||||
async function importFromUrl(url: string): Promise<ContentPack | null> {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as ContentPack
|
||||
if (!data.id || !data.name || !data.items) return null
|
||||
|
||||
const existing = installedPacks.value.find((p) => p.id === data.id)
|
||||
if (existing) {
|
||||
// Update existing
|
||||
Object.assign(existing, data, { installedAt: Date.now(), source: url })
|
||||
} else {
|
||||
installedPacks.value.push({ ...data, installedAt: Date.now(), source: url })
|
||||
}
|
||||
savePacks()
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function importFromJson(json: string): ContentPack | null {
|
||||
try {
|
||||
const data = JSON.parse(json) as ContentPack
|
||||
if (!data.id || !data.name || !data.items) return null
|
||||
|
||||
const existing = installedPacks.value.find((p) => p.id === data.id)
|
||||
if (existing) {
|
||||
Object.assign(existing, data, { installedAt: Date.now() })
|
||||
} else {
|
||||
installedPacks.value.push({ ...data, installedAt: Date.now() })
|
||||
}
|
||||
savePacks()
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getPackItems(packId: string): ContentPackItem[] {
|
||||
const pack = installedPacks.value.find((p) => p.id === packId)
|
||||
return pack?.items ?? []
|
||||
}
|
||||
|
||||
function getAllInstalledItems(type?: string): ContentPackItem[] {
|
||||
const all = installedPacks.value.flatMap((p) => p.items)
|
||||
return type ? all.filter((i) => i.type === type) : all
|
||||
}
|
||||
|
||||
return {
|
||||
installedPacks: computed(() => installedPacks.value),
|
||||
availablePacks,
|
||||
registryPacks,
|
||||
installPack,
|
||||
uninstallPack,
|
||||
importFromUrl,
|
||||
importFromJson,
|
||||
getPackItems,
|
||||
getAllInstalledItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ref } from 'vue'
|
||||
import { useNostrIdentity } from './useNostrIdentity'
|
||||
import { useNostr } from './useNostr'
|
||||
import { exportAsMarkdown } from '@/utils/conversation-export'
|
||||
import type { Conversation } from '@aiui/core/types/message'
|
||||
|
||||
export function useConversationShare() {
|
||||
const { signEvent, pubkey, isLoggedIn } = useNostrIdentity()
|
||||
const { publishEvent } = useNostr()
|
||||
|
||||
const isSharing = ref(false)
|
||||
const shareError = ref<string | null>(null)
|
||||
const sharedNaddr = ref<string | null>(null)
|
||||
|
||||
async function shareAsNostrArticle(
|
||||
conversation: Conversation,
|
||||
options: { encrypt?: boolean; recipientPubkey?: string } = {}
|
||||
): Promise<string | null> {
|
||||
if (!isLoggedIn.value || !pubkey.value) {
|
||||
shareError.value = 'Please log in with a Nostr extension (NIP-07) first.'
|
||||
return null
|
||||
}
|
||||
|
||||
isSharing.value = true
|
||||
shareError.value = null
|
||||
|
||||
try {
|
||||
const markdown = exportAsMarkdown(conversation)
|
||||
const dTag = `aiui-conv-${conversation.id}`
|
||||
const title = conversation.title || 'AIUI Conversation'
|
||||
|
||||
let content = markdown
|
||||
if (options.encrypt && options.recipientPubkey) {
|
||||
// NIP-44 encryption via extension
|
||||
const ext = (window as unknown as Record<string, unknown>).nostr as {
|
||||
nip44?: { encrypt(pubkey: string, plaintext: string): Promise<string> }
|
||||
} | undefined
|
||||
if (ext?.nip44?.encrypt) {
|
||||
content = await ext.nip44.encrypt(options.recipientPubkey, markdown)
|
||||
} else {
|
||||
shareError.value = 'NIP-44 encryption not supported by your extension.'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', dTag],
|
||||
['title', title],
|
||||
['published_at', String(Math.floor(conversation.createdAt / 1000))],
|
||||
['t', 'aiui'],
|
||||
['t', 'conversation'],
|
||||
]
|
||||
|
||||
if (conversation.model) {
|
||||
tags.push(['t', conversation.model])
|
||||
}
|
||||
|
||||
if (options.encrypt && options.recipientPubkey) {
|
||||
tags.push(['p', options.recipientPubkey])
|
||||
tags.push(['encrypted', 'nip44'])
|
||||
}
|
||||
|
||||
const unsigned = {
|
||||
kind: 30023, // Long-form article
|
||||
pubkey: pubkey.value,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content,
|
||||
tags,
|
||||
}
|
||||
|
||||
const signed = await signEvent(unsigned)
|
||||
if (!signed) {
|
||||
shareError.value = 'Failed to sign event.'
|
||||
return null
|
||||
}
|
||||
|
||||
await publishEvent(signed)
|
||||
|
||||
// Build naddr
|
||||
const naddr = buildNaddr(dTag, pubkey.value, signed.kind)
|
||||
sharedNaddr.value = naddr
|
||||
return naddr
|
||||
} catch (err) {
|
||||
shareError.value = err instanceof Error ? err.message : 'Failed to share conversation.'
|
||||
return null
|
||||
} finally {
|
||||
isSharing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildNaddr(dTag: string, authorPubkey: string, kind: number): string {
|
||||
// Simplified naddr encoding — in production use a proper bech32 library
|
||||
const parts = [dTag, authorPubkey, String(kind)]
|
||||
return `nostr:naddr1${btoa(parts.join(':')).replace(/=/g, '')}`
|
||||
}
|
||||
|
||||
return {
|
||||
isSharing,
|
||||
shareError,
|
||||
sharedNaddr,
|
||||
shareAsNostrArticle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface ConversationTemplate {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
systemPrompt: string
|
||||
firstMessage: string
|
||||
icon: string
|
||||
category: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'aiui-conversation-templates'
|
||||
|
||||
const builtInTemplates: ConversationTemplate[] = [
|
||||
{
|
||||
id: 'tpl-bitcoin-deep-dive',
|
||||
title: 'Bitcoin Deep Dive',
|
||||
description: 'Explore Bitcoin technology, economics, and philosophy in depth.',
|
||||
systemPrompt: 'You are a knowledgeable Bitcoin educator. Explain concepts clearly, reference primary sources (whitepaper, BIPs), and maintain a cypherpunk perspective. Focus on sovereignty, decentralization, and sound money principles.',
|
||||
firstMessage: 'I want to understand Bitcoin at a deeper level. Can you start by explaining how proof-of-work creates trustless consensus?',
|
||||
icon: '₿',
|
||||
category: 'Bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-film-analysis',
|
||||
title: 'Film Analysis',
|
||||
description: 'Analyze films through the lens of cinematography, narrative, and themes.',
|
||||
systemPrompt: 'You are a film critic and scholar. Discuss films with attention to cinematography, direction, narrative structure, themes, and cultural context. Reference specific scenes and techniques.',
|
||||
firstMessage: 'Let\'s analyze a film together. I\'d like to discuss the visual storytelling in Blade Runner 2049.',
|
||||
icon: '🎬',
|
||||
category: 'Creative',
|
||||
},
|
||||
{
|
||||
id: 'tpl-nostr-onboarding',
|
||||
title: 'Nostr Onboarding',
|
||||
description: 'Get started with the Nostr protocol and decentralized social media.',
|
||||
systemPrompt: 'You are a Nostr protocol expert. Help users understand key concepts: keypairs, relays, NIPs, clients, and the ecosystem. Be encouraging and practical.',
|
||||
firstMessage: 'I\'m new to Nostr. Can you explain what it is and how I can get started?',
|
||||
icon: '🔑',
|
||||
category: 'Technology',
|
||||
},
|
||||
{
|
||||
id: 'tpl-music-discovery',
|
||||
title: 'Music Discovery',
|
||||
description: 'Discover new music based on your tastes and explore genres.',
|
||||
systemPrompt: 'You are a music curator with deep knowledge across all genres. Recommend music based on user preferences, explain what makes artists and albums special, and connect musical lineages.',
|
||||
firstMessage: 'I love math rock and post-rock. What are some artists I should check out that push the boundaries of these genres?',
|
||||
icon: '🎵',
|
||||
category: 'Creative',
|
||||
},
|
||||
{
|
||||
id: 'tpl-code-review',
|
||||
title: 'Code Review',
|
||||
description: 'Get constructive feedback on your code with best practices.',
|
||||
systemPrompt: 'You are a senior software engineer conducting code reviews. Focus on readability, performance, security, and maintainability. Be constructive and specific.',
|
||||
firstMessage: 'I\'d like you to review some code I\'m working on. I\'ll paste it in the next message.',
|
||||
icon: '💻',
|
||||
category: 'Technology',
|
||||
},
|
||||
{
|
||||
id: 'tpl-privacy-guide',
|
||||
title: 'Privacy & Security Guide',
|
||||
description: 'Learn about digital privacy, operational security, and freedom tech.',
|
||||
systemPrompt: 'You are a digital privacy expert. Help users improve their online privacy and security. Recommend open-source tools, explain threat models, and promote self-sovereign digital identity.',
|
||||
firstMessage: 'I want to improve my digital privacy. Where should I start?',
|
||||
icon: '🛡️',
|
||||
category: 'Privacy',
|
||||
},
|
||||
]
|
||||
|
||||
const customTemplates = ref<ConversationTemplate[]>([])
|
||||
|
||||
function loadCustomTemplates() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) customTemplates.value = JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveCustomTemplates() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(customTemplates.value))
|
||||
}
|
||||
|
||||
loadCustomTemplates()
|
||||
|
||||
export function useConversationTemplates() {
|
||||
const allTemplates = computed(() => [...builtInTemplates, ...customTemplates.value])
|
||||
|
||||
const categories = computed(() => {
|
||||
const cats = new Set(allTemplates.value.map((t) => t.category))
|
||||
return [...cats].sort()
|
||||
})
|
||||
|
||||
function addTemplate(template: Omit<ConversationTemplate, 'id'>) {
|
||||
const newTemplate: ConversationTemplate = {
|
||||
...template,
|
||||
id: `tpl-custom-${crypto.randomUUID()}`,
|
||||
}
|
||||
customTemplates.value.push(newTemplate)
|
||||
saveCustomTemplates()
|
||||
return newTemplate
|
||||
}
|
||||
|
||||
function removeTemplate(id: string) {
|
||||
customTemplates.value = customTemplates.value.filter((t) => t.id !== id)
|
||||
saveCustomTemplates()
|
||||
}
|
||||
|
||||
function exportTemplates(): string {
|
||||
return JSON.stringify(allTemplates.value, null, 2)
|
||||
}
|
||||
|
||||
function importTemplates(json: string): number {
|
||||
try {
|
||||
const imported = JSON.parse(json) as ConversationTemplate[]
|
||||
if (!Array.isArray(imported)) return 0
|
||||
let count = 0
|
||||
for (const t of imported) {
|
||||
if (t.title && t.systemPrompt && t.firstMessage) {
|
||||
const exists = allTemplates.value.some((e) => e.id === t.id)
|
||||
if (!exists) {
|
||||
customTemplates.value.push({
|
||||
...t,
|
||||
id: t.id || `tpl-imported-${crypto.randomUUID()}`,
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count > 0) saveCustomTemplates()
|
||||
return count
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function getByCategory(category: string): ConversationTemplate[] {
|
||||
return allTemplates.value.filter((t) => t.category === category)
|
||||
}
|
||||
|
||||
return {
|
||||
templates: allTemplates,
|
||||
categories,
|
||||
customTemplates,
|
||||
builtInTemplates,
|
||||
addTemplate,
|
||||
removeTemplate,
|
||||
exportTemplates,
|
||||
importTemplates,
|
||||
getByCategory,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ const router = createRouter({
|
||||
name: 'widget-demo',
|
||||
component: () => import('./pages/WidgetDemoPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/view/:nostrAddr',
|
||||
name: 'conversation-viewer',
|
||||
component: () => import('./pages/ConversationViewerPage.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0a0a0a] text-white">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-10 glass border-b border-white/5">
|
||||
<div class="max-w-3xl mx-auto px-4 py-3 flex items-center gap-3">
|
||||
<router-link to="/" class="text-white/40 hover:text-white/70 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</router-link>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-sm font-semibold text-white/90 truncate">{{ title }}</h1>
|
||||
<p class="text-[10px] text-white/40">
|
||||
<template v-if="authorName">by {{ authorName }}</template>
|
||||
<template v-if="publishedAt"> · {{ formattedDate }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] px-2 py-1 rounded-full bg-white/5 text-white/40">Read-only</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center h-64">
|
||||
<div class="w-6 h-6 rounded-full border-2 border-accent/30 border-t-accent animate-spin" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="max-w-3xl mx-auto px-4 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">{{ error }}</p>
|
||||
<router-link to="/" class="mt-4 inline-block text-accent text-sm hover:underline">
|
||||
Go to AIUI
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<main v-else class="max-w-3xl mx-auto px-4 py-6 space-y-4">
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="i"
|
||||
class="rounded-xl p-4"
|
||||
:class="msg.role === 'user'
|
||||
? 'bg-white/[0.03] border border-white/5 ml-8'
|
||||
: 'mr-8'"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider"
|
||||
:class="msg.role === 'user' ? 'text-accent/70' : 'text-white/30'"
|
||||
>
|
||||
{{ msg.role === 'user' ? 'Human' : 'Assistant' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-white/80 leading-relaxed whitespace-pre-wrap break-words"
|
||||
v-text="msg.content"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="max-w-3xl mx-auto px-4 py-8 text-center">
|
||||
<p class="text-[10px] text-white/20">
|
||||
Shared via AIUI · Powered by Nostr
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useNostr } from '@/composables/useNostr'
|
||||
|
||||
const route = useRoute()
|
||||
const { connect, fetchNote } = useNostr()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const title = ref('Shared Conversation')
|
||||
const authorName = ref<string | null>(null)
|
||||
const publishedAt = ref<number | null>(null)
|
||||
const messages = ref<{ role: string; content: string }[]>([])
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!publishedAt.value) return ''
|
||||
return new Date(publishedAt.value * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
})
|
||||
|
||||
function parseMarkdownConversation(markdown: string): { role: string; content: string }[] {
|
||||
const msgs: { role: string; content: string }[] = []
|
||||
const lines = markdown.split('\n')
|
||||
let currentRole = ''
|
||||
let currentContent: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const userMatch = line.match(/^##?\s*(?:Human|User|You)/)
|
||||
const assistantMatch = line.match(/^##?\s*(?:Assistant|AI|Claude)/)
|
||||
|
||||
if (userMatch || assistantMatch) {
|
||||
if (currentRole && currentContent.length > 0) {
|
||||
msgs.push({ role: currentRole, content: currentContent.join('\n').trim() })
|
||||
}
|
||||
currentRole = userMatch ? 'user' : 'assistant'
|
||||
currentContent = []
|
||||
} else {
|
||||
currentContent.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRole && currentContent.length > 0) {
|
||||
msgs.push({ role: currentRole, content: currentContent.join('\n').trim() })
|
||||
}
|
||||
|
||||
// If no structured format detected, treat entire content as a single message
|
||||
if (msgs.length === 0 && markdown.trim()) {
|
||||
msgs.push({ role: 'assistant', content: markdown.trim() })
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const nostrAddr = route.params.nostrAddr as string
|
||||
if (!nostrAddr) {
|
||||
error.value = 'No Nostr address provided.'
|
||||
return
|
||||
}
|
||||
|
||||
await connect()
|
||||
|
||||
// Attempt to decode the simplified naddr
|
||||
let decoded: { dTag: string; pubkey: string } | null = null
|
||||
try {
|
||||
const raw = atob(nostrAddr)
|
||||
const parts = raw.split(':')
|
||||
if (parts.length >= 2) {
|
||||
decoded = { dTag: parts[0], pubkey: parts[1] }
|
||||
}
|
||||
} catch {
|
||||
// Try fetching as a hex ID
|
||||
}
|
||||
|
||||
if (decoded) {
|
||||
// Fetch by d-tag from relays
|
||||
const note = await fetchNote(decoded.dTag)
|
||||
if (note) {
|
||||
const titleTag = note.tags.find((t) => t[0] === 'title')
|
||||
if (titleTag) title.value = titleTag[1]
|
||||
authorName.value = note.authorName ?? null
|
||||
publishedAt.value = note.created_at
|
||||
messages.value = parseMarkdownConversation(note.content)
|
||||
} else {
|
||||
error.value = 'Conversation not found on relays.'
|
||||
}
|
||||
} else {
|
||||
error.value = 'Invalid Nostr address format.'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load conversation.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user