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
17
README.md
17
README.md
|
|
@ -23,6 +23,7 @@ npm-Workspace mit zwei Paketen:
|
|||
|
||||
## Voraussetzungen
|
||||
|
||||
- **macOS** mit [Homebrew](https://brew.sh)
|
||||
- **Node.js 22+** — `server/src/db.ts` nutzt das eingebaute `node:sqlite`,
|
||||
das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start
|
||||
eine `ExperimentalWarning`; ab Node 24 ist das Modul stabil.
|
||||
|
|
@ -32,6 +33,22 @@ npm-Workspace mit zwei Paketen:
|
|||
- Piper als **Python-Modul** — der Server ruft `python3 -m piper` auf,
|
||||
nicht das gleichnamige Homebrew-Binary
|
||||
|
||||
## Schnellstart (Mac)
|
||||
|
||||
Ein Befehl von null bis zur lauffähigen App — das Skript prüft alle
|
||||
Abhängigkeiten und bietet fehlende (Homebrew, Node, ffmpeg, whisper, Piper,
|
||||
Ollama, Modell, Sprachdateien) zur Installation an:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Jeuners/agenttwo-tools.git
|
||||
cd agenttwo-tools
|
||||
./setup.sh # oder: npm run setup
|
||||
npm run dev # → http://localhost:5174
|
||||
```
|
||||
|
||||
Nur prüfen ohne etwas zu ändern: `./setup.sh --check` · Ohne Nachfragen:
|
||||
`./setup.sh --yes`
|
||||
|
||||
Unter macOS:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
232
package-lock.json
generated
232
package-lock.json
generated
|
|
@ -920,6 +920,205 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
|
|
@ -3325,6 +3524,38 @@
|
|||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -4693,6 +4924,7 @@
|
|||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"fastify": "^5.2.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"setup": "bash setup.sh",
|
||||
"dev": "npm run dev --workspace server & npm run dev --workspace web & wait",
|
||||
"dev:server": "npm run dev --workspace server",
|
||||
"dev:web": "npm run dev --workspace web",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"fastify": "^5.2.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -23,13 +23,19 @@ db.exec(`
|
|||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||
`);
|
||||
|
||||
// Bestehende Datenbanken aus agenttwo kennen die Spalte noch nicht.
|
||||
// Bestehende Datenbanken aus agenttwo kennen die Spalten noch nicht.
|
||||
const hasImages = (
|
||||
db.prepare("PRAGMA table_info(messages)").all() as unknown as { name: string }[]
|
||||
).some((c) => c.name === "images");
|
||||
if (!hasImages) {
|
||||
db.exec("ALTER TABLE messages ADD COLUMN images TEXT");
|
||||
}
|
||||
const hasFiles = (
|
||||
db.prepare("PRAGMA table_info(messages)").all() as unknown as { name: string }[]
|
||||
).some((c) => c.name === "files");
|
||||
if (!hasFiles) {
|
||||
db.exec("ALTER TABLE messages ADD COLUMN files TEXT");
|
||||
}
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
|
|
@ -45,6 +51,8 @@ export interface MessageRow {
|
|||
thinking: string | null;
|
||||
/** JSON-Array mit base64-Bilddaten (ohne data:-Präfix), oder null. */
|
||||
images: string | null;
|
||||
/** JSON-Array mit Text-Datei-Anhängen ({name, content}), oder null. */
|
||||
files: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +100,7 @@ export function insertMessage(
|
|||
content: string,
|
||||
thinking?: string | null,
|
||||
images?: string[] | null,
|
||||
files?: { name: string; content: string }[] | null,
|
||||
): MessageRow {
|
||||
const row: MessageRow = {
|
||||
id: randomUUID(),
|
||||
|
|
@ -100,10 +109,11 @@ export function insertMessage(
|
|||
content,
|
||||
thinking: thinking ?? null,
|
||||
images: images?.length ? JSON.stringify(images) : null,
|
||||
files: files?.length ? JSON.stringify(files) : null,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
db.prepare(
|
||||
"INSERT INTO messages (id, session_id, role, content, thinking, images, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO messages (id, session_id, role, content, thinking, images, files, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(
|
||||
row.id,
|
||||
row.session_id,
|
||||
|
|
@ -111,6 +121,7 @@ export function insertMessage(
|
|||
row.content,
|
||||
row.thinking,
|
||||
row.images,
|
||||
row.files,
|
||||
row.created_at,
|
||||
);
|
||||
return row;
|
||||
|
|
@ -127,6 +138,24 @@ export function parseImages(row: Pick<MessageRow, "images">): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
/** Text-Datei-Anhänge einer Zeile als Array — leer, wenn keine oder unlesbar. */
|
||||
export function parseFiles(
|
||||
row: Pick<MessageRow, "files">,
|
||||
): { name: string; content: string }[] {
|
||||
if (!row.files) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(row.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 updateAssistantMessage(
|
||||
id: string,
|
||||
content: string,
|
||||
|
|
|
|||
134
server/src/files.ts
Normal file
134
server/src/files.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
export const MAX_FILES_PER_MESSAGE = 4;
|
||||
/** Maximale Zeichen je Textdatei — deckt JSON/CSV/Code weit ab, ohne das Kontextfenster zu sprengen. */
|
||||
export const MAX_FILE_CHARS = 100_000;
|
||||
/** Maximale PDF-Größe vor der Textextraktion. */
|
||||
export const MAX_PDF_BYTES = 10 * 1024 * 1024;
|
||||
/** Maximale Namenlänge nach Bereinigung. */
|
||||
const MAX_NAME_CHARS = 120;
|
||||
|
||||
export interface ValidatedFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface RawFile {
|
||||
name?: unknown;
|
||||
content?: unknown;
|
||||
encoding?: unknown;
|
||||
}
|
||||
|
||||
export class FileError extends Error {}
|
||||
|
||||
/** Stripped Pfade und gefährliche Namen; "." und ".." werden abgewiesen. */
|
||||
function sanitizeName(raw: unknown): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
throw new FileError("Datei ohne Namen");
|
||||
}
|
||||
const base = raw.split(/[\\/]/).pop() ?? "";
|
||||
const name = base.replace(/[\x00-\x1F]/g, "").trim().slice(0, MAX_NAME_CHARS);
|
||||
if (!name || name === "." || name === "..") {
|
||||
throw new FileError("Ungültiger Dateiname");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Textnachweis: druckbare Zeichen plus Whitespace; sonstige Steuerzeichen → binär. */
|
||||
function isText(content: string): boolean {
|
||||
return !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content.slice(0, 2000));
|
||||
}
|
||||
|
||||
function decodeBase64(input: string): Buffer {
|
||||
const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
|
||||
const raw = comma === -1 ? input : input.slice(comma + 1);
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(raw.replace(/\s/g, ""))) {
|
||||
throw new FileError("Anhang ist kein gültiges base64");
|
||||
}
|
||||
return Buffer.from(raw, "base64");
|
||||
}
|
||||
|
||||
async function extractPdfText(name: string, b64: string): Promise<string> {
|
||||
const buf = decodeBase64(b64);
|
||||
if (buf.length === 0) throw new FileError(`${name} ist leer`);
|
||||
if (buf.length > MAX_PDF_BYTES) {
|
||||
throw new FileError(`${name} ist größer als ${Math.round(MAX_PDF_BYTES / 1024 / 1024)} MB`);
|
||||
}
|
||||
if (!buf.subarray(0, 5).toString("latin1").startsWith("%PDF-")) {
|
||||
throw new FileError(`${name} ist keine PDF-Datei`);
|
||||
}
|
||||
let text = "";
|
||||
try {
|
||||
const parser = new PDFParse({ data: new Uint8Array(buf) });
|
||||
try {
|
||||
const result = await parser.getText();
|
||||
text = result.text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
} catch {
|
||||
throw new FileError(`${name} konnte nicht gelesen werden`);
|
||||
}
|
||||
text = text.replace(/\x00/g, "").trim();
|
||||
if (!text) {
|
||||
throw new FileError(
|
||||
`${name} enthält keinen extrahierbaren Text — vermutlich gescannt (OCR wird nicht unterstützt)`,
|
||||
);
|
||||
}
|
||||
if (text.length > MAX_FILE_CHARS) {
|
||||
text = text.slice(0, MAX_FILE_CHARS) + "\n[…gekürzt]";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Datei-Anhänge: Textdateien direkt, PDFs als base64 mit serverseitiger
|
||||
* Textextraktion. Wirft FileError, sobald etwas nicht passt.
|
||||
*/
|
||||
export async function prepareFiles(raw: unknown): Promise<ValidatedFile[]> {
|
||||
if (raw === undefined || raw === null) return [];
|
||||
if (!Array.isArray(raw)) throw new FileError("files muss ein Array sein");
|
||||
if (raw.length > MAX_FILES_PER_MESSAGE) {
|
||||
throw new FileError(`Maximal ${MAX_FILES_PER_MESSAGE} Dateien pro Nachricht`);
|
||||
}
|
||||
|
||||
const out: ValidatedFile[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new FileError("Datei hat ein ungültiges Format");
|
||||
}
|
||||
const o = entry as RawFile;
|
||||
const name = sanitizeName(o.name);
|
||||
|
||||
if (o.encoding === "base64") {
|
||||
if (typeof o.content !== "string" || o.content.length === 0) {
|
||||
throw new FileError(`${name} ist leer`);
|
||||
}
|
||||
out.push({ name, content: await extractPdfText(name, o.content) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof o.content !== "string" || o.content.length === 0) {
|
||||
throw new FileError(`${name} ist leer`);
|
||||
}
|
||||
if (o.content.length > MAX_FILE_CHARS) {
|
||||
throw new FileError(
|
||||
`${name} ist größer als ${Math.round(MAX_FILE_CHARS / 1000)} kB`,
|
||||
);
|
||||
}
|
||||
if (!isText(o.content)) {
|
||||
throw new FileError(`${name} wirkt binär — nur Textdateien werden unterstützt`);
|
||||
}
|
||||
out.push({ name, content: o.content });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Baut einen Datei-Block für den LLM-Kontext, gekürzt auf maxChars. */
|
||||
export function fileBlock(name: string, content: string, maxChars = 24_000): string {
|
||||
const body =
|
||||
content.length > maxChars
|
||||
? content.slice(0, maxChars) + "\n[…gekürzt]"
|
||||
: content;
|
||||
return `[Datei: ${name}]\n\`\`\`\n${body}\n\`\`\``;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "./openrouter.js";
|
||||
import { ALLOWED_ORIGINS, isOriginAllowed, createRateLimiter } from "./security.js";
|
||||
import { validateImages, ImageError, MAX_WS_PAYLOAD } from "./images.js";
|
||||
import { prepareFiles, FileError, fileBlock } from "./files.js";
|
||||
import { toolNames } from "./tools/index.js";
|
||||
|
||||
// simple .env loader (project root)
|
||||
|
|
@ -81,6 +82,7 @@ app.addHook("onRequest", async (req, reply) => {
|
|||
|
||||
const sttLimiter = createRateLimiter(10, 60_000);
|
||||
const ttsLimiter = createRateLimiter(30, 60_000);
|
||||
const dreamLimiter = createRateLimiter(4, 60_000);
|
||||
|
||||
app.addContentTypeParser(
|
||||
["application/octet-stream", "audio/*", "video/*"],
|
||||
|
|
@ -145,6 +147,9 @@ app.get("/api/sessions/:id/memory", async (req, reply) => {
|
|||
});
|
||||
|
||||
app.post("/api/sessions/:id/dream", async (req, reply) => {
|
||||
if (!dreamLimiter(req.ip)) {
|
||||
return reply.code(429).send({ error: "Zu viele Anfragen" });
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
|
||||
const result = await mem.dream(id, { ollamaUrl: OLLAMA_URL, model: MODEL });
|
||||
|
|
@ -153,6 +158,9 @@ app.post("/api/sessions/:id/dream", async (req, reply) => {
|
|||
});
|
||||
|
||||
app.post("/api/sessions/:id/memory/rebuild", async (req, reply) => {
|
||||
if (!dreamLimiter(req.ip)) {
|
||||
return reply.code(429).send({ error: "Zu viele Anfragen" });
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
|
||||
const result = mem.rebuildMemory(id);
|
||||
|
|
@ -351,35 +359,56 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Ein Bild allein ist eine gültige Anfrage — Text darf dann fehlen.
|
||||
if (!sessionId || (!content && images.length === 0)) {
|
||||
let chatFiles: { name: string; content: string }[];
|
||||
try {
|
||||
chatFiles = await prepareFiles(msg.files);
|
||||
} catch (err) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: err instanceof FileError ? err.message : "Datei abgelehnt",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ein Bild oder eine Datei allein ist eine gültige Anfrage — Text darf dann fehlen.
|
||||
if (!sessionId || (!content && images.length === 0 && chatFiles.length === 0)) {
|
||||
socket.send(JSON.stringify({ type: "error", error: "sessionId/content fehlt" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let session = dbmod.getSession(sessionId);
|
||||
if (!session) session = dbmod.createSession();
|
||||
dbmod.renameSessionIfDefault(session.id, content || "Bild");
|
||||
dbmod.renameSessionIfDefault(
|
||||
session.id,
|
||||
content || chatFiles.map((f) => f.name).join(", ") || "Bild",
|
||||
);
|
||||
|
||||
const userMsg = dbmod.insertMessage(session.id, "user", content, null, images);
|
||||
const userMsg = dbmod.insertMessage(session.id, "user", content, null, images, chatFiles);
|
||||
mem.appendEvent(session.id, "message", {
|
||||
role: "user",
|
||||
content,
|
||||
images: images.length,
|
||||
files: chatFiles.map((f) => ({ name: f.name, chars: f.content.length })),
|
||||
});
|
||||
socket.send(JSON.stringify({ type: "user-message", message: userMsg }));
|
||||
broadcast({ type: "sessions-changed" });
|
||||
|
||||
const opts = parseOptions(msg.options);
|
||||
opts.sessionId = session.id;
|
||||
const history = dbmod
|
||||
.listMessages(session.id)
|
||||
.slice(-opts.memorySteps)
|
||||
.map((m) => {
|
||||
const history = dbmod.listMessages(session.id).slice(-opts.memorySteps).map((m) => {
|
||||
const imgs = dbmod.parseImages(m);
|
||||
return imgs.length
|
||||
? { role: m.role, content: m.content, images: imgs }
|
||||
: { role: m.role, content: m.content };
|
||||
let text = m.content;
|
||||
const msgFiles = dbmod.parseFiles(m);
|
||||
if (msgFiles.length) {
|
||||
text = (text ? text + "\n\n" : "") + msgFiles.map((f) => fileBlock(f.name, f.content)).join("\n\n");
|
||||
}
|
||||
const base: { role: string; content: string; images?: string[] } = {
|
||||
role: m.role,
|
||||
content: text,
|
||||
};
|
||||
return imgs.length ? { ...base, images: imgs } : base;
|
||||
});
|
||||
|
||||
const userSystem =
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ export function appendEvent(
|
|||
type: "message" | "tool_call",
|
||||
payload: Record<string, unknown>,
|
||||
): number {
|
||||
if (typeof payload.content === "string") {
|
||||
payload = { ...payload, content: payload.content.slice(0, 20_000) };
|
||||
}
|
||||
const res = db
|
||||
.prepare(
|
||||
"INSERT INTO memory_events (session_id, type, payload, created_at) VALUES (?, ?, ?, ?)",
|
||||
|
|
|
|||
225
setup.sh
Executable file
225
setup.sh
Executable file
|
|
@ -0,0 +1,225 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# agenttwo-tools — Installationsroutine für macOS
|
||||
# Prüft alle Abhängigkeiten und bietet fehlende Komponenten zur Installation an.
|
||||
#
|
||||
# ./setup.sh interaktiv (fragt vor jeder Installation)
|
||||
# ./setup.sh --check prüft nur, ändert nichts
|
||||
# ./setup.sh --yes installiert alles Fehlende ohne Nachfragen
|
||||
#
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
AUTO=0
|
||||
CHECK=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes|-y) AUTO=1 ;;
|
||||
--check|-c) CHECK=1 ;;
|
||||
*) echo "Unbekannte Option: $arg"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; BOLD='\033[1m'; OFF='\033[0m'
|
||||
ok() { printf " ${GREEN}✓${OFF} %s\n" "$1"; }
|
||||
warn() { printf " ${YELLOW}!${OFF} %s\n" "$1"; }
|
||||
fail() { printf " ${RED}✗${OFF} %s\n" "$1"; }
|
||||
sect() { printf "\n${BOLD}== %s ==${OFF}\n" "$1"; }
|
||||
|
||||
MISSING=0
|
||||
note_missing() { MISSING=$((MISSING + 1)); }
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
ask() {
|
||||
if [ "$AUTO" -eq 1 ]; then echo " → --yes: installiere automatisch"; return 0; fi
|
||||
printf " ${BOLD}%s installieren? [J/n] ${OFF}" "$1"
|
||||
read -r answer
|
||||
[ -z "$answer" ] || [ "$answer" = "j" ] || [ "$answer" = "J" ] || [ "$answer" = "ja" ]
|
||||
}
|
||||
|
||||
# .env lesen (nur einfache KEY=VALUE-Zeilen), ohne sie zu verändern
|
||||
env_var() {
|
||||
if [ -f .env ]; then
|
||||
grep -E "^\s*$1=" .env | head -1 | cut -d= -f2- | sed 's/^["'\'']//; s/["'\'']$//'
|
||||
fi
|
||||
}
|
||||
|
||||
MODEL="$(env_var MODEL)"; MODEL="${MODEL:-qwen3.5:latest}"
|
||||
WHISPER_MODEL="$(env_var WHISPER_MODEL)"; WHISPER_MODEL="${WHISPER_MODEL:-$HOME/whisper-models/ggml-large-v3-turbo.bin}"
|
||||
WHISPER_MODEL="${WHISPER_MODEL/#\~/$HOME}"
|
||||
PIPER_MODEL="$(env_var PIPER_MODEL)"; PIPER_MODEL="${PIPER_MODEL:-server/voices/de_DE-thorsten-high.onnx}"
|
||||
OLLAMA_URL="$(env_var OLLAMA_URL)"; OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
|
||||
|
||||
sect "agenttwo-tools — Setup"
|
||||
echo "Ziel: lauffähiger Voice-Chat auf diesem Mac (alles lokal)."
|
||||
echo "Modell: $MODEL · Ollama: $OLLAMA_URL"
|
||||
|
||||
sect "1/9 macOS"
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
ok "macOS $(sw_vers -productVersion 2>/dev/null) · $(uname -m)"
|
||||
else
|
||||
fail "Kein macOS — dieses Skript ist für macOS geschrieben."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sect "2/9 Homebrew"
|
||||
if have brew; then
|
||||
ok "brew $(brew --version | head -1 | awk '{print $2}')"
|
||||
else
|
||||
note_missing
|
||||
warn "Homebrew fehlt (Paketbasis für fast alles Weitere)."
|
||||
if [ "$CHECK" -eq 0 ] && ask "Homebrew"; then
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || fail "Homebrew-Installation fehlgeschlagen"
|
||||
if [ "$(uname -m)" = "arm64" ] && ! have brew; then
|
||||
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
fi
|
||||
have brew && ok "Homebrew installiert" || fail "brew weiterhin nicht gefunden — Terminal neu starten und Skript erneut laufen lassen"
|
||||
fi
|
||||
fi
|
||||
|
||||
sect "3/9 Node.js 22+"
|
||||
NODE_OK=0
|
||||
if have node; then
|
||||
NODE_MAJOR="$(node -v | sed 's/^v//' | cut -d. -f1)"
|
||||
if [ "${NODE_MAJOR:-0}" -ge 22 ]; then
|
||||
ok "node $(node -v)"
|
||||
NODE_OK=1
|
||||
else
|
||||
warn "node $(node -v) ist zu alt (mind. v22, wegen node:sqlite)."
|
||||
fi
|
||||
fi
|
||||
if [ "$NODE_OK" -eq 0 ]; then
|
||||
note_missing
|
||||
if [ "$CHECK" -eq 0 ] && have brew && ask "Node.js 22 (brew install node@22)"; then
|
||||
brew install node@22 && brew link --overwrite node@22
|
||||
have node && ok "node $(node -v)" || fail "node weiterhin nicht gefunden — Terminal neu starten"
|
||||
fi
|
||||
fi
|
||||
|
||||
sect "4/9 Projekt-Abhängigkeiten (npm)"
|
||||
if [ -d node_modules ] && [ -x node_modules/.bin/tsx ] && [ -x node_modules/.bin/vite ]; then
|
||||
ok "node_modules vorhanden"
|
||||
else
|
||||
note_missing
|
||||
if [ "$CHECK" -eq 0 ] && have npm && ask "npm install"; then
|
||||
npm install && ok "Abhängigkeiten installiert" || fail "npm install fehlgeschlagen"
|
||||
fi
|
||||
fi
|
||||
|
||||
sect "5/9 ffmpeg"
|
||||
if have ffmpeg; then
|
||||
ok "ffmpeg $(ffmpeg -version 2>/dev/null | head -1 | awk '{print $3}')"
|
||||
else
|
||||
note_missing
|
||||
if [ "$CHECK" -eq 0 ] && have brew && ask "ffmpeg (brew install ffmpeg)"; then
|
||||
brew install ffmpeg && ok "ffmpeg installiert" || fail "ffmpeg-Installation fehlgeschlagen"
|
||||
fi
|
||||
fi
|
||||
|
||||
sect "6/9 whisper.cpp (Spracheingabe)"
|
||||
if have whisper-cli; then
|
||||
ok "whisper-cli gefunden"
|
||||
else
|
||||
note_missing
|
||||
if [ "$CHECK" -eq 0 ] && have brew && ask "whisper.cpp (brew install whisper-cpp)"; then
|
||||
brew install whisper-cpp && ok "whisper-cli installiert" || fail "whisper-cpp-Installation fehlgeschlagen"
|
||||
fi
|
||||
fi
|
||||
if [ -f "$WHISPER_MODEL" ]; then
|
||||
ok "Whisper-Modell: $(basename "$WHISPER_MODEL") ($(( $(stat -f%z "$WHISPER_MODEL") / 1024 / 1024 )) MB)"
|
||||
else
|
||||
note_missing
|
||||
warn "Whisper-Modell fehlt: $WHISPER_MODEL (~1,5 GB Download)."
|
||||
if [ "$CHECK" -eq 0 ] && ask "Whisper-Modell herunterladen"; then
|
||||
mkdir -p "$(dirname "$WHISPER_MODEL")"
|
||||
curl -L -o "$WHISPER_MODEL" \
|
||||
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin" \
|
||||
&& ok "Whisper-Modell geladen" || fail "Download fehlgeschlagen"
|
||||
fi
|
||||
fi
|
||||
|
||||
sect "7/9 Piper (Sprachausgabe)"
|
||||
if python3 -m piper --help >/dev/null 2>&1; then
|
||||
ok "piper (python3 -m piper)"
|
||||
else
|
||||
note_missing
|
||||
warn "piper fehlt. Hinweis: Homebrew-Python verweigert pip oft (externally-managed)."
|
||||
if [ "$CHECK" -eq 0 ] && ask "piper-tts"; then
|
||||
pip3 install piper-tts 2>/dev/null \
|
||||
|| pip3 install --user piper-tts 2>/dev/null \
|
||||
|| pip3 install --break-system-packages piper-tts
|
||||
python3 -m piper --help >/dev/null 2>&1 && ok "piper installiert" \
|
||||
|| fail "piper weiterhin nicht nutzbar — manuell: pipx install piper-tts oder venv"
|
||||
fi
|
||||
fi
|
||||
if [ -f "$PIPER_MODEL" ]; then
|
||||
ok "Piper-Stimme: $(basename "$PIPER_MODEL")"
|
||||
else
|
||||
note_missing
|
||||
warn "Piper-Stimme fehlt: $PIPER_MODEL (~110 MB Download)."
|
||||
if [ "$CHECK" -eq 0 ] && ask "Piper-Stimme herunterladen"; then
|
||||
mkdir -p "$(dirname "$PIPER_MODEL")"
|
||||
curl -L -o "$PIPER_MODEL" \
|
||||
"https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/$(basename "$PIPER_MODEL")" \
|
||||
&& ok "Stimme geladen" || fail "Download fehlgeschlagen"
|
||||
fi
|
||||
fi
|
||||
|
||||
sect "8/9 Ollama"
|
||||
OLLAMA_SVC=0
|
||||
if have ollama; then
|
||||
ok "ollama $(ollama --version 2>/dev/null | awk '{print $NF}')"
|
||||
else
|
||||
note_missing
|
||||
warn "Ollama fehlt (führt das Sprachmodell lokal aus)."
|
||||
if [ "$CHECK" -eq 0 ] && ask "Ollama (brew install ollama)"; then
|
||||
brew install ollama && ok "Ollama installiert" || fail "Ollama-Installation fehlgeschlagen"
|
||||
fi
|
||||
fi
|
||||
if curl -s --max-time 2 "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then
|
||||
ok "Ollama-Server erreichbar ($OLLAMA_URL)"
|
||||
OLLAMA_SVC=1
|
||||
else
|
||||
note_missing
|
||||
warn "Ollama-Server läuft nicht auf $OLLAMA_URL."
|
||||
if [ "$CHECK" -eq 0 ] && have ollama && ask "Ollama-Server starten (ollama serve im Hintergrund)"; then
|
||||
nohup ollama serve >/dev/null 2>&1 & disown
|
||||
sleep 3
|
||||
if curl -s --max-time 2 "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then
|
||||
ok "Ollama-Server läuft"
|
||||
OLLAMA_SVC=1
|
||||
else
|
||||
fail "Server nicht erreichbar — App „Ollama“ öffnen oder 'ollama serve' manuell starten"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
sect "9/9 Sprachmodell ($MODEL)"
|
||||
MODEL_OK=0
|
||||
if [ "$OLLAMA_SVC" -eq 1 ]; then
|
||||
if ollama list 2>/dev/null | awk '{print $1}' | grep -qx "$MODEL"; then
|
||||
ok "$MODEL ist geladen"
|
||||
MODEL_OK=1
|
||||
else
|
||||
note_missing
|
||||
warn "$MODEL fehlt (Download je nach Modell mehrere GB)."
|
||||
if [ "$CHECK" -eq 0 ] && have ollama && ask "ollama pull $MODEL"; then
|
||||
ollama pull "$MODEL" && ok "$MODEL geladen" || fail "Modell-Download fehlgeschlagen"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
warn "Ohne laufenden Ollama-Server keine Modellprüfung möglich."
|
||||
fi
|
||||
|
||||
sect "Ergebnis"
|
||||
if [ "$MISSING" -eq 0 ]; then
|
||||
printf "${GREEN}Alles bereit!${OFF} Starten mit:\n\n npm run dev\n → http://localhost:5174\n"
|
||||
else
|
||||
printf "${YELLOW}%s Punkt(e) offen.${OFF} Skript erneut laufen lassen (fehlende Teile werden nachinstalliert), dann:\n\n npm run dev\n → http://localhost:5174\n" "$MISSING"
|
||||
fi
|
||||
printf "\nOptional (nur für Cloud-Fallback): OPENROUTER_API_KEY in die .env eintragen.\n"
|
||||
[ "$CHECK" -eq 1 ] && [ "$MISSING" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
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;
|
||||
}
|
||||
usable.push(f);
|
||||
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 (!usable.length) return;
|
||||
|
||||
try {
|
||||
const added = await Promise.all(usable.map(readAsAttachment));
|
||||
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