feat: Installationsroutine für macOS (setup.sh)

- prüft macOS, Homebrew, Node 22+, npm-Abhängigkeiten, ffmpeg,
  whisper.cpp inkl. Modell, Piper inkl. Stimme, Ollama-Server und
  das Sprachmodell — fehlende Teile werden auf Nachfrage installiert
- Modi: interaktiv, --check (nur Prüfung), --yes (ohne Nachfragen)
- README: Schnellstart-Abschnitt (clone → setup → dev)
- npm run setup als Alias
This commit is contained in:
Jeuner 2026-08-28 13:35:16 +02:00
parent c8b74892ef
commit ea87c350a5
19 changed files with 989 additions and 130 deletions

74
web/dist/assets/index-CQxQcFyH.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -6,8 +6,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#0a0f0c" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-leRe8gQm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKaNlN-w.css">
<script type="module" crossorigin src="/assets/index-CQxQcFyH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CXQcDbVB.css">
</head>
<body>
<div id="root"></div>

View file

@ -157,10 +157,10 @@ export default function App() {
: chat.options.model.replace(/:latest$/, "")) ?? "qwen3";
const handleSend = useCallback(
(text: string, images: string[] = []) => {
(text: string, images: string[] = [], files: { name: string; content: string; encoding?: string }[] = []) => {
voice.cancelSpeech();
pendingSpeechRef.current = "";
chat.sendMessage(text, images);
chat.sendMessage(text, images, files);
},
[chat, voice],
);

View file

@ -27,6 +27,22 @@ function imagesOf(message: Message): string[] {
}
}
/** Der Server legt Text-Datei-Anhänge als JSON-Array {name, content} ab. */
function filesOf(message: Message): { name: string; content: string }[] {
if (!message.files) return [];
try {
const parsed: unknown = JSON.parse(message.files);
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((f) => {
const o = f as { name?: unknown; content?: unknown };
if (typeof o?.name !== "string" || typeof o?.content !== "string") return [];
return [{ name: o.name, content: o.content }];
});
} catch {
return [];
}
}
export function ChatMessage({
message,
toolEvents = [],
@ -39,6 +55,7 @@ export function ChatMessage({
const [showThinking, setShowThinking] = useState(false);
const isUser = message.role === "user";
const images = imagesOf(message);
const chatFiles = filesOf(message);
return (
<div className={`msg ${isUser ? "msg-user" : "msg-assistant"}`}>
@ -90,6 +107,20 @@ export function ChatMessage({
</div>
)}
{chatFiles.length > 0 && (
<div className="msg-files">
{chatFiles.map((f) => (
<span
className="file-chip"
key={f.name}
title={`${f.content.length.toLocaleString("de-DE")} Zeichen`}
>
📄 {f.name} · {Math.max(1, Math.round(f.content.length / 1024))} kB
</span>
))}
</div>
)}
{isUser ? (
message.content ? (
<div className="msg-content user-content">{message.content}</div>

View file

@ -4,6 +4,20 @@ import { useEffect, useRef, useState } from "react";
const MAX_IMAGES = 4;
const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
const ACCEPTED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
/** Muss zu MAX_FILES_PER_MESSAGE / MAX_FILE_CHARS im Server passen. */
const MAX_FILES = 4;
const MAX_FILE_CHARS = 100_000;
const MAX_PDF_BYTES = 10 * 1024 * 1024;
const TEXT_EXTS = [
".txt", ".json", ".md", ".markdown", ".csv", ".tsv", ".log", ".xml", ".yaml",
".yml", ".toml", ".ini", ".cfg", ".conf", ".sql", ".sh", ".bash", ".zsh",
".py", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".css", ".scss", ".html",
".htm", ".svg", ".rs", ".go", ".java", ".kt", ".rb", ".php", ".c", ".h",
".cpp", ".hpp", ".cs", ".swift", ".diff", ".patch",
];
const PDF_TYPES = ["application/pdf"];
const PDF_EXTS = [".pdf"];
const TEXT_MIMES = ["application/json", "application/xml", "application/yaml", "application/x-sh"];
interface Attachment {
id: string;
@ -13,6 +27,15 @@ interface Attachment {
name: string;
}
interface TextAttachment {
id: string;
name: string;
/** Textinhalt oder base64 (encoding: base64, nur PDF). */
content: string;
bytes: number;
encoding: "text" | "base64";
}
interface Props {
streaming: boolean;
disabled: boolean;
@ -21,7 +44,7 @@ interface Props {
injectedText: string | null;
modelLabel?: string;
onInjected: () => void;
onSend: (text: string, images: string[]) => void;
onSend: (text: string, images: string[], files?: { name: string; content: string; encoding?: string }[]) => void;
onAbort: () => void;
onMicToggle: () => void;
}
@ -44,6 +67,44 @@ function readAsAttachment(file: File): Promise<Attachment> {
});
}
async function readAsTextAttachment(file: File): Promise<TextAttachment> {
const ext = "." + (file.name.split(".").pop() ?? "").toLowerCase();
if (PDF_TYPES.includes(file.type) || PDF_EXTS.includes(ext)) {
if (file.size > MAX_PDF_BYTES) {
throw new Error(`${file.name}: größer als ${Math.round(MAX_PDF_BYTES / 1024 / 1024)} MB`);
}
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error(`${file.name} konnte nicht gelesen werden`));
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(file);
});
const comma = dataUrl.indexOf(",");
return {
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
name: file.name || "PDF",
content: dataUrl.slice(comma + 1),
bytes: file.size,
encoding: "base64",
};
}
const content = await file.text();
if (content.length === 0) throw new Error(`${file.name} ist leer`);
if (content.length > MAX_FILE_CHARS) {
throw new Error(`${file.name}: größer als ${Math.round(MAX_FILE_CHARS / 1000)} kB Text`);
}
if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content.slice(0, 2000))) {
throw new Error(`${file.name}: wirkt binär — nur Textdateien`);
}
return {
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
name: file.name || "Datei",
content,
bytes: file.size,
encoding: "text",
};
}
export function Composer({
streaming,
disabled,
@ -58,6 +119,7 @@ export function Composer({
}: Props) {
const [value, setValue] = useState("");
const [images, setImages] = useState<Attachment[]>([]);
const [textFiles, setTextFiles] = useState<TextAttachment[]>([]);
const [imgError, setImgError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const ref = useRef<HTMLTextAreaElement>(null);
@ -76,43 +138,71 @@ export function Composer({
const addFiles = async (files: File[]) => {
setImgError(null);
const usable: File[] = [];
const imgFiles: File[] = [];
const txtFiles: File[] = [];
for (const f of files) {
if (!ACCEPTED.includes(f.type)) {
setImgError(`${f.name || "Datei"}: nur PNG, JPEG, GIF oder WebP`);
continue;
const ext = "." + (f.name.split(".").pop() ?? "").toLowerCase();
if (ACCEPTED.includes(f.type)) {
if (f.size > MAX_IMAGE_BYTES) {
setImgError(`${f.name}: größer als ${MAX_IMAGE_BYTES / 1024 / 1024} MB`);
continue;
}
imgFiles.push(f);
} else if (
f.type.startsWith("text/") ||
TEXT_MIMES.includes(f.type) ||
PDF_TYPES.includes(f.type) ||
TEXT_EXTS.includes(ext) ||
PDF_EXTS.includes(ext)
) {
if (f.size > MAX_FILE_CHARS * 2) {
setImgError(`${f.name}: größer als ${Math.round((MAX_FILE_CHARS * 2) / 1000)} kB`);
continue;
}
txtFiles.push(f);
} else {
setImgError(`${f.name || "Datei"}: nur Bilder (PNG, JPEG, GIF, WebP) oder Textdateien`);
}
if (f.size > MAX_IMAGE_BYTES) {
setImgError(`${f.name}: größer als ${MAX_IMAGE_BYTES / 1024 / 1024} MB`);
continue;
}
usable.push(f);
}
if (!usable.length) return;
try {
const added = await Promise.all(usable.map(readAsAttachment));
setImages((prev) => {
const free = MAX_IMAGES - prev.length;
if (added.length > free) setImgError(`Maximal ${MAX_IMAGES} Bilder pro Nachricht`);
return [...prev, ...added.slice(0, Math.max(free, 0))];
});
if (imgFiles.length) {
const added = await Promise.all(imgFiles.map(readAsAttachment));
setImages((prev) => {
const free = MAX_IMAGES - prev.length;
if (added.length > free) setImgError(`Maximal ${MAX_IMAGES} Bilder pro Nachricht`);
return [...prev, ...added.slice(0, Math.max(free, 0))];
});
}
if (txtFiles.length) {
const added = await Promise.all(txtFiles.map(readAsTextAttachment));
setTextFiles((prev) => {
const free = MAX_FILES - prev.length;
if (added.length > free) setImgError(`Maximal ${MAX_FILES} Dateien pro Nachricht`);
return [...prev, ...added.slice(0, Math.max(free, 0))];
});
}
} catch (err) {
setImgError(err instanceof Error ? err.message : "Bild konnte nicht gelesen werden");
setImgError(err instanceof Error ? err.message : "Datei konnte nicht gelesen werden");
}
};
const submit = () => {
const text = value.trim();
if ((!text && images.length === 0) || streaming || disabled) return;
onSend(text, images.map((i) => i.base64));
if ((!text && images.length === 0 && textFiles.length === 0) || streaming || disabled) return;
onSend(text, images.map((i) => i.base64), textFiles.map((f) => ({
name: f.name,
content: f.content,
encoding: f.encoding === "base64" ? "base64" : undefined,
})));
setValue("");
setImages([]);
setTextFiles([]);
setImgError(null);
requestAnimationFrame(() => ref.current?.focus());
};
const canSend = (value.trim().length > 0 || images.length > 0) && !disabled;
const canSend = (value.trim().length > 0 || images.length > 0 || textFiles.length > 0) && !disabled;
return (
<div
@ -130,7 +220,7 @@ export function Composer({
void addFiles([...e.dataTransfer.files]);
}}
>
{(images.length > 0 || imgError) && (
{(images.length > 0 || textFiles.length > 0 || imgError) && (
<div className="attachments">
{images.map((img) => (
<div className="attachment" key={img.id}>
@ -144,6 +234,20 @@ export function Composer({
</button>
</div>
))}
{textFiles.map((f) => (
<div className="attachment attachment-file" key={f.id}>
<span className="attachment-file-name">
📄 {f.name} · {Math.max(1, Math.round(f.bytes / 1024))} kB
</span>
<button
className="attachment-remove"
onClick={() => setTextFiles((p) => p.filter((i) => i.id !== f.id))}
title={`${f.name} entfernen`}
>
×
</button>
</div>
))}
{imgError && <span className="attachment-error">{imgError}</span>}
</div>
)}
@ -167,19 +271,15 @@ export function Composer({
<button
className="btn-attach"
onClick={() => fileRef.current?.click()}
disabled={disabled || images.length >= MAX_IMAGES}
title={
images.length >= MAX_IMAGES
? `Maximal ${MAX_IMAGES} Bilder`
: "Bild anhängen (auch per Einfügen oder Drag & Drop)"
}
disabled={disabled}
title="Bild oder Textdatei anhängen (auch per Einfügen oder Drag & Drop)"
>
🖼
📎
</button>
<input
ref={fileRef}
type="file"
accept={ACCEPTED.join(",")}
accept={[...ACCEPTED, ...TEXT_EXTS, ...PDF_EXTS].join(",")}
multiple
hidden
onChange={(e) => {
@ -202,9 +302,15 @@ export function Composer({
disabled={disabled}
onChange={(e) => setValue(e.target.value)}
onPaste={(e) => {
const files = [...e.clipboardData.files].filter((f) =>
f.type.startsWith("image/"),
);
const files = [...e.clipboardData.files].filter((f) => {
const ext = "." + (f.name.split(".").pop() ?? "").toLowerCase();
return (
ACCEPTED.includes(f.type) ||
f.type.startsWith("text/") ||
TEXT_MIMES.includes(f.type) ||
TEXT_EXTS.includes(ext)
);
});
if (files.length) {
e.preventDefault();
void addFiles(files);

View file

@ -833,3 +833,45 @@ body {
.anchor-actions button:hover {
color: var(--accent);
}
/* ---------- Datei-Anhänge ---------- */
.attachment-file {
width: auto;
min-width: 120px;
max-width: 320px;
height: auto;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-elevated);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
.attachment-file-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-files {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.file-chip {
font-size: 12px;
color: var(--text-dim);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -12,9 +12,17 @@ export interface Message {
thinking: string | null;
/** JSON-Array mit base64-Bilddaten, wie es der Server speichert. */
images: string | null;
/** JSON-Array mit Text-Datei-Anhängen ({name, content}), wie es der Server speichert. */
files: string | null;
created_at: number;
}
export interface ChatFile {
name: string;
content: string;
encoding?: string;
}
/** Werkzeugaufruf während einer Antwort. Nur zur Laufzeit, nicht gespeichert. */
export interface ToolEvent {
name: string;

View file

@ -177,9 +177,9 @@ export function useChat() {
}, []);
const sendMessage = useCallback(
(content: string, images: string[] = []) => {
// Ein Bild ohne Text ist eine gültige Anfrage.
if ((!content.trim() && images.length === 0) || streamingRef.current || !activeId) {
(content: string, images: string[] = [], files: { name: string; content: string; encoding?: string }[] = []) => {
// Ein Bild oder eine Datei allein ist eine gültige Anfrage.
if ((!content.trim() && images.length === 0 && files.length === 0) || streamingRef.current || !activeId) {
return;
}
streamingRef.current = true;
@ -189,6 +189,7 @@ export function useChat() {
sessionId: activeId,
content,
images,
files,
options,
systemPrompt,
});