mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
Merge remote-tracking branch 'origin/main' into feature/prompt-wildcards
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "model-share",
|
||||
"version": "5.0.1722",
|
||||
"version": "5.0.1724",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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".'
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ?? {});
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user