repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
The Living OS cockpit
stars
latest
clone command
git clone gitlawb://did:key:z6Mku78K...XywC/living-os-cockp...git clone gitlawb://did:key:z6Mku78K.../living-os-cockp...59751530feat: surface worker supervisor health in live work5h ago| #1 | 'use client'; |
| #2 | |
| #3 | import { useRef, useState } from 'react'; |
| #4 | import { Upload, Link2, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react'; |
| #5 | import MessageContent from '@/components/MessageContent'; |
| #6 | |
| #7 | const ACCEPTED = '.mp4,.mov,.mkv,.webm,.m4a,.mp3,.wav'; |
| #8 | |
| #9 | type IntakeMode = 'auto' | 'article' | 'clip_log' | 'script'; |
| #10 | |
| #11 | export default function ContentIntakePanel() { |
| #12 | const inputRef = useRef<HTMLInputElement>(null); |
| #13 | const [files, setFiles] = useState<File[]>([]); |
| #14 | const [urls, setUrls] = useState(''); |
| #15 | const [mode, setMode] = useState<IntakeMode>('auto'); |
| #16 | const [focus, setFocus] = useState(''); |
| #17 | const [pending, setPending] = useState<any>(null); |
| #18 | const [result, setResult] = useState<any>(null); |
| #19 | const [busy, setBusy] = useState(false); |
| #20 | const [error, setError] = useState(''); |
| #21 | |
| #22 | const urlList = urls |
| #23 | .split(/[\n,]+/g) |
| #24 | .map(item => item.trim()) |
| #25 | .filter(Boolean); |
| #26 | |
| #27 | const proposedInputs = [...files.map(file => file.name), ...urlList]; |
| #28 | |
| #29 | const addFiles = (incoming: FileList | File[]) => { |
| #30 | const next = Array.from(incoming).filter(file => /\.(mp4|mov|mkv|webm|m4a|mp3|wav)$/i.test(file.name)); |
| #31 | setFiles(prev => [...prev, ...next]); |
| #32 | setResult(null); |
| #33 | setError(''); |
| #34 | }; |
| #35 | |
| #36 | const propose = () => { |
| #37 | if (!files.length && !urlList.length) { |
| #38 | setError('Add at least one video/audio file or URL first.'); |
| #39 | return; |
| #40 | } |
| #41 | setPending({ |
| #42 | tool: 'content_intake', |
| #43 | parameters: { |
| #44 | input: proposedInputs, |
| #45 | mode, |
| #46 | focus, |
| #47 | target_collection: 'clip_bank', |
| #48 | }, |
| #49 | reason: 'This will upload media, call Aethon content_intake, transcribe/analyze it, and update the clip bank.', |
| #50 | }); |
| #51 | setResult(null); |
| #52 | setError(''); |
| #53 | }; |
| #54 | |
| #55 | const approve = async () => { |
| #56 | if (!pending) return; |
| #57 | setBusy(true); |
| #58 | setError(''); |
| #59 | setResult({ |
| #60 | pending: true, |
| #61 | message: 'Uploading media and starting Aethon content intake. Long videos may take several minutes while subtitles or Whisper run.', |
| #62 | }); |
| #63 | |
| #64 | try { |
| #65 | const form = new FormData(); |
| #66 | files.forEach(file => form.append('files', file)); |
| #67 | form.append('urls', JSON.stringify(urlList)); |
| #68 | form.append('mode', mode); |
| #69 | form.append('focus', focus); |
| #70 | form.append('target_collection', 'clip_bank'); |
| #71 | const response = await fetch('/api/content-intake', { method: 'POST', body: form }); |
| #72 | const data = await response.json(); |
| #73 | if (!response.ok || !data?.ok) throw new Error(data?.error || `Content intake returned ${response.status}`); |
| #74 | setResult(data); |
| #75 | setPending(null); |
| #76 | } catch (exc: any) { |
| #77 | setError(exc?.message || 'Content intake failed.'); |
| #78 | setResult(null); |
| #79 | } finally { |
| #80 | setBusy(false); |
| #81 | } |
| #82 | }; |
| #83 | |
| #84 | const summary = result?.result?.report |
| #85 | ? [ |
| #86 | '### Content intake complete', |
| #87 | '', |
| #88 | `**Thesis:** ${result.result.report.thesis || 'No thesis returned.'}`, |
| #89 | '', |
| #90 | '**Top clips**', |
| #91 | ...(result.result.report.top_3_clips || []).map((clip: any) => `- ${clip.start}-${clip.end}: ${clip.suggested_caption || clip.transcript_snippet || clip.reason}`), |
| #92 | '', |
| #93 | '**Cross-links**', |
| #94 | ...((result.result.report.cross_links || []).length ? result.result.report.cross_links.map((link: any) => `- ${link.source_video || 'source'} ${link.timestamp || ''}: ${link.why || ''}`) : ['- No existing clip-bank cross-links found yet.']), |
| #95 | '', |
| #96 | `> ${result.result.report.proactive_question || 'Want me to build this into the next segment?'}`, |
| #97 | ].join('\n') |
| #98 | : ''; |
| #99 | |
| #100 | return ( |
| #101 | <section className="content-intake-panel"> |
| #102 | <div className="content-intake-head"> |
| #103 | <div> |
| #104 | <h2 className="card-header text-lg">Content Intake</h2> |
| #105 | <p>Drop videos or paste URLs. Aethon turns them into clips, scripts, articles, and clip-bank memory.</p> |
| #106 | </div> |
| #107 | <span>Phase 2.5</span> |
| #108 | </div> |
| #109 | |
| #110 | <div |
| #111 | className="content-drop-zone" |
| #112 | onDragOver={event => event.preventDefault()} |
| #113 | onDrop={event => { |
| #114 | event.preventDefault(); |
| #115 | addFiles(event.dataTransfer.files); |
| #116 | }} |
| #117 | > |
| #118 | <Upload size={22} /> |
| #119 | <strong>Drop video or audio here</strong> |
| #120 | <p>.mp4, .mov, .mkv, .webm, .m4a, .mp3, .wav</p> |
| #121 | <button type="button" className="btn-ghost rounded px-3 py-1.5 text-xs" onClick={() => inputRef.current?.click()}> |
| #122 | Pick files |
| #123 | </button> |
| #124 | <input ref={inputRef} type="file" multiple hidden accept={ACCEPTED} onChange={event => event.target.files && addFiles(event.target.files)} /> |
| #125 | </div> |
| #126 | |
| #127 | {files.length > 0 && ( |
| #128 | <div className="content-file-list"> |
| #129 | {files.map((file, index) => ( |
| #130 | <span key={`${file.name}-${index}`}>{file.name}</span> |
| #131 | ))} |
| #132 | </div> |
| #133 | )} |
| #134 | |
| #135 | <label className="content-intake-label"> |
| #136 | <span><Link2 size={13} /> Source URLs</span> |
| #137 | <textarea value={urls} onChange={event => setUrls(event.target.value)} placeholder="One URL per line, or comma-separated" /> |
| #138 | </label> |
| #139 | |
| #140 | <div className="content-intake-grid"> |
| #141 | <label className="content-intake-label"> |
| #142 | <span>Mode</span> |
| #143 | <select value={mode} onChange={event => setMode(event.target.value as IntakeMode)}> |
| #144 | <option value="auto">Auto</option> |
| #145 | <option value="article">Article</option> |
| #146 | <option value="clip_log">Clip Log</option> |
| #147 | <option value="script">Script</option> |
| #148 | </select> |
| #149 | </label> |
| #150 | <label className="content-intake-label"> |
| #151 | <span>Focus</span> |
| #152 | <input value={focus} onChange={event => setFocus(event.target.value)} placeholder="strawman reveal, jurisdictional fraud..." /> |
| #153 | </label> |
| #154 | </div> |
| #155 | |
| #156 | <button type="button" className="btn-gold rounded px-4 py-2 text-sm inline-flex items-center gap-2" disabled={busy} onClick={propose}> |
| #157 | <PlayCircle size={15} /> Run Content Intake |
| #158 | </button> |
| #159 | |
| #160 | {pending && ( |
| #161 | <div className="tool-card"> |
| #162 | <h3>Confirm Aethon tool call</h3> |
| #163 | <p>{pending.reason}</p> |
| #164 | <pre>{JSON.stringify(pending.parameters, null, 2)}</pre> |
| #165 | <div className="flex gap-2"> |
| #166 | <button type="button" className="btn-gold rounded px-3 py-1.5 text-xs" disabled={busy} onClick={approve}>Approve and run</button> |
| #167 | <button type="button" className="btn-ghost rounded px-3 py-1.5 text-xs" disabled={busy} onClick={() => setPending(null)}>Cancel</button> |
| #168 | </div> |
| #169 | </div> |
| #170 | )} |
| #171 | |
| #172 | {result?.pending && ( |
| #173 | <div className="content-progress"> |
| #174 | <span className="spinner" /> |
| #175 | <div> |
| #176 | <strong>Transcribing and analyzing</strong> |
| #177 | <p>{result.message}</p> |
| #178 | </div> |
| #179 | </div> |
| #180 | )} |
| #181 | |
| #182 | {error && ( |
| #183 | <div className="content-error"><AlertTriangle size={14} /> {error}</div> |
| #184 | )} |
| #185 | |
| #186 | {summary && ( |
| #187 | <div className="content-result"> |
| #188 | <CheckCircle2 size={16} /> |
| #189 | <MessageContent content={summary} role="assistant" /> |
| #190 | </div> |
| #191 | )} |
| #192 | </section> |
| #193 | ); |
| #194 | } |
| #195 |