mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 15:02:31 +02:00
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:
parent
c8b74892ef
commit
ea87c350a5
19 changed files with 989 additions and 130 deletions
74
web/dist/assets/index-CQxQcFyH.js
vendored
Normal file
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
74
web/dist/assets/index-leRe8gQm.js
vendored
74
web/dist/assets/index-leRe8gQm.js
vendored
File diff suppressed because one or more lines are too long
4
web/dist/index.html
vendored
4
web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue