mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-18 01:20:29 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc20ea40d |
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
# Disable Discord community banner (optional)
|
# Disable Discord community banner (optional)
|
||||||
# Uncomment the line below to hide the Discord banner
|
# Uncomment the line below to hide the Discord banner
|
||||||
# NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
|
NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
|
.idea/
|
||||||
|
bun.lock
|
||||||
|
.env
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
.env.local
|
.env.local
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ You can also build and run Sucker as a Docker container with `docker compose bui
|
|||||||
|
|
||||||
### Changelog
|
### Changelog
|
||||||
|
|
||||||
- 2.4: CSS editor for Janitor profiles. New banner links to the live-preview editor at tools.from-avalon.com/janitor-profile-maker.
|
|
||||||
- 2.3: ECHO. Put ECHO: anywhere in a message and sucker repeats whatever comes after it in the reply — for triggering char-side script and lorebook keywords.
|
|
||||||
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
|
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
|
||||||
- 2.1: updated deps, note about image fetching, list of mirrors
|
- 2.1: updated deps, note about image fetching, list of mirrors
|
||||||
- 2.0: from Tui: Multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions. Shows version badges, message counts, and provides detailed change history viewer.
|
- 2.0: from Tui: Multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions. Shows version badges, message counts, and provides detailed change history viewer.
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
image: sucker
|
image: sucker
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
Binary file not shown.
@@ -93,15 +93,6 @@ function parseConversationToken(content: string | undefined | null): string | nu
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEchoText(content: string | undefined | null): string | null {
|
|
||||||
if (!content) return null;
|
|
||||||
const match = content.match(/ECHO:\s*([\s\S]+)/i);
|
|
||||||
if (match && match[1].trim()) {
|
|
||||||
return match[1].trim();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePersonaTags(content: string): string {
|
function removePersonaTags(content: string): string {
|
||||||
let result = content;
|
let result = content;
|
||||||
const openingMatch = result.match(/<[^<>]+?\s*'s\s+Persona>/i);
|
const openingMatch = result.match(/<[^<>]+?\s*'s\s+Persona>/i);
|
||||||
@@ -496,18 +487,6 @@ export async function POST(request: NextRequest) {
|
|||||||
responseMessage = `New character "${extracted.data.name}" created (v1).${tokenNote}`;
|
responseMessage = `New character "${extracted.data.name}" created (v1).${tokenNote}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Echo back anything the user sent after an "ECHO:" marker in their
|
|
||||||
// latest message, appended after the normal status message
|
|
||||||
const lastUserMessage =
|
|
||||||
[...body.messages]
|
|
||||||
.reverse()
|
|
||||||
.find((m: { role?: string }) => m?.role === "user") ??
|
|
||||||
body.messages[body.messages.length - 1];
|
|
||||||
const echoText = parseEchoText(lastUserMessage?.content);
|
|
||||||
if (echoText) {
|
|
||||||
responseMessage += `\n\n${echoText}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupExpiredCards();
|
cleanupExpiredCards();
|
||||||
|
|
||||||
// Return SSE response if requested, otherwise JSON
|
// Return SSE response if requested, otherwise JSON
|
||||||
@@ -549,7 +528,7 @@ export async function POST(request: NextRequest) {
|
|||||||
console.error("Error processing request:", error);
|
console.error("Error processing request:", error);
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
"You dingus, read the directions on sucker before trying again. If you're trying to suck a bot with more tokens than your current context limit, try bumping your Context Size setting up on Generation Settings.";
|
"You dingus, read the directions on sucker before trying again.";
|
||||||
|
|
||||||
// Check if this was a streaming request
|
// Check if this was a streaming request
|
||||||
const acceptHeader = request.headers.get("accept");
|
const acceptHeader = request.headers.get("accept");
|
||||||
|
|||||||
+864
-971
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, ReactNode } from "react";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
interface BannerCarouselProps {
|
|
||||||
slides: ReactNode[];
|
|
||||||
intervalMs?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BannerCarousel({
|
|
||||||
slides,
|
|
||||||
intervalMs = 8000,
|
|
||||||
className = "",
|
|
||||||
}: BannerCarouselProps) {
|
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
|
|
||||||
// Pick a random starting slide on the client to avoid hydration mismatch
|
|
||||||
useEffect(() => {
|
|
||||||
setIndex(Math.floor(Math.random() * slides.length));
|
|
||||||
}, [slides.length]);
|
|
||||||
|
|
||||||
// Re-keyed on index so a manual change restarts the countdown
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setTimeout(
|
|
||||||
() => setIndex((i) => (i + 1) % slides.length),
|
|
||||||
intervalMs
|
|
||||||
);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [index, intervalMs, slides.length]);
|
|
||||||
|
|
||||||
const go = (delta: number) =>
|
|
||||||
setIndex((i) => (i + delta + slides.length) % slides.length);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative ${className}`}>
|
|
||||||
{/* Slides stacked in one grid cell so the container fits the tallest */}
|
|
||||||
<div className="grid">
|
|
||||||
{slides.map((slide, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`[grid-area:1/1] transition-opacity duration-500 ${
|
|
||||||
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
||||||
}`}
|
|
||||||
aria-hidden={i !== index}
|
|
||||||
>
|
|
||||||
{slide}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => go(-1)}
|
|
||||||
aria-label="Previous banner"
|
|
||||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 rounded-full bg-black/50 hover:bg-black/70 text-white p-1 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => go(1)}
|
|
||||||
aria-label="Next banner"
|
|
||||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full bg-black/50 hover:bg-black/70 text-white p-1 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5">
|
|
||||||
{slides.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIndex(i)}
|
|
||||||
aria-label={`Show banner ${i + 1}`}
|
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
|
||||||
i === index ? "w-4 bg-white" : "w-1.5 bg-white/50"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface CssEditorBannerProps {
|
|
||||||
href?: string;
|
|
||||||
className?: string;
|
|
||||||
/** "contain" letterboxes the video so its caption is never cropped */
|
|
||||||
fit?: "cover" | "contain";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CssEditorBanner({
|
|
||||||
href = "https://tools.from-avalon.com/janitor-profile-maker/",
|
|
||||||
className = "",
|
|
||||||
fit = "cover",
|
|
||||||
}: CssEditorBannerProps) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
// Browsers like Opera GX overlay pop-out controls on large <video> elements.
|
|
||||||
// The real video stays 1px and invisible; its frames are painted onto a canvas.
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = canvas?.getContext("2d");
|
|
||||||
if (!video || !canvas || !ctx) return;
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = Math.round(canvas.clientWidth * dpr);
|
|
||||||
canvas.height = Math.round(canvas.clientHeight * dpr);
|
|
||||||
};
|
|
||||||
resize();
|
|
||||||
const observer = new ResizeObserver(resize);
|
|
||||||
observer.observe(canvas);
|
|
||||||
|
|
||||||
let frame = 0;
|
|
||||||
const draw = () => {
|
|
||||||
const vw = video.videoWidth;
|
|
||||||
const vh = video.videoHeight;
|
|
||||||
const cw = canvas.width;
|
|
||||||
const ch = canvas.height;
|
|
||||||
if (vw && vh && cw && ch) {
|
|
||||||
const scale =
|
|
||||||
fit === "contain"
|
|
||||||
? Math.min(cw / vw, ch / vh)
|
|
||||||
: Math.max(cw / vw, ch / vh);
|
|
||||||
const w = vw * scale;
|
|
||||||
const h = vh * scale;
|
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
|
||||||
ctx.drawImage(video, (cw - w) / 2, (ch - h) / 2, w, h);
|
|
||||||
}
|
|
||||||
frame = requestAnimationFrame(draw);
|
|
||||||
};
|
|
||||||
frame = requestAnimationFrame(draw);
|
|
||||||
|
|
||||||
// Browsers may pause muted videos they consider hidden; keep it looping.
|
|
||||||
const resume = () => {
|
|
||||||
if (video.paused && document.visibilityState === "visible") {
|
|
||||||
video.play().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
resume();
|
|
||||||
video.addEventListener("pause", resume);
|
|
||||||
document.addEventListener("visibilitychange", resume);
|
|
||||||
const keepAlive = setInterval(resume, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
observer.disconnect();
|
|
||||||
clearInterval(keepAlive);
|
|
||||||
video.removeEventListener("pause", resume);
|
|
||||||
document.removeEventListener("visibilitychange", resume);
|
|
||||||
};
|
|
||||||
}, [fit]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Open the Janitor profile CSS editor"
|
|
||||||
className={`block relative rounded-lg overflow-hidden shadow-lg bg-black ${className}`}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src="/avalon/css-banner.mp4"
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
disablePictureInPicture
|
|
||||||
preload="auto"
|
|
||||||
aria-hidden
|
|
||||||
className="absolute top-0 left-0 w-px h-px opacity-[0.01] pointer-events-none"
|
|
||||||
/>
|
|
||||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,18 +7,16 @@ interface DiscordBannerPermanentProps {
|
|||||||
serverName?: string;
|
serverName?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
ctaText?: string;
|
ctaText?: string;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiscordBannerPermanent({
|
export function DiscordBannerPermanent({
|
||||||
inviteLink,
|
inviteLink,
|
||||||
serverName = "Avalon",
|
serverName = "Avalon",
|
||||||
description = "Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:",
|
description = "Looking for somewhere more... interesting? Avalon's an enchanted collective of botmakers who refuse to be boring 🌿 Come play with us~ (18+ only, darling)",
|
||||||
ctaText = "Join Server",
|
ctaText = "Join Server",
|
||||||
className = "mb-4",
|
|
||||||
}: DiscordBannerPermanentProps) {
|
}: DiscordBannerPermanentProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`relative rounded-lg overflow-hidden shadow-lg bg-[#36393f] ${className}`}>
|
<div className="relative rounded-lg overflow-hidden shadow-lg mb-4 bg-[#36393f]">
|
||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<Image
|
<Image
|
||||||
@@ -71,7 +69,7 @@ export function DiscordBannerPermanent({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-white font-semibold text-base mb-0.5">{serverName}</div>
|
<div className="text-white font-semibold text-base mb-0.5">{serverName}</div>
|
||||||
<div className="text-[#b9bbbe] text-sm space-y-0.5">
|
<div className="text-[#b9bbbe] text-sm space-y-0.5">
|
||||||
<div className="font-medium">885 members</div>
|
<div className="font-medium">67 members</div>
|
||||||
<a
|
<a
|
||||||
href="https://from-avalon.com"
|
href="https://from-avalon.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
Reference in New Issue
Block a user