Merge remote-tracking branch 'origin/main' into feature/prompt-wildcards

This commit is contained in:
Briant Diehl
2026-05-15 11:52:49 -06:00
27 changed files with 787 additions and 194 deletions
+1
View File
@@ -235,6 +235,7 @@ Feature-specific documentation lives in `docs/features/`. Before implementing a
| Notifications | [docs/features/notifications.md](docs/features/notifications.md) |
| Metrics/Analytics | [docs/features/metrics-analytics.md](docs/features/metrics-analytics.md) |
| Bitwise Flags | [docs/features/bitwise-flags.md](docs/features/bitwise-flags.md) |
| Civitai LLM Client | [docs/features/civitai-llm-client.md](docs/features/civitai-llm-client.md) |
## Troubleshooting
+216
View File
@@ -0,0 +1,216 @@
# Civitai LLM Client
OpenAI-compatible chat completions client targeting Civitai's Orchestrator (`POST /v1/chat/completions`). Lets the application call Civitai-hosted LLMs (Qwen3 and future additions) with the same call shape used for OpenRouter, while keeping non-Civitai-hosted models (`openai/*`, `anthropic/*`, etc.) on OpenRouter via a model-prefix dispatcher.
## What It Provides
- **OpenAI-compatible chat completions** against the Orchestrator endpoint.
- **`getJsonCompletion<T>`** — schema-typed JSON output with built-in parse fallbacks.
- **Prefix-based routing** — `urn:air:*` models route to this client; everything else stays on OpenRouter. No call site has to choose a backend manually.
- **Drop-in API** — same `SimpleMessage` shape, same `temperature` / `maxTokens` / `retries` semantics, same return type as `openrouter.getJsonCompletion`.
- **Defensive normalization** for the Orchestrator's stricter validator and Qwen's default thinking behavior.
- **Per-call `debug` flag** for opt-in request/response logging.
## Architecture
```
┌──────────────────────────────────┐
│ generative-content.ts call site │
│ model = input.model │
│ ?? DEFAULT_*_MODEL │
└──────────────┬───────────────────┘
┌─────────────────────┐
│ pickClient(model) │
│ urn:air:* → civitai│
│ else → openR │
└────────┬────────────┘
┌──────────┴───────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ civitaiLLM │ │ openrouter │
│ src/server/ │ │ src/server/ │
│ services/ai/ │ │ services/ai/ │
│ civitai-llm.ts │ │ openrouter.ts │
└─────────┬──────────┘ └─────────┬──────────┘
│ │
▼ ▼
Orchestrator OpenRouter
/v1/chat/completions /api/v1/chat/completions
```
Dispatcher rule (`src/server/games/daily-challenge/generative-content.ts`):
```ts
function pickClient(model: string) {
if (model.startsWith('urn:air:')) {
if (!civitaiLLM) throw new Error('Civitai LLM not connected');
return civitaiLLM;
}
if (!openrouter) throw new Error('OpenRouter not connected');
return openrouter;
}
```
URN-prefixed models hit the Civitai LLM client. All other model strings (`openai/*`, `anthropic/*`, `moonshotai/*`, `stepfun/*`, etc.) stay on OpenRouter.
## Files
| File | Purpose |
|------|---------|
| `src/server/services/ai/civitai-llm.ts` | Thin OpenAI-compatible client + defenses |
| `src/server/services/ai/openrouter.ts` | OpenRouter SDK wrapper; hosts `AI_MODELS` constants (including Civitai-hosted URNs) |
| `src/server/games/daily-challenge/generative-content.ts` | Dispatcher, per-function model defaults, five call sites |
| `src/components/Challenge/Playground/ModelSelector.tsx` | Mod-only model picker |
| `src/components/Challenge/Playground/playground.store.ts` | Zustand store; versioned `migrate` keeps persisted defaults current |
## Public API
```ts
import { civitaiLLM } from '~/server/services/ai/civitai-llm';
const result = await civitaiLLM!.getJsonCompletion<MyShape>({
model: 'urn:air:qwen3:repository:huggingface:Civitai/Qwen3.6-35B-A3B-Abliterated-AWQ@main.tar',
messages: [
{ role: 'system', content: 'You are a JSON-producing assistant.' },
{ role: 'user', content: 'Return {"hello":"world"}.' },
],
temperature: 1,
maxTokens: 8192, // default
retries: 3,
debug: false, // opt-in per-call info logging
suppressThinking: false, // opt-in JSON-only directive for thinking models
});
```
`SimpleMessage` is re-exported from `civitai-llm.ts` so callers don't need to import from both modules.
In practice, the daily-challenge call sites go through a file-local `pickClient(model)` helper in `generative-content.ts` rather than importing `civitaiLLM` directly, so the URN-prefix routing rule stays in one place:
```ts
const result = await pickClient(model).getJsonCompletion<MyShape>({ model, messages });
```
If a second consumer needs the same routing, promote `pickClient` to a shared module (e.g. `src/server/services/ai/dispatch.ts`) and import it from both sites.
## Built-in Defenses
The Orchestrator's chat endpoint and the Qwen3 family have a few sharp edges. The client handles them so call sites stay clean.
### 1. Content-array flattening (`normalizeMessage`)
OpenAI and OpenRouter accept `content` as either a `string` or an array of `{ type: 'text' | 'image_url', ... }` parts. The Orchestrator's validator rejects arrays for text-only messages (`The JSON value could not be converted to System.String`). The client flattens text-only arrays to a joined string before sending. Messages containing an `image_url` part pass through unchanged so vision support can be exercised when it lands end-to-end.
### 2. Thinking-mode suppression (opt-in)
Some models (e.g. Qwen3 thinking variants) emit chain-of-thought reasoning by default. With creative prompts they can consume the entire `max_tokens` budget on preamble and return `finish_reason: length` before producing JSON.
Callers can opt in via `suppressThinking: true`, which appends a "JSON only" directive to the last user message:
```
IMPORTANT: Respond with ONLY the raw JSON object. Do NOT include any
analysis, planning, thinking steps, markdown fences, or preamble before
or after the JSON. Begin your response with `{` and end with `}`.
```
Off by default — the client stays model-agnostic. The soft `/no_think` token and `chat_template_kwargs: { enable_thinking: false }` were also tried — `/no_think` was ignored by the current proxy build, and `chat_template_kwargs` triggered a 500. The instruction-based approach is what survived.
### 3. JSON extraction fallbacks (`extractJsonSlice`)
When parsing fails, the client tries three candidates in order:
1. Raw content (`JSON.parse(content)`).
2. Fenced block: ` ```json … ``` `.
3. **Slice**: substring from the first `{` to the last `}`.
This catches cases where the model wraps JSON in prose despite the instruction.
### 4. Trailing-slash normalization
`env.ORCHESTRATOR_ENDPOINT` may be set with a trailing slash. The constructor strips trailing slashes before composing `${endpoint}/v1/chat/completions`.
### 5. Retries
Same retry semantics as `openrouter.getJsonCompletion`: on "no content" or "all JSON candidates failed", recurse with `retries - 1` until exhausted. `debug` is forwarded through recursive calls.
## Daily-Challenge Defaults
The daily-challenge pipeline runs on OpenRouter today. The civitai-llm client is wired up via the dispatcher so any call site can opt into a Civitai-hosted model (e.g. for testing in the Playground) without touching the existing OpenRouter path.
`src/server/games/daily-challenge/generative-content.ts`:
```ts
const DEFAULT_CONTENT_MODEL: AIModel = AI_MODELS.GPT_4O_MINI;
const DEFAULT_REVIEW_MODEL: AIModel = AI_MODELS.GPT_5_NANO;
```
| Function | Default | Reason |
|----------|---------|--------|
| `generateCollectionDetails` | `GPT_4O_MINI` | Short text generation |
| `generateArticle` | `GPT_4O_MINI` | Persona / creative writing |
| `generateThemeElements` | `GPT_4O_MINI` | Keyword extraction |
| `generateReview` | `GPT_5_NANO` | Stricter image scoring |
| `generateWinners` | `GPT_4O_MINI` | Narrative + ranking |
Caller-supplied `input.model` overrides the default at every site. The mod-only Playground (`ModelSelector.tsx`) is the primary way to override interactively.
## Routing to a Civitai-hosted Model
To make a call site use a Civitai-hosted model:
1. Confirm the model is registered in `AI_MODELS` (`src/server/services/ai/openrouter.ts`). URN-shaped values route to this client automatically.
2. Pass the URN as `input.model` (or set it as the call-site default).
3. If the model emits chain-of-thought by default, pass `suppressThinking: true`.
4. Optionally surface the model in `ModelSelector.tsx` for mod testing.
No dispatcher change is needed — URN-prefix routing already directs all `urn:air:*` models to the Civitai LLM client.
If the Orchestrator later accepts `chat_template_kwargs` or `response_format`, those flags can be added directly on the request body to avoid the prompt-based workaround:
```ts
const body = {
model, messages: finalMessages, temperature, max_tokens: maxTokens, stream: false,
response_format: { type: 'json_object' },
chat_template_kwargs: { enable_thinking: false },
};
```
Both fields previously triggered a 500 on the current proxy build. Re-test before re-introducing.
## Env Vars
| Var | Used For |
|-----|----------|
| `ORCHESTRATOR_ENDPOINT` | Base URL. Trailing slashes are stripped. |
| `ORCHESTRATOR_ACCESS_TOKEN` | Bearer token. |
If either is missing, `civitaiLLM` exports as `undefined`. The dispatcher then throws `'Civitai LLM not connected'` if a URN model is requested, surfacing a clear setup error instead of an opaque network failure.
## Debugging
```ts
await civitaiLLM!.getJsonCompletion({ model, messages, debug: true });
```
When `debug: true`, the client emits two log lines per attempt:
```
[civitai-llm] REQUEST { model, maxTokens, retries, messageCount }
[civitai-llm] RESPONSE { finishReason, contentLength }
```
Errors are always logged (HTTP non-2xx and final JSON parse failure), regardless of `debug`.
For richer per-message dumps (full prompt content, image URLs, content tails), check the file's git history — earlier revisions had extensive logging that can be cherry-picked back when investigating a regression.
## Known Limits
- **Streaming**: not implemented. The endpoint supports SSE (`stream: true`); add a `streamChatCompletion` method when a consumer needs it.
- **Tool calls**: not implemented. Add `runAgentLoop` parity with `openrouter.ts` if/when the Orchestrator supports OpenAI-format tool calls through Qwen.
- **Vision**: image-bearing content arrays are forwarded unchanged, but end-to-end vision handling has not been validated against the current Orchestrator build. Validate by hand before routing image-bearing flows to `urn:air:*` models.
## See Also
- [Daily Challenge System](./daily-challenge.md) — primary consumer of these clients.
- `src/server/services/ai/openrouter.ts``AI_MODELS` lives here; reuse it from both clients.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "model-share",
"version": "5.0.1722",
"version": "5.0.1724",
"private": true,
"packageManager": "pnpm@10.28.1",
"scripts": {
+7 -1
View File
@@ -705,7 +705,13 @@ export const AuctionInfo = () => {
// label="Buzz"
// labelProps={{ sx: { fontSize: 12, fontWeight: 590 } }}
placeholder="Enter Buzz..."
leftSection={<CurrencyIcon currency={Currency.BUZZ} size={18} />}
leftSection={
<CurrencyIcon
currency={Currency.BUZZ}
type={features.isGreen ? 'green' : 'yellow'}
size={18}
/>
}
value={bidPrice}
min={1}
max={buzzConstants.maxChargeAmount}
@@ -352,6 +352,7 @@ const SectionBidInfo = ({
slugHref?: AuctionBaseData;
}) => {
const mobile = useIsMobile({ breakpoint: 'md' });
const features = useFeatureFlags();
return (
<Stack
@@ -375,6 +376,7 @@ const SectionBidInfo = ({
<Tooltip label={currencyTooltip} disabled={!currencyTooltip}>
<CurrencyBadge
currency={Currency.BUZZ}
type={features.isGreen ? 'green' : 'yellow'}
unitAmount={amount}
displayCurrency={false}
radius="sm"
@@ -1,48 +1,38 @@
import { Select, TextInput, Stack } from '@mantine/core';
import { Select } from '@mantine/core';
import { usePlaygroundStore } from './playground.store';
// Mod-only playground — list every model wired into AI_MODELS for testing.
// Vision-capable models work for all flows; text-only models will fail on
// generateArticle / generateReview (they send image_url).
const MODEL_OPTIONS = [
{ value: 'x-ai/grok-4.1-fast', label: 'Grok (x-ai/grok-4.1-fast)' },
{ value: 'moonshotai/kimi-k2.5', label: 'Kimi (moonshotai/kimi-k2.5)' },
{ value: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet (anthropic/claude-sonnet-4)' },
{ value: 'openai/gpt-4o-mini', label: 'GPT-4o Mini (openai/gpt-4o-mini)' },
{ value: 'openai/gpt-5-nano', label: 'GPT-5 Nano (openai/gpt-5-nano)' },
{ value: 'openai/gpt-4o', label: 'GPT-4o (openai/gpt-4o)' },
{ value: '__other__', label: 'Other...' },
{ value: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (anthropic/claude-sonnet-4)' },
{ value: 'anthropic/claude-3-5-haiku', label: 'Claude 3.5 Haiku (anthropic/claude-3-5-haiku)' },
{ value: 'moonshotai/kimi-k2.5', label: 'Kimi K2.5 — text only (moonshotai/kimi-k2.5)' },
{
value: 'stepfun/step-3.5-flash',
label: 'StepFun 3.5 Flash — text only (stepfun/step-3.5-flash)',
},
{
value: 'urn:air:qwen3:repository:huggingface:Civitai/Qwen3.6-35B-A3B-Abliterated-AWQ@main.tar',
label: 'Qwen 35B (Civitai orchestrator)',
},
];
export function ModelSelector() {
const aiModel = usePlaygroundStore((s) => s.aiModel);
const customModelId = usePlaygroundStore((s) => s.customModelId);
const setAiModel = usePlaygroundStore((s) => s.setAiModel);
const setCustomModelId = usePlaygroundStore((s) => s.setCustomModelId);
const isOther = !MODEL_OPTIONS.some((o) => o.value === aiModel && o.value !== '__other__');
const selectValue = isOther ? '__other__' : aiModel;
return (
<Stack gap="xs">
<Select
label="AI Model"
data={MODEL_OPTIONS}
value={selectValue}
onChange={(val) => {
if (val === '__other__') {
setAiModel(customModelId || '');
} else if (val) {
setAiModel(val);
}
}}
/>
{isOther && (
<TextInput
placeholder="e.g. google/gemini-2.5-pro"
value={customModelId}
onChange={(e) => {
const val = e.currentTarget.value;
setCustomModelId(val);
setAiModel(val);
}}
/>
)}
</Stack>
<Select
label="AI Model"
data={MODEL_OPTIONS}
value={aiModel}
onChange={(val) => {
if (val) setAiModel(val);
}}
/>
);
}
@@ -33,7 +33,6 @@ type PlaygroundState = {
selectedJudgeId: number | null;
activityTab: ActivityTab;
aiModel: string;
customModelId: string;
drafts: Record<number, JudgeDraft>;
generateContentInputs: GenerateContentInputs;
reviewImageInputs: ReviewImageInputs;
@@ -44,7 +43,6 @@ type PlaygroundActions = {
setSelectedJudgeId: (id: number | null) => void;
setActivityTab: (tab: ActivityTab) => void;
setAiModel: (model: string) => void;
setCustomModelId: (id: string) => void;
updateDraft: (judgeId: number, updates: Partial<JudgeDraft>) => void;
clearDraft: (judgeId: number) => void;
updateGenerateContentInputs: (updates: Partial<GenerateContentInputs>) => void;
@@ -57,8 +55,7 @@ export const usePlaygroundStore = create<PlaygroundState & PlaygroundActions>()(
immer((set) => ({
selectedJudgeId: null,
activityTab: 'generateContent' as ActivityTab,
aiModel: 'x-ai/grok-4.1-fast',
customModelId: '',
aiModel: 'openai/gpt-4o-mini',
drafts: {},
generateContentInputs: { modelVersionIds: [] },
reviewImageInputs: { imageInput: '', theme: '', themeElements: '', creator: '' },
@@ -79,11 +76,6 @@ export const usePlaygroundStore = create<PlaygroundState & PlaygroundActions>()(
state.aiModel = model;
}),
setCustomModelId: (id) =>
set((state) => {
state.customModelId = id;
}),
updateDraft: (judgeId, updates) =>
set((state) => {
state.drafts[judgeId] = { ...state.drafts[judgeId], ...updates };
@@ -111,11 +103,27 @@ export const usePlaygroundStore = create<PlaygroundState & PlaygroundActions>()(
})),
{
name: 'judge-playground',
version: 3,
migrate: (persistedState, version) => {
// Migration history:
// v0 -> v1: x-ai/grok-4.1-fast deprecated 2026-05-15
// v1 -> v2: Qwen orchestrator endpoint not ready; fall back to gpt-5-nano
// v2 -> v3: gpt-5-nano returned empty content on generateArticle; use gpt-4o-mini
const state = persistedState as Partial<PlaygroundState> | undefined;
const stale = [
'x-ai/grok-4.1-fast',
'urn:air:qwen3:repository:huggingface:Civitai/Qwen3.6-35B-A3B-Abliterated-AWQ@main.tar',
'openai/gpt-5-nano',
];
if ((version ?? 0) < 3 && state?.aiModel && stale.includes(state.aiModel)) {
state.aiModel = 'openai/gpt-4o-mini';
}
return state;
},
partialize: (state) => ({
selectedJudgeId: state.selectedJudgeId,
activityTab: state.activityTab,
aiModel: state.aiModel,
customModelId: state.customModelId,
drafts: state.drafts,
generateContentInputs: state.generateContentInputs,
reviewImageInputs: state.reviewImageInputs,
+14 -1
View File
@@ -109,7 +109,20 @@ export function CommentContent({
useEffect(() => {
if (!isHighlighted) return;
const elem = document.getElementById(`comment-${comment.id}`);
if (elem) elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' });
if (!elem) return;
const center = () => elem.scrollIntoView({ behavior: 'auto', block: 'center' });
const recenterIfDrifted = () => {
const rect = elem.getBoundingClientRect();
if (rect.top < 0 || rect.bottom > window.innerHeight) center();
};
// Initial scroll, then retry as layout settles (images, late mounts, paginated batches)
center();
const t1 = window.setTimeout(recenterIfDrifted, 100);
const t2 = window.setTimeout(recenterIfDrifted, 500);
return () => {
window.clearTimeout(t1);
window.clearTimeout(t2);
};
}, [isHighlighted, comment.id]);
const isExpanded = !viewOnly && expanded.includes(comment.id);
+26 -3
View File
@@ -179,6 +179,11 @@ export function CommentsProvider({
entityType,
});
// Notification deep-links pass ?highlight=<commentId>. Forward it to the server so the
// target comment is included in the first page even when it would otherwise be past the
// cursor — otherwise the highlight scroll never fires.
const highlighted = parseNumericString(router.query.highlight);
// Use infinite query with cursor-based pagination for comments
const { data, isLoading, isRefetching, fetchNextPage, hasNextPage, isFetchingNextPage } =
trpc.commentv2.getInfinite.useInfiniteQuery(
@@ -188,6 +193,7 @@ export function CommentsProvider({
limit: initialLimit,
sort,
hidden: hidden ?? false,
targetCommentId: highlighted,
},
{
enabled: initialCount === undefined || initialCount > 0,
@@ -195,13 +201,30 @@ export function CommentsProvider({
}
);
// Flatten all pages into single comments array
const comments = useMemo(() => data?.pages.flatMap((page) => page?.comments ?? []) ?? [], [data]);
// Flatten pages, prepending the deep-link target (when present) and deduping by id so a
// later cursor page that naturally contains the target doesn't render it twice.
const comments = useMemo(() => {
if (!data) return [] as CommentV2Model[];
const seen = new Set<number>();
const result: CommentV2Model[] = [];
const target = data.pages[0]?.targetComment;
if (target) {
seen.add(target.id);
result.push(target);
}
for (const page of data.pages) {
for (const c of page?.comments ?? []) {
if (seen.has(c.id)) continue;
seen.add(c.id);
result.push(c);
}
}
return result;
}, [data]);
// Get thread metadata from dedicated query (includes locked status and hiddenCount)
const threadMeta = threadDetails;
const hiddenCount = threadMeta?.hiddenCount ?? 0;
const highlighted = parseNumericString(router.query.highlight);
const createdComments = useMemo(
() => created.filter((x) => !comments?.some((comment) => comment.id === x.id)),
@@ -14,6 +14,7 @@
import { Anchor, Badge, Group, HoverCard, Text, ThemeIcon } from '@mantine/core';
import { IconAlertTriangle, IconBan, IconLock, IconShield } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { CurrencyBadge } from '~/components/Currency/CurrencyBadge';
import { EdgeMedia2 } from '~/components/EdgeMedia/EdgeMedia';
import { NumberSlider } from '~/libs/form/components/NumberSlider';
import { useAppContext } from '~/providers/AppProvider';
@@ -215,16 +216,35 @@ export function ResourceItemContent({
)}
{/* Version name + warnings — second line */}
<Group gap={4} wrap="nowrap">
{resource.name && resource.model.name && resource.model.name.toLowerCase() !== resource.name.toLowerCase() && (
<Text size="xs" c="dimmed" className="shrink-0">
({resource.name})
</Text>
)}
{resource.name &&
resource.model.name &&
resource.model.name.toLowerCase() !== resource.name.toLowerCase() && (
<Text size="xs" c="dimmed" className="shrink-0">
({resource.name})
</Text>
)}
{epochDetails?.epochNumber && (
<Badge size="sm" color="dark.5" variant="filled" className="shrink-0">
Epoch {epochDetails.epochNumber}
</Badge>
)}
{!!resource.licensingFee && resource.licensingFee > 0 && (
<HoverCard position="bottom" withArrow width={220}>
<HoverCard.Target>
<CurrencyBadge
unitAmount={resource.licensingFee}
currency="BUZZ"
size="xs"
className="shrink-0 cursor-help"
/>
</HoverCard.Target>
<HoverCard.Dropdown>
<Text size="sm">
License fee charged per image when using this resource for generation.
</Text>
</HoverCard.Dropdown>
</HoverCard>
)}
{isSfwOnly && (
<HoverCard position="bottom" withArrow width={200}>
<HoverCard.Target>
@@ -83,14 +83,30 @@ export default AuthedEndpoint(
return res.status(404).json({ error: 'Epoch download URL not available' });
}
// Abort the upstream fetch + stream when the client disconnects.
// Without this, a client hang or Traefik timeout leaves the pod streaming
// into a dead socket for minutes, holding an event-loop slot.
const abortController = new AbortController();
const onClientClose = () => abortController.abort();
req.on('close', onClientClose);
// Fetch from orchestrator using server-side token (bypasses CORS)
const orchestratorResponse = await fetch(epochUrl, {
headers: {
Authorization: `Bearer ${env.ORCHESTRATOR_ACCESS_TOKEN}`,
},
});
let orchestratorResponse: Response;
try {
orchestratorResponse = await fetch(epochUrl, {
headers: {
Authorization: `Bearer ${env.ORCHESTRATOR_ACCESS_TOKEN}`,
},
signal: abortController.signal,
});
} catch (err) {
req.off('close', onClientClose);
if (abortController.signal.aborted) return res.end();
throw err;
}
if (!orchestratorResponse.ok) {
req.off('close', onClientClose);
return res
.status(orchestratorResponse.status)
.json({ error: 'Failed to fetch epoch from storage' });
@@ -110,17 +126,31 @@ export default AuthedEndpoint(
const body = orchestratorResponse.body;
if (!body) {
req.off('close', onClientClose);
return res.status(500).json({ error: 'No response body from storage' });
}
// Convert Web ReadableStream to Node.js Readable and pipe to response
// Convert Web ReadableStream to Node.js Readable and pipe to response.
// Destroy the stream on client disconnect so we stop reading from the orchestrator.
const nodeStream = Readable.fromWeb(body as NodeReadableStream);
await new Promise<void>((resolve, reject) => {
nodeStream.pipe(res);
nodeStream.on('error', reject);
res.on('finish', resolve);
res.on('error', reject);
});
const onClientCloseStream = () => nodeStream.destroy();
req.on('close', onClientCloseStream);
try {
await new Promise<void>((resolve, reject) => {
nodeStream.pipe(res);
nodeStream.on('error', (err) => {
// Aborted by client disconnect — not an error condition for us.
if (abortController.signal.aborted) return resolve();
reject(err);
});
res.on('finish', resolve);
res.on('error', reject);
});
} finally {
req.off('close', onClientClose);
req.off('close', onClientCloseStream);
}
},
['GET']
);
+3
View File
@@ -27,6 +27,7 @@ import { Meta } from '~/components/Meta/Meta';
import { ScrollArea } from '~/components/ScrollArea/ScrollArea';
import { useTourContext } from '~/components/Tours/ToursProvider';
import { useIsMobile } from '~/hooks/useIsMobile';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { createServerSideProps } from '~/server/utils/server-side-helpers';
import { getLoginLink } from '~/utils/login-helpers';
import { trpc } from '~/utils/trpc';
@@ -104,6 +105,7 @@ export default function Auctions({
const pathname = usePathname();
const { runTour, running } = useTourContext();
const isMobile = useIsMobile({ breakpoint: 'md' });
const features = useFeatureFlags();
useAuctionTopicListener(selectedAuction?.id);
const {
@@ -186,6 +188,7 @@ export default function Auctions({
<Tooltip label="Min bid currently required to place">
<CurrencyBadge
currency="BUZZ"
type={features.isGreen ? 'green' : 'yellow'}
unitAmount={a.lowestBidRequired}
displayCurrency={false}
radius="md"
@@ -94,7 +94,10 @@ function ScannerAuditTablePage() {
return (
<>
<Meta title="Scanner Audit" deIndex />
<ScannerAuditLayout activeMode={mode} rightAction={<ExportButton view={view} mode={mode} filters={filters} />}>
<ScannerAuditLayout
activeMode={mode}
rightAction={<ExportButton view={view} mode={mode} filters={filters} />}
>
<Group align="end">
<TextInput
label="Label"
@@ -161,83 +164,79 @@ function ScannerAuditTablePage() {
<LoadingOverlay visible={isFetching} zIndex={5} overlayProps={{ blur: 1 }} />
<Stack gap="sm">
<Table striped withTableBorder highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Threshold</Table.Th>
<Table.Th>Occurrences</Table.Th>
<Table.Th>Policy</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Last seen</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.rows.map((r) => (
<Table.Tr key={`${r.contentHash}::${r.version}::${r.label}`}>
<Table.Td>
<Link
href={`/moderator/scanner-audit/${mode}/${encodeURIComponent(r.label)}`}
style={{ color: 'inherit', textDecoration: 'none' }}
>
<code style={{ textDecoration: 'underline', cursor: 'pointer' }}>
{r.label}
</code>
</Link>
{r.labelValue && (
<Text size="xs" c="dimmed" component="span" ml={4}>
= {r.labelValue}
</Text>
)}
</Table.Td>
<Table.Td>{r.score.toFixed(3)}</Table.Td>
<Table.Td>{r.threshold !== null ? r.threshold.toFixed(2) : '—'}</Table.Td>
<Table.Td>
<Text size="sm">{r.occurrences.toLocaleString()}</Text>
</Table.Td>
<Table.Td>
<Tooltip label={r.version || '(none)'}>
<Text size="xs" c="dimmed" ff="monospace">
{r.version ? `${r.version.slice(0, 10)}` : '—'}
</Text>
</Tooltip>
</Table.Td>
<Table.Td>
<Group gap={4}>
{r.myVerdict && (
<Badge size="xs" color={verdictColor(r.myVerdict)}>
{verdictShort(r.myVerdict)}
</Badge>
)}
{!r.myVerdict && r.anyVerdict && (
<Tooltip label="Verdict from another moderator">
<Badge
size="xs"
color={verdictColor(r.anyVerdict)}
variant="outline"
>
{verdictShort(r.anyVerdict)}
</Badge>
</Tooltip>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{new Date(r.lastSeenAt).toLocaleString()}
</Text>
</Table.Td>
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Threshold</Table.Th>
<Table.Th>Occurrences</Table.Th>
<Table.Th>Policy</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Last seen</Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{data?.rows.map((r) => (
<Table.Tr key={`${r.contentHash}::${r.version}::${r.label}`}>
<Table.Td>
<Link
href={`/moderator/scanner-audit/${mode}/${encodeURIComponent(r.label)}`}
style={{ color: 'inherit', textDecoration: 'none' }}
>
<code style={{ textDecoration: 'underline', cursor: 'pointer' }}>
{r.label}
</code>
</Link>
{r.labelValue && (
<Text size="xs" c="dimmed" component="span" ml={4}>
= {r.labelValue}
</Text>
)}
</Table.Td>
<Table.Td>{r.score.toFixed(3)}</Table.Td>
<Table.Td>{r.threshold !== null ? r.threshold.toFixed(2) : '—'}</Table.Td>
<Table.Td>
<Text size="sm">{r.occurrences.toLocaleString()}</Text>
</Table.Td>
<Table.Td>
<Tooltip label={r.version || '(none)'}>
<Text size="xs" c="dimmed" ff="monospace">
{r.version ? `${r.version.slice(0, 10)}` : '—'}
</Text>
</Tooltip>
</Table.Td>
<Table.Td>
<Group gap={4}>
{r.myVerdict && (
<Badge size="xs" color={verdictColor(r.myVerdict)}>
{verdictShort(r.myVerdict)}
</Badge>
)}
{!r.myVerdict && r.anyVerdict && (
<Tooltip label="Verdict from another moderator">
<Badge size="xs" color={verdictColor(r.anyVerdict)} variant="outline">
{verdictShort(r.anyVerdict)}
</Badge>
</Tooltip>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{new Date(r.lastSeenAt).toLocaleString()}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{data ? `${data.total.toLocaleString()} matching decisions` : '—'}
</Text>
<Pagination value={page} onChange={setPage} total={totalPages} size="sm" />
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{data ? `${data.total.toLocaleString()} matching decisions` : '—'}
</Text>
<Pagination value={page} onChange={setPage} total={totalPages} size="sm" />
</Group>
</Stack>
</Box>
)}
@@ -4,9 +4,10 @@ import type {
Prize,
Score,
} from '~/server/games/daily-challenge/daily-challenge.utils';
import { logToAxiom } from '~/server/logging/client';
import { civitaiLLM } from '~/server/services/ai/civitai-llm';
import { openrouter, AI_MODELS, type AIModel } from '~/server/services/ai/openrouter';
import type { SimpleMessage } from '~/server/services/ai/openrouter';
import { logToAxiom } from '~/server/logging/client';
import type { ReviewReactions } from '~/shared/utils/prisma/enums';
import { findLastIndex } from '~/utils/array-helpers';
import { markdownToHtml } from '~/utils/markdown-helpers';
@@ -17,6 +18,31 @@ import {
type ReviewTemplateVariables,
} from './template-engine';
// Default models for the daily-challenge pipeline. Routed through OpenRouter.
//
// Split rationale:
// - Content + winner selection use warm, varied creative output → GPT-4o Mini.
// - Image review needs critical scoring that doesn't inflate; GPT-5 Nano
// runs stricter in practice → GPT-5 Nano.
//
// To experiment with a Civitai-hosted model (e.g. Qwen via the orchestrator),
// pass the URN as `input.model` from the call site or the Playground; the
// `pickClient` dispatcher routes `urn:air:*` to the civitai-llm client.
const DEFAULT_CONTENT_MODEL: AIModel = AI_MODELS.GPT_4O_MINI;
const DEFAULT_REVIEW_MODEL: AIModel = AI_MODELS.GPT_5_NANO;
// URN-prefixed models go through the orchestrator's OpenAI-compatible endpoint
// (Civitai-hosted Qwen, etc.). Everything else (openai/*, anthropic/*, x-ai/*,
// moonshotai/*, stepfun/*) stays on OpenRouter.
function pickClient(model: string) {
if (model.startsWith('urn:air:')) {
if (!civitaiLLM) throw new Error('Civitai LLM not connected');
return civitaiLLM;
}
if (!openrouter) throw new Error('OpenRouter not connected');
return openrouter;
}
type GenerateCollectionDetailsInput = {
resource: {
modelId: number;
@@ -35,11 +61,10 @@ type CollectionDetails = {
description: string;
};
export async function generateCollectionDetails(input: GenerateCollectionDetailsInput) {
if (!openrouter) throw new Error('OpenRouter not connected');
const results = await openrouter.getJsonCompletion<CollectionDetails>({
const model = input.model ?? DEFAULT_CONTENT_MODEL;
const results = await pickClient(model).getJsonCompletion<CollectionDetails>({
retries: 3,
model: input.model ?? AI_MODELS.GROK,
model,
messages: [
prepareSystemMessage(
input.config,
@@ -96,13 +121,12 @@ type GeneratedArticle = {
themeElements: string[];
};
export async function generateArticle({ resource, image, config, model }: GenerateArticleInput) {
if (!openrouter) throw new Error('OpenRouter not connected');
const userText = `Resource title: ${resource.title}\nResource link: https://civitai.com/models/${resource.modelId}\nCreator: ${resource.creator}\nCreator link: https://civitai.com/user/${resource.creator}`;
const result = await openrouter.getJsonCompletion<GeneratedArticle>({
const selectedModel = model ?? DEFAULT_CONTENT_MODEL;
const result = await pickClient(selectedModel).getJsonCompletion<GeneratedArticle>({
retries: 3,
model: model ?? AI_MODELS.GROK,
model: selectedModel,
messages: [
prepareSystemMessage(
config,
@@ -153,12 +177,11 @@ type GenerateThemeElementsInput = {
model?: AIModel;
};
export async function generateThemeElements(input: GenerateThemeElementsInput): Promise<string[]> {
if (!openrouter) throw new Error('OpenRouter not connected');
try {
const result = await openrouter.getJsonCompletion<{ themeElements: string[] }>({
const model = input.model ?? DEFAULT_CONTENT_MODEL;
const result = await pickClient(model).getJsonCompletion<{ themeElements: string[] }>({
retries: 3,
model: input.model ?? AI_MODELS.GROK,
model,
messages: [
{
role: 'system',
@@ -218,8 +241,6 @@ const RESPONSE_SCHEMA = `{
}`;
export async function generateReview(input: GenerateReviewInput): Promise<GeneratedReview> {
if (!openrouter) throw new Error('OpenRouter not connected');
let messages: SimpleMessage[];
if (input.config.reviewTemplate) {
try {
@@ -232,9 +253,10 @@ export async function generateReview(input: GenerateReviewInput): Promise<Genera
messages = buildFallbackMessages(input);
}
const result = await openrouter.getJsonCompletion<GeneratedReview>({
const model = input.model ?? DEFAULT_REVIEW_MODEL;
const result = await pickClient(model).getJsonCompletion<GeneratedReview>({
retries: 3,
model: input.model ?? AI_MODELS.GROK,
model,
messages,
});
@@ -343,17 +365,16 @@ type GeneratedWinners = {
outcome: string;
};
export async function generateWinners(input: GenerateWinnersInput) {
if (!openrouter) throw new Error('OpenRouter not connected');
const userText = `Theme: ${input.theme}\nEntries:\n\`\`\`json \n${JSON.stringify(
input.entries,
null,
2
)}\n\`\`\``;
const result = await openrouter.getJsonCompletion<GeneratedWinners>({
const model = input.model ?? DEFAULT_CONTENT_MODEL;
const result = await pickClient(model).getJsonCompletion<GeneratedWinners>({
retries: 3,
model: input.model ?? AI_MODELS.GROK,
model,
messages: [
prepareSystemMessage(
input.config,
@@ -53,9 +53,15 @@ class FreshdeskCaller extends HttpCaller {
}
async closeAsSpam(ticketId: number) {
return this.put<FreshdeskTicket>(`/tickets/${ticketId}`, {
payload: { status: 5, spam: true },
// Freshdesk API v2 has no `spam` field on the ticket update endpoint and no dedicated
// "mark as spam" route. Emulate the UI action: tag "spam", close, then trash.
const ticket = await this.getTicket(ticketId);
const existingTags = ticket.ok && ticket.data ? ticket.data.tags ?? [] : [];
const tags = Array.from(new Set([...existingTags, 'spam']));
await this.put<FreshdeskTicket>(`/tickets/${ticketId}`, {
payload: { status: 5, tags },
});
return this.delete(`/tickets/${ticketId}`);
}
async searchTickets(query: string) {
@@ -142,7 +148,6 @@ export type FreshdeskTicketUpdate = {
group_id?: number;
responder_id?: number;
custom_fields?: Record<string, unknown>;
spam?: boolean;
};
export type FreshdeskConversation = {
+13 -10
View File
@@ -188,9 +188,7 @@ async function bulkInsertMetrics<T extends readonly string[]>(
}
if (offenders.length > 0) {
log(
`⚠️ out-of-range ${options.logName} (${offenders.length}) batch ${i + 1}/${
tasks.length
}:`,
`⚠️ out-of-range ${options.logName} (${offenders.length}) batch ${i + 1}/${tasks.length}:`,
JSON.stringify(offenders)
);
}
@@ -309,15 +307,20 @@ async function getDownloadTasks(ctx: ModelMetricContext) {
const injectedVersionIds = allInjectableResourceIds;
async function getGenerationTasks(ctx: ModelMetricContext) {
// Guard against corrupt rows in daily_resource_generation_counts: future
// dates, ids <= 0, and counts that overflow PG INT4 (2_147_483_647).
// Pull versions touched since lastUpdate from `orchestration.jobs` directly.
// The `daily_resource_generation_counts` MV is bucketed by Date, so filtering
// it by `toDate(lastUpdate)` returned every version generated since 00:00 UTC
// — growing linearly through the day and resetting at midnight UTC. That
// produced a daily ramp of search-index update volume into Meili.
const generated = await ctx.ch.$query<{ modelVersionId: number }>`
SELECT DISTINCT modelVersionId
FROM orchestration.daily_resource_generation_counts
WHERE createdDate >= toDate(${ctx.lastUpdate})
AND createdDate <= today()
AND modelVersionId > 0
AND count <= 2147483647
FROM (
SELECT arrayJoin(resourcesUsed) AS modelVersionId
FROM orchestration.jobs
WHERE createdAt >= ${ctx.lastUpdate}
AND length(resourcesUsed) > 0
)
WHERE modelVersionId > 0
`;
const affected = generated
.map((x) => x.modelVersionId)
@@ -14,13 +14,13 @@ export const threadUrlMap = ({ threadType, threadParentId, ...details }: any) =>
return {
model: `/models/${threadParentId}?dialog=commentThread&${queryString}`,
image: `/images/${threadParentId}?${queryString}`,
post: `/posts/${threadParentId}?${queryString}#comments`,
article: `/articles/${threadParentId}?${queryString}#comments`,
post: `/posts/${threadParentId}?${queryString}`,
article: `/articles/${threadParentId}?${queryString}`,
review: `/reviews/${threadParentId}?${queryString}`,
bounty: `/bounties/${threadParentId}?${queryString}#comments`,
bountyEntry: `/bounties/entries/${threadParentId}?${queryString}#comments`,
challenge: `/challenges/${threadParentId}?${queryString}#comments`,
comicChapter: `/comics/${threadParentId}?${queryString}#comments`,
bounty: `/bounties/${threadParentId}?${queryString}`,
bountyEntry: `/bounties/entries/${threadParentId}?${queryString}`,
challenge: `/challenges/${threadParentId}?${queryString}`,
comicChapter: `/comics/${threadParentId}?${queryString}`,
// question: `/questions/${threadParentId}?highlight=${details.commentId}#comments`,
// answer: `/questions/${threadParentId}?highlight=${details.commentId}#answer-`,
}[threadType as string] as string;
@@ -453,7 +453,7 @@ export const commentNotifications = createNotificationProcessor({
details && !isEmpty(details)
? {
message: `${details.username} commented on your article: "${details.articleTitle}"`,
url: `/articles/${details.articleId}?highlight=${details.commentId}#comments`,
url: `/articles/${details.articleId}?highlight=${details.commentId}`,
}
: undefined,
prepareQuery: ({ lastSent }) => `
@@ -490,7 +490,7 @@ export const commentNotifications = createNotificationProcessor({
category: NotificationCategory.Comment,
prepareMessage: ({ details }) => ({
message: `${details.username} commented on your bounty: "${details.bountyTitle}"`,
url: `/bounties/${details.bountyId}?highlight=${details.commentId}#comments`,
url: `/bounties/${details.bountyId}?highlight=${details.commentId}`,
}),
prepareQuery: ({ lastSent }) => `
WITH new_bounty_comment AS (
@@ -527,7 +527,7 @@ export const commentNotifications = createNotificationProcessor({
category: NotificationCategory.Comment,
prepareMessage: ({ details }) => ({
message: `${details.username} commented on your challenge: "${details.challengeTitle}"`,
url: `/challenges/${details.challengeId}?highlight=${details.commentId}#comments`,
url: `/challenges/${details.challengeId}?highlight=${details.commentId}`,
}),
prepareQuery: ({ lastSent }) => `
WITH new_challenge_comment AS (
+2
View File
@@ -30,6 +30,7 @@ export const resourceDataCache = createCachedArray({
LIMIT 1) AS "vaeId",
mv."status",
mv."usageControl",
mv."licensingFee",
(CASE WHEN mv."availability" = 'EarlyAccess' AND mv."earlyAccessEndsAt" >= NOW() THEN mv."earlyAccessConfig" END) as "earlyAccessConfig",
gc."covered",
FALSE AS "hasAccess",
@@ -82,6 +83,7 @@ export type GenerationResourceDataModel = {
covered: boolean | null;
status: ModelStatus;
usageControl?: string;
licensingFee: number | null;
hasAccess: boolean;
epochNumber?: number;
model: {
+3
View File
@@ -66,4 +66,7 @@ export const getCommentsInfiniteSchema = commentConnectorSchema.extend({
limit: z.number().min(1).max(100).default(20),
sort: z.enum(ThreadSort).default(ThreadSort.Oldest),
cursor: z.number().optional(),
// If set on the first page, the server will include this comment in the response when
// it belongs to the thread but isn't in the initial batch (e.g. notification deep-links).
targetCommentId: z.number().optional(),
});
+216
View File
@@ -0,0 +1,216 @@
import { isProd } from '~/env/other';
import { env } from '~/env/server';
import type { SimpleMessage } from '~/server/services/ai/openrouter';
export type { SimpleMessage } from '~/server/services/ai/openrouter';
declare global {
// eslint-disable-next-line no-var, vars-on-top
var globalCivitaiLLM: CivitaiLLM | undefined;
}
type GetJsonCompletionInput = {
model: string;
messages: SimpleMessage[];
temperature?: number;
maxTokens?: number;
retries?: number;
/** Opt-in info logging for this call. Errors are logged regardless. */
debug?: boolean;
/**
* Append a "JSON only, no preamble" instruction to the last user message.
* Enable for models that emit chain-of-thought by default (e.g. Qwen3
* thinking variants) so they don't burn the token budget on preamble.
* Off by default the client is model-agnostic.
*/
suppressThinking?: boolean;
};
type ChatCompletionResponse = {
choices?: Array<{
message?: { role: string; content?: string | null };
finish_reason?: string;
}>;
};
// Orchestrator's /v1/chat/completions only accepts `string` content (not the
// OpenAI multimodal array form). Flatten text-only arrays to a single string;
// pass arrays that contain images through unchanged so any vision support added
// later still works (and surfaces a clear server error if it doesn't yet).
function normalizeMessage(msg: SimpleMessage): SimpleMessage {
if (typeof msg.content === 'string') return msg;
const hasImage = msg.content.some((c) => c.type === 'image_url');
if (hasImage) return msg;
const text = msg.content
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
.map((c) => c.text)
.join('\n');
return { ...msg, content: text };
}
// Opt-in helper for models that emit chain-of-thought before JSON (e.g. Qwen3
// thinking variants). The instruction-based approach is the only reliable
// switch today — the soft `/no_think` token is ignored by the current
// orchestrator proxy, and `chat_template_kwargs: { enable_thinking: false }`
// trips a 500 there. Callers enable this via `suppressThinking: true` on a
// per-request basis.
const NO_PREAMBLE_INSTRUCTION =
'\n\nIMPORTANT: Respond with ONLY the raw JSON object. Do NOT include any analysis, planning, thinking steps, markdown fences, or preamble before or after the JSON. Begin your response with `{` and end with `}`.';
function appendNoPreamble(messages: SimpleMessage[]): SimpleMessage[] {
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return messages;
return messages.map((m, i) => {
if (i !== lastUserIdx) return m;
if (typeof m.content === 'string')
return { ...m, content: `${m.content}${NO_PREAMBLE_INSTRUCTION}` };
return {
...m,
content: [...m.content, { type: 'text', text: NO_PREAMBLE_INSTRUCTION }],
};
});
}
// Last-ditch JSON extractor: take the substring from the first `{` to the
// last `}`. Handles models that wrap JSON in prose or markdown.
function extractJsonSlice(content: string): string | null {
const first = content.indexOf('{');
const last = content.lastIndexOf('}');
if (first === -1 || last <= first) return null;
return content.slice(first, last + 1);
}
export type CivitaiLLM = {
getJsonCompletion: <T>(params: GetJsonCompletionInput) => Promise<T>;
};
function createCivitaiLLM(endpoint: string, token: string): CivitaiLLM {
const url = `${endpoint.replace(/\/+$/, '')}/v1/chat/completions`;
const getJsonCompletion = async <T>({
model,
messages,
temperature = 1,
maxTokens = 8192,
retries = 0,
debug = false,
suppressThinking = false,
}: GetJsonCompletionInput): Promise<T> => {
const normalized = messages.map(normalizeMessage);
const finalMessages = suppressThinking ? appendNoPreamble(normalized) : normalized;
const body = {
model,
messages: finalMessages,
temperature,
max_tokens: maxTokens,
stream: false,
};
if (debug) {
console.log('[civitai-llm] REQUEST', {
model,
maxTokens,
retries,
messageCount: finalMessages.length,
});
}
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text().catch(() => '');
console.error('[civitai-llm] HTTP', res.status, errText.slice(0, 500));
throw new Error(`Civitai LLM error ${res.status}: ${errText.slice(0, 500)}`);
}
const json = (await res.json()) as ChatCompletionResponse;
const choice = json.choices?.[0];
const content = choice?.message?.content;
if (debug) {
console.log('[civitai-llm] RESPONSE', {
finishReason: choice?.finish_reason,
contentLength: typeof content === 'string' ? content.length : 0,
});
}
if (!content || typeof content !== 'string') {
if (retries > 0) {
return getJsonCompletion<T>({
model,
messages,
temperature,
maxTokens,
retries: retries - 1,
debug,
suppressThinking,
});
}
throw new Error('No content in Civitai LLM response');
}
const candidates: string[] = [content];
const fenced = content.match(/```json\n(.*?)\n```/s)?.[1];
if (fenced) candidates.push(fenced);
const slice = extractJsonSlice(content);
if (slice) candidates.push(slice);
for (const candidate of candidates) {
try {
return JSON.parse(candidate) as T;
} catch {
// try next candidate
}
}
if (retries > 0) {
return getJsonCompletion<T>({
model,
messages,
temperature,
maxTokens,
retries: retries - 1,
debug,
suppressThinking,
});
}
console.error(
'[civitai-llm] JSON parse failed; finishReason=',
choice?.finish_reason,
'\n',
content
);
throw new Error('Failed to parse JSON from Civitai LLM completion');
};
return { getJsonCompletion };
}
export let civitaiLLM: CivitaiLLM | undefined;
const endpoint = env.ORCHESTRATOR_ENDPOINT;
const token = env.ORCHESTRATOR_ACCESS_TOKEN;
if (endpoint && token) {
if (isProd) {
civitaiLLM = createCivitaiLLM(endpoint, token);
} else {
if (!global.globalCivitaiLLM) global.globalCivitaiLLM = createCivitaiLLM(endpoint, token);
civitaiLLM = global.globalCivitaiLLM;
}
} else {
console.warn(
'[civitai-llm] ORCHESTRATOR_ENDPOINT and/or ORCHESTRATOR_ACCESS_TOKEN missing — calls to urn:air:* models will throw "Civitai LLM not connected".'
);
}
+2
View File
@@ -20,9 +20,11 @@ export const AI_MODELS = {
CLAUDE_HAIKU: 'anthropic/claude-3-5-haiku',
KIMI: 'moonshotai/kimi-k2.5',
// DEPRECATED 2026-05-15. Prefer QWEN_35B (via civitai-llm client) for new code.
GROK: 'x-ai/grok-4.1-fast',
GPT_5_NANO: 'openai/gpt-5-nano',
STEP_FUN: 'stepfun/step-3.5-flash',
QWEN_35B: 'urn:air:qwen3:repository:huggingface:Civitai/Qwen3.6-35B-A3B-Abliterated-AWQ@main.tar',
// Fallback chains
VISION_PRIMARY: 'openai/gpt-4o',
+25 -1
View File
@@ -486,6 +486,7 @@ export async function getCommentsInfinite({
sort = ThreadSort.Oldest,
hidden = false,
cursor,
targetCommentId,
excludedUserIds = [],
}: GetCommentsInfiniteInput & { excludedUserIds?: number[] }) {
return withSpan('commentv2:getInfinite', async () => {
@@ -520,13 +521,36 @@ export async function getCommentsInfinite({
hidden,
});
// 4. Determine next cursor and hasMore
// 4. If a target comment was requested (notification deep-link) and it isn't already
// in this first-page batch, fetch it separately so the client can render + scroll
// to it without forcing the user to click "Load More" until they hit it.
let targetComment: CommentV2Model | null = null;
if (!cursor && targetCommentId) {
const alreadyIncluded =
pinnedComments.some((c) => c.id === targetCommentId) ||
regularComments.some((c) => c.id === targetCommentId);
if (!alreadyIncluded) {
const candidate = await dbRead.commentV2.findFirst({
where: {
id: targetCommentId,
threadId: mainThread.id,
hidden,
userId: excludedUserIds.length ? { notIn: excludedUserIds } : undefined,
},
select: commentV2Select,
});
if (candidate) targetComment = candidate as CommentV2Model;
}
}
// 5. Determine next cursor and hasMore
const nextCursor =
regularComments.length === limit ? regularComments[regularComments.length - 1].id : undefined;
return {
comments: !cursor ? [...pinnedComments, ...regularComments] : regularComments,
nextCursor,
targetComment,
};
});
}
@@ -53,7 +53,9 @@ export const createAnimaInput = defineHandler<AnimaCtx, [ImageGenStepTemplate]>(
outputFormat: data.outputFormat,
loras: Object.keys(loras).length > 0 ? loras : undefined,
diffuserModel,
}) as AnimaCreateImageGenInput,
// TODO: remove `as any` once @civitai/client ships updated AnimaCreateImageGenInput
// (current published type still declares engine: 'sdcpp', upstream switched to 'comfy').
}) as any as AnimaCreateImageGenInput,
},
];
});
@@ -193,10 +193,12 @@ async function resolveScanContent(
negativePrompt?: string | null;
};
// Per-label modelReason — used by the focused review UI in place of
// the column that used to live in ClickHouse.
// the column that used to live in ClickHouse. Key by lowercase label to
// match the canonical form stored in ClickHouse (writer lowercases on
// insert), so page lookups by `item.label` resolve.
const labelReasons: Record<string, string> = {};
for (const r of step.output?.results ?? []) {
if (r.modelReason) labelReasons[r.label] = r.modelReason;
if (r.modelReason) labelReasons[r.label.toLowerCase()] = r.modelReason;
}
if (item.scanner === 'xguard_text') {
const inputKeys = Object.keys(input ?? {});
+1 -1
View File
@@ -986,7 +986,7 @@ export const ecosystemSettings: EcosystemSettings[] = [
{
ecosystemId: ECO.Anima,
defaults: {
model: { id: 2836417 },
model: { id: 2945208 },
},
},
{
@@ -28,7 +28,7 @@ import { sdxlAspectRatioBuckets } from '~/shared/constants/generation.constants'
// =============================================================================
/** Anima default model version ID */
const animaVersionId = 2836417;
const animaVersionId = 2945208;
// =============================================================================
// Sampler & Schedule Options
@@ -74,7 +74,7 @@ export const animaGraph = new DataGraph<{ ecosystem: string; workflow: string },
.node('seed', seedNode())
.node('aspectRatio', aspectRatioNode({ options: sdxlAspectRatioBuckets, defaultValue: '1:1' }))
.node('cfgScale', sliderNode({ min: 1, max: 20, defaultValue: 7, step: 0.5 }))
.node('steps', sliderNode({ min: 10, max: 50, defaultValue: 25 }))
.node('steps', sliderNode({ min: 8, max: 50, defaultValue: 25 }))
.node(
'sampler',
samplerNode({ options: animaSamplers, defaultValue: 'euler_a', presets: animaSamplerPresets })
+2
View File
@@ -24,6 +24,8 @@ export type GenerationResourceBase = {
additionalResourceCost?: boolean;
availability?: Availability;
epochNumber?: number;
/** Per-image license fee in Buzz set by the model version owner */
licensingFee?: number | null;
// settings
clipSkip?: number;
minStrength: number;