feat(agent): AI model config with admin controls (#48)
* feat(agent): add AI model config with admin controls per-user model selection with admin cost ceiling and toggle. models filter/sort by output token cost. adds usage tracking, provider icons, and settings UI. * docs: add openclaw discord integration notes --------- Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
179
docs/openclaw-principles/DISCORD-INTEGRATION.md
Executable file
@ -0,0 +1,179 @@
|
|||||||
|
Discord Integration
|
||||||
|
===
|
||||||
|
|
||||||
|
how OpenClaw connects to Discord, processes inbound messages, delivers replies, and exposes guild operations to the agent. covers the monitoring pipeline, the outbound delivery system, thread and DM handling, slash commands, and the action gating model. relevant context for Compass because Discord is one of OpenClaw's most complete channel integrations — around 70 files and 10k lines — and its architecture illustrates patterns that any new channel (or any Compass integration with OpenClaw's messaging layer) would need to follow.
|
||||||
|
|
||||||
|
|
||||||
|
why channels are structured this way
|
||||||
|
---
|
||||||
|
|
||||||
|
OpenClaw supports many messaging surfaces — Discord, Telegram, WhatsApp, Slack, Signal, a web UI, and several extension channels. each has wildly different capabilities and constraints: Discord has slash commands, threads, reactions, and embeds; WhatsApp has none of these but supports end-to-end encryption; Telegram sits somewhere in between. the challenge is giving the agent access to channel-specific features while keeping the core agent loop channel-agnostic.
|
||||||
|
|
||||||
|
the solution is a three-layer architecture. the **monitor layer** receives events from Discord and normalizes them into a common inbound format. the **outbound layer** takes the agent's response and formats it for Discord's specific constraints (character limits, chunking, embeds). the **channel plugin layer** provides shared abstractions (send text, send media, send poll) that the agent's tool system calls without knowing which channel it's talking to.
|
||||||
|
|
||||||
|
this separation matters because it means the agent doesn't need Discord-specific knowledge. it calls `message(action="send", to="user:123", message="hello")` and the routing layer figures out that this is a Discord DM, resolves the right bot account, and calls the Discord API. the agent's system prompt mentions available channels but doesn't contain Discord API details.
|
||||||
|
|
||||||
|
|
||||||
|
the monitoring pipeline
|
||||||
|
---
|
||||||
|
|
||||||
|
the entry point is `monitorDiscordProvider()` in `src/discord/monitor/provider.ts`. initialization follows a specific sequence because order matters — you can't register event listeners before the client is ready, and you can't deploy slash commands before you've validated the application ID.
|
||||||
|
|
||||||
|
```
|
||||||
|
monitorDiscordProvider()
|
||||||
|
→ resolveDiscordAccount() — load per-account config
|
||||||
|
→ fetchDiscordApplicationId() — validate bot setup
|
||||||
|
→ new Client() — create Carbon discord.js wrapper
|
||||||
|
→ registerDiscordListener() — attach event handlers
|
||||||
|
→ deployDiscordCommands() — register native slash commands
|
||||||
|
```
|
||||||
|
|
||||||
|
the client is created with gateway intents that control which events Discord sends. the base set is always: Guilds, GuildMessages, MessageContent, DirectMessages, GuildMessageReactions, DirectMessageReactions. two additional intents — GuildPresences and GuildMembers — require explicit opt-in because Discord gates these behind a privileged intent approval for bots in more than 100 guilds. this is a Discord-imposed constraint, not an OpenClaw choice.
|
||||||
|
|
||||||
|
|
||||||
|
inbound message flow
|
||||||
|
---
|
||||||
|
|
||||||
|
a Discord message passes through four stages before reaching the agent.
|
||||||
|
|
||||||
|
**stage 1: event listener.** `DiscordMessageListener` in `src/discord/monitor/listeners.ts` wraps Carbon's `MessageCreateListener`. when a message arrives, it gets queued with slow-listener detection — if the handler takes too long, the system flags it rather than blocking other events.
|
||||||
|
|
||||||
|
**stage 2: debouncing.** the message handler in `src/discord/monitor/message-handler.ts` batches rapid-fire messages from the same user in the same channel. the debounce key is `discord:{accountId}:{channelId}:{authorId}`. this exists because Discord users commonly split a thought across multiple quick messages. without debouncing, the agent would receive three separate prompts and try to respond to each one independently, producing three partial replies instead of one coherent response. when multiple messages arrive in the debounce window, their text gets concatenated and the last message's metadata (attachments, reference info) is preserved.
|
||||||
|
|
||||||
|
**stage 3: preflight.** `message-handler.preflight.ts` validates whether the message should be processed at all. this is where most messages get dropped — bot messages (to prevent loops), messages from ungated guilds, messages in disallowed channels, DMs when DM policy is disabled. the preflight also resolves PluralKit proxy identity (if enabled), checks mention requirements, and extracts thread context. the output is either a `DiscordMessagePreflightContext` or null.
|
||||||
|
|
||||||
|
the PluralKit integration deserves a mention: PluralKit is a Discord bot used by plural systems to proxy messages under different identities. OpenClaw can optionally resolve the actual proxied author, which means the agent sees the correct identity rather than the PluralKit bot. this is configured per-account and requires the PluralKit system ID.
|
||||||
|
|
||||||
|
**stage 4: dispatch.** `message-handler.process.ts` sends the validated message to the agent via `dispatchInboundMessage()`. it starts a typing indicator (so the user sees "OpenClaw is typing..."), manages ack reactions (a configurable emoji the bot adds to acknowledge receipt, removed after the reply arrives), and routes the agent's response back through the outbound pipeline.
|
||||||
|
|
||||||
|
|
||||||
|
outbound delivery
|
||||||
|
---
|
||||||
|
|
||||||
|
the outbound pipeline in `src/discord/send.outbound.ts` and `reply-delivery.ts` handles the translation from "agent wants to say X" to "Discord receives properly formatted messages."
|
||||||
|
|
||||||
|
discord imposes a 2000-character limit per message. the pipeline chunks responses by both character count and line count (default 17 lines per message). the line limit exists because a single 2000-character message is a wall of text on mobile — breaking it into shorter messages improves readability even when the character limit isn't reached.
|
||||||
|
|
||||||
|
the first chunk gets a reply-to reference (so it threads under the user's message in Discord's UI). subsequent chunks are sent as follow-up messages without the reference. media attachments go with the first message when possible; additional media gets separate messages.
|
||||||
|
|
||||||
|
markdown formatting gets adjusted for Discord's flavor. tables, which Discord doesn't render natively, get converted to a format that displays reasonably in a monospace code block. this is a lossy transformation but better than sending raw pipe-delimited tables that render as garbled text.
|
||||||
|
|
||||||
|
|
||||||
|
slash commands
|
||||||
|
---
|
||||||
|
|
||||||
|
native slash commands are built in `src/discord/monitor/native-command.ts`. OpenClaw maps its internal command registry to Discord's `ApplicationCommand` format, which means commands registered in OpenClaw automatically appear as slash commands in the Discord UI with proper argument types, descriptions, and autocomplete.
|
||||||
|
|
||||||
|
the implementation handles a subtle Discord constraint: interactions expire after 3 seconds. if the agent takes longer than that to respond (which it almost always does), the interaction becomes invalid. the system wraps all interaction replies in `safeDiscordInteractionCall()`, which catches Discord error 10062 (Unknown interaction) and degrades gracefully — typically by sending a follow-up message to the channel instead of replying to the interaction.
|
||||||
|
|
||||||
|
for commands with many options, the system renders button menus instead of requiring the user to type a value. each button encodes its context in the custom ID string: `cmdarg:command=X;arg=Y;value=Z;user=U`. this is stateless — no server-side session is needed to handle the button click, because all the necessary context is embedded in the button itself. the tradeoff is that custom IDs have a 100-character limit, which constrains how much context can be embedded.
|
||||||
|
|
||||||
|
|
||||||
|
threads and DMs
|
||||||
|
---
|
||||||
|
|
||||||
|
**threads** are detected by channel type (PublicThread, PrivateThread, AnnouncementThread). OpenClaw supports two thread modes: responding in existing threads (always), and auto-creating threads for new conversations (`channelConfig.autoThread=true`). auto-thread creation takes the first message's text as the thread name (sanitized, with a fallback to "Thread {messageId}").
|
||||||
|
|
||||||
|
thread starters are cached because Discord's API doesn't include the original message when delivering events in a thread. the cache maps message ID to text/author/timestamp, which lets the agent see the context that started the conversation.
|
||||||
|
|
||||||
|
threads inherit their parent channel's permissions and bindings, but the session key includes the thread ID — so each thread gets its own conversation state. this means the agent maintains separate context per thread, which is the correct behavior for a guild where multiple threads discuss different topics simultaneously.
|
||||||
|
|
||||||
|
**DMs** are governed by a three-tier policy:
|
||||||
|
|
||||||
|
- `"open"`: accept all DMs. simplest setup, appropriate for personal bots.
|
||||||
|
- `"pairing"`: require an authentication code before accepting. the bot sends a code, the user provides it through another channel, and DMs are unlocked. this prevents random users from consuming agent resources.
|
||||||
|
- `"disabled"`: reject all DMs.
|
||||||
|
|
||||||
|
group DMs are opt-in separately (`dm.groupEnabled`) because they have different trust characteristics — a group DM includes multiple users, so the agent's responses are visible to people who may not have been explicitly authorized.
|
||||||
|
|
||||||
|
|
||||||
|
multi-account support
|
||||||
|
---
|
||||||
|
|
||||||
|
OpenClaw can run multiple Discord bots simultaneously via `discord.accounts.{id}`. each account has its own token, configuration, event listeners, and gateway connection. a gateway registry (`src/discord/monitor/gateway-registry.ts`) maps account IDs to gateway plugins so agent tools resolve the correct bot when performing operations.
|
||||||
|
|
||||||
|
the default account ID is `"default"`, used when no specific account is referenced. this means single-bot deployments don't need to think about accounts at all — the configuration is backward-compatible.
|
||||||
|
|
||||||
|
multi-account is useful for deployments where different guilds need different bot identities (different names, avatars, permission sets) while sharing the same underlying agent.
|
||||||
|
|
||||||
|
|
||||||
|
routing
|
||||||
|
---
|
||||||
|
|
||||||
|
when a Discord message passes preflight, it gets routed to an agent via `resolveAgentRoute()`. the routing key includes:
|
||||||
|
|
||||||
|
- `channel: "discord"`
|
||||||
|
- `accountId`
|
||||||
|
- `peer: { kind: "dm"|"group"|"channel", id }` — who sent it
|
||||||
|
- `parentPeer` — for threads, the parent channel
|
||||||
|
- `guildId` — guild context
|
||||||
|
|
||||||
|
routing returns an agent ID, session key, and binding info. the binding system allows per-guild and per-channel routing — guild A can route to agent "support" while guild B routes to agent "main". channel-level bindings override guild-level, which overrides the account default.
|
||||||
|
|
||||||
|
this cascading resolution is important for multi-guild deployments where different communities need different agent behavior.
|
||||||
|
|
||||||
|
|
||||||
|
allowlists and gating
|
||||||
|
---
|
||||||
|
|
||||||
|
access control happens through allowlists that support multiple ID formats:
|
||||||
|
|
||||||
|
- bare numeric IDs
|
||||||
|
- `user:` prefixed
|
||||||
|
- `discord:` prefixed
|
||||||
|
- `pk:` (PluralKit system IDs)
|
||||||
|
- slug-based lookups (normalized channel/guild names)
|
||||||
|
|
||||||
|
the resolution cascades: per-channel config overrides per-guild, which overrides account defaults. this means you can have a guild-wide allowlist with specific channels that have tighter or looser restrictions.
|
||||||
|
|
||||||
|
|
||||||
|
agent actions
|
||||||
|
---
|
||||||
|
|
||||||
|
the Discord channel exposes over 30 actions to the agent through the tool system. these are defined in `src/agents/tools/discord-actions*.ts` and gated by `DiscordActionConfig`, a per-account configuration that controls which categories of actions are available.
|
||||||
|
|
||||||
|
the categories:
|
||||||
|
|
||||||
|
- **messaging**: send, read, edit, delete, pin messages
|
||||||
|
- **reactions**: add, remove, read reactions
|
||||||
|
- **threads**: create, list, reply in threads
|
||||||
|
- **guild info**: member lookup, role info, channel info
|
||||||
|
- **moderation**: timeout, kick, ban members
|
||||||
|
- **channel management**: create, edit, delete, move channels, manage permissions
|
||||||
|
- **rich content**: polls, embeds, file attachments, emoji and sticker uploads
|
||||||
|
- **presence**: set bot status and activity
|
||||||
|
- **search**: message search with query filters
|
||||||
|
|
||||||
|
the gating is coarse-grained by category. you can disable all moderation actions for an account while keeping messaging enabled. this is a practical choice — fine-grained per-action gating would create a configuration surface too large to manage, while category-level gating covers the real use cases (a community bot that shouldn't moderate, a personal bot that should have full access).
|
||||||
|
|
||||||
|
the action gating is independent of Discord's own permission system. even if an action is enabled in OpenClaw's config, it will fail if the bot's Discord role doesn't have the necessary permissions. Discord error 50013 (Missing Permissions) is caught and logged without crashing the handler.
|
||||||
|
|
||||||
|
|
||||||
|
error handling
|
||||||
|
---
|
||||||
|
|
||||||
|
the integration is designed to survive Discord's various failure modes without taking down the gateway.
|
||||||
|
|
||||||
|
**retry with backoff**: `createDiscordRetryRunner()` wraps API calls with exponential backoff. configured per-account. this handles transient Discord API errors (rate limits, 5xx responses) without manual intervention.
|
||||||
|
|
||||||
|
**interaction expiry**: the 3-second interaction timeout is handled gracefully. rather than crashing when the agent takes longer to respond, the system catches the expiry and falls back to a regular channel message.
|
||||||
|
|
||||||
|
**permission errors**: Discord 50013 (missing permissions) and 50007 (can't DM user) are caught, logged, and don't propagate. the agent receives an error result from the tool call, which lets it explain the failure to the user rather than silently failing.
|
||||||
|
|
||||||
|
**slow-listener detection**: event listeners that take too long get flagged. this prevents a single slow handler from blocking the event queue and causing the bot to appear unresponsive.
|
||||||
|
|
||||||
|
**debounce resilience**: the inbound debouncer preserves the last message's metadata across batches. if something goes wrong mid-batch, the system still has enough context to process at least the final message.
|
||||||
|
|
||||||
|
|
||||||
|
relevance to compass
|
||||||
|
---
|
||||||
|
|
||||||
|
the Discord integration illustrates patterns that would apply to any Compass integration with OpenClaw's messaging layer:
|
||||||
|
|
||||||
|
**the three-layer architecture** (monitor, outbound, channel plugin) is how all channels work. if Compass adds its own messaging surface (in-app notifications, a chat panel, a Slack integration), it would follow the same pattern — a monitor that receives events, an outbound layer that formats responses, and a channel plugin that provides the shared abstractions.
|
||||||
|
|
||||||
|
**the allowlist and routing model** shows how multi-tenant deployments work. if Compass serves multiple organizations, each would need its own routing bindings and access control, similar to how Discord guilds are isolated.
|
||||||
|
|
||||||
|
**the action gating model** demonstrates how to give the agent channel-specific capabilities without hardcoding them. Compass could expose project-specific actions (create task, update schedule, assign member) through the same tool system, gated by user role or project permissions.
|
||||||
|
|
||||||
|
**the debouncing pattern** is worth adopting for any real-time messaging interface. users don't compose complete thoughts in single messages, and an agent that responds to each fragment independently produces a poor experience.
|
||||||
@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
"./src/db/schema-netsuite.ts",
|
"./src/db/schema-netsuite.ts",
|
||||||
"./src/db/schema-plugins.ts",
|
"./src/db/schema-plugins.ts",
|
||||||
"./src/db/schema-agent.ts",
|
"./src/db/schema-agent.ts",
|
||||||
|
"./src/db/schema-ai-config.ts",
|
||||||
],
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dialect: "sqlite",
|
dialect: "sqlite",
|
||||||
|
|||||||
26
drizzle/0013_curved_ricochet.sql
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE `agent_config` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`model_id` text NOT NULL,
|
||||||
|
`model_name` text NOT NULL,
|
||||||
|
`provider` text NOT NULL,
|
||||||
|
`prompt_cost` text NOT NULL,
|
||||||
|
`completion_cost` text NOT NULL,
|
||||||
|
`context_length` integer NOT NULL,
|
||||||
|
`updated_by` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `agent_usage` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`conversation_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`model_id` text NOT NULL,
|
||||||
|
`prompt_tokens` integer DEFAULT 0 NOT NULL,
|
||||||
|
`completion_tokens` integer DEFAULT 0 NOT NULL,
|
||||||
|
`total_tokens` integer DEFAULT 0 NOT NULL,
|
||||||
|
`estimated_cost` text NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`conversation_id`) REFERENCES `agent_conversations`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
11
drizzle/0014_new_giant_girl.sql
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE `user_model_preference` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`model_id` text NOT NULL,
|
||||||
|
`prompt_cost` text NOT NULL,
|
||||||
|
`completion_cost` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `agent_config` ADD `max_cost_per_million` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `agent_config` ADD `allow_user_selection` integer DEFAULT 1 NOT NULL;
|
||||||
3098
drizzle/meta/0013_snapshot.json
Executable file
3172
drizzle/meta/0014_snapshot.json
Executable file
@ -92,6 +92,20 @@
|
|||||||
"when": 1770389906158,
|
"when": 1770389906158,
|
||||||
"tag": "0012_chilly_lake",
|
"tag": "0012_chilly_lake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770427704637,
|
||||||
|
"tag": "0013_curved_ricochet",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770431392946,
|
||||||
|
"tag": "0014_new_giant_girl",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1
public/providers/amazon.svg
Executable file
|
After Width: | Height: | Size: 5.5 KiB |
1
public/providers/anthropic.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg height="2500" viewBox="0 6.603 1192.672 1193.397" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m233.96 800.215 234.684-131.678 3.947-11.436-3.947-6.363h-11.436l-39.221-2.416-134.094-3.624-116.296-4.832-112.67-6.04-28.35-6.04-26.577-35.035 2.738-17.477 23.84-16.027 34.147 2.98 75.463 5.155 113.235 7.812 82.147 4.832 121.692 12.644h19.329l2.738-7.812-6.604-4.832-5.154-4.832-117.182-79.41-126.845-83.92-66.443-48.321-35.92-24.484-18.12-22.953-7.813-50.093 32.618-35.92 43.812 2.98 11.195 2.98 44.375 34.147 94.792 73.37 123.786 91.167 18.12 15.06 7.249-5.154.886-3.624-8.135-13.61-67.329-121.692-71.838-123.785-31.974-51.302-8.456-30.765c-2.98-12.645-5.154-23.275-5.154-36.242l37.127-50.416 20.537-6.604 49.53 6.604 20.86 18.121 30.765 70.39 49.852 110.818 77.315 150.684 22.631 44.698 12.08 41.396 4.51 12.645h7.813v-7.248l6.362-84.886 11.759-104.215 11.436-134.094 3.946-37.772 18.685-45.262 37.127-24.482 28.994 13.852 23.839 34.148-3.303 22.067-14.174 92.134-27.785 144.323-18.121 96.644h10.55l12.08-12.08 48.887-64.913 82.147-102.685 36.242-40.752 42.282-45.02 27.14-21.423h51.303l37.772 56.135-16.913 57.986-52.832 67.007-43.812 56.779-62.82 84.563-39.22 67.651 3.623 5.396 9.343-.886 141.906-30.201 76.671-13.852 91.49-15.705 41.396 19.329 4.51 19.65-16.269 40.189-97.852 24.16-114.764 22.954-170.9 40.43-2.093 1.53 2.416 2.98 76.993 7.248 32.94 1.771h80.617l150.12 11.195 39.222 25.933 23.517 31.732-3.946 24.16-60.403 30.766-81.503-19.33-190.228-45.26-65.235-16.27h-9.02v5.397l54.362 53.154 99.624 89.96 124.752 115.973 6.362 28.671-16.027 22.63-16.912-2.415-109.611-82.47-42.282-37.127-95.758-80.618h-6.363v8.456l22.067 32.296 116.537 175.167 6.04 53.719-8.456 17.476-30.201 10.55-33.181-6.04-68.215-95.758-70.39-107.84-56.778-96.644-6.926 3.947-33.503 360.886-15.705 18.443-36.243 13.852-30.201-22.953-16.027-37.127 16.027-73.37 19.329-95.758 15.704-76.107 14.175-94.55 8.456-31.41-.563-2.094-6.927.886-71.275 97.852-108.402 146.497-85.772 91.812-20.537 8.134-35.597-18.443 3.301-32.94 19.893-29.315 118.712-151.007 71.597-93.583 46.228-54.04-.322-7.813h-2.738l-315.302 204.725-56.135 7.248-24.16-22.63 2.98-37.128 11.435-12.08 94.792-65.236-.322.323z" fill="#d97757"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
1
public/providers/deepseek.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" height="1320" viewBox="3.771 6.973 23.993 17.652" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m27.501 8.469c-.252-.123-.36.111-.508.23-.05.04-.093.09-.135.135-.368.395-.797.652-1.358.621-.821-.045-1.521.213-2.14.842-.132-.776-.57-1.238-1.235-1.535-.349-.155-.701-.309-.944-.645-.171-.238-.217-.504-.303-.765-.054-.159-.108-.32-.29-.348-.197-.031-.274.135-.352.273-.31.567-.43 1.192-.419 1.825.028 1.421.628 2.554 1.82 3.36.136.093.17.186.128.321-.081.278-.178.547-.264.824-.054.178-.135.217-.324.14a5.448 5.448 0 0 1 -1.719-1.169c-.848-.82-1.614-1.726-2.57-2.435-.225-.166-.449-.32-.681-.467-.976-.95.128-1.729.383-1.82.267-.096.093-.428-.77-.424s-1.653.293-2.659.677a2.782 2.782 0 0 1 -.46.135 9.554 9.554 0 0 0 -2.853-.1c-1.866.21-3.356 1.092-4.452 2.6-1.315 1.81-1.625 3.87-1.246 6.018.399 2.261 1.552 4.136 3.326 5.601 1.837 1.518 3.955 2.262 6.37 2.12 1.466-.085 3.1-.282 4.942-1.842.465.23.952.322 1.762.392.623.059 1.223-.031 1.687-.127.728-.154.677-.828.414-.953-2.132-.994-1.665-.59-2.09-.916 1.084-1.285 2.717-2.619 3.356-6.94.05-.343.007-.558 0-.837-.004-.168.034-.235.228-.254a4.084 4.084 0 0 0 1.529-.47c1.382-.757 1.938-1.997 2.07-3.485.02-.227-.004-.463-.243-.582zm-12.041 13.391c-2.067-1.627-3.07-2.162-3.483-2.138-.387.021-.318.465-.233.754.089.285.205.482.368.732.113.166.19.414-.112.598-.666.414-1.823-.139-1.878-.166-1.347-.793-2.473-1.842-3.267-3.276-.765-1.38-1.21-2.861-1.284-4.441-.02-.383.093-.518.472-.586a4.692 4.692 0 0 1 1.514-.04c2.109.31 3.905 1.255 5.41 2.749.86.853 1.51 1.871 2.18 2.865.711 1.057 1.478 2.063 2.454 2.887.343.289.619.51.881.672-.792.088-2.117.107-3.022-.61zm.99-6.38a.304.304 0 1 1 .609 0c0 .17-.136.304-.306.304a.3.3 0 0 1 -.303-.305zm3.077 1.581c-.197.08-.394.15-.584.159a1.246 1.246 0 0 1 -.79-.252c-.27-.227-.463-.354-.546-.752a1.752 1.752 0 0 1 .016-.582c.07-.324-.008-.531-.235-.72-.187-.155-.422-.196-.682-.196a.551.551 0 0 1 -.252-.078c-.108-.055-.197-.19-.112-.356.027-.053.159-.183.19-.207.352-.201.758-.135 1.134.016.349.142.611.404.99.773.388.448.457.573.678.906.174.264.333.534.441.842.066.192-.02.35-.248.448z" fill="#4d6bfe"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
1
public/providers/google.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-380.2 274.7 65.7 65.8"><style>.st0{fill:#e0e0e0}.st1{fill:#fff}.st2{clip-path:url(#SVGID_2_);fill:#fbbc05}.st3{clip-path:url(#SVGID_4_);fill:#ea4335}.st4{clip-path:url(#SVGID_6_);fill:#34a853}.st5{clip-path:url(#SVGID_8_);fill:#4285f4}</style><circle class="st0" cx="-347.3" cy="307.6" r="32.9"/><circle class="st1" cx="-347.3" cy="307.1" r="32.4"/><g><defs><path id="SVGID_1_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" overflow="visible"/></clipPath><path class="st2" d="M-370.8 320.3v-26l17 13z"/><defs><path id="SVGID_3_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_4_"><use xlink:href="#SVGID_3_" overflow="visible"/></clipPath><path class="st3" d="M-370.8 294.3l17 13 7-6.1 24-3.9v-14h-48z"/><g><defs><path id="SVGID_5_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_6_"><use xlink:href="#SVGID_5_" overflow="visible"/></clipPath><path class="st4" d="M-370.8 320.3l30-23 7.9 1 10.1-15v48h-48z"/></g><g><defs><path id="SVGID_7_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_8_"><use xlink:href="#SVGID_7_" overflow="visible"/></clipPath><path class="st5" d="M-322.8 331.3l-31-24-4-3 35-10z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
1
public/providers/meta.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 287.59000000000003 191" xmlns="http://www.w3.org/2000/svg" width="2500" height="1660"><linearGradient id="a" gradientTransform="matrix(1 0 0 -1 0 192)" gradientUnits="userSpaceOnUse" x1="62.34" x2="260.34" y1="101.45" y2="91.45"><stop offset="0" stop-color="#0064e1"/><stop offset=".4" stop-color="#0064e1"/><stop offset=".83" stop-color="#0073ee"/><stop offset="1" stop-color="#0082fb"/></linearGradient><linearGradient id="b" gradientTransform="matrix(1 0 0 -1 0 192)" gradientUnits="userSpaceOnUse" x1="41.42" x2="41.42" y1="53" y2="126"><stop offset="0" stop-color="#0082fb"/><stop offset="1" stop-color="#0064e0"/></linearGradient><path d="M31.06 126c0 11 2.41 19.41 5.56 24.51A19 19 0 0 0 53.19 160c8.1 0 15.51-2 29.79-21.76 11.44-15.83 24.92-38 34-52l15.36-23.6c10.67-16.39 23-34.61 37.18-47C181.07 5.6 193.54 0 206.09 0c21.07 0 41.14 12.21 56.5 35.11 16.81 25.08 25 56.67 25 89.27 0 19.38-3.82 33.62-10.32 44.87C271 180.13 258.72 191 238.13 191v-31c17.63 0 22-16.2 22-34.74 0-26.42-6.16-55.74-19.73-76.69-9.63-14.86-22.11-23.94-35.84-23.94-14.85 0-26.8 11.2-40.23 31.17-7.14 10.61-14.47 23.54-22.7 38.13l-9.06 16c-18.2 32.27-22.81 39.62-31.91 51.75C84.74 183 71.12 191 53.19 191c-21.27 0-34.72-9.21-43-23.09C3.34 156.6 0 141.76 0 124.85z" fill="#0081fb"/><path d="M24.49 37.3C38.73 15.35 59.28 0 82.85 0c13.65 0 27.22 4 41.39 15.61 15.5 12.65 32 33.48 52.63 67.81l7.39 12.32c17.84 29.72 28 45 33.93 52.22 7.64 9.26 13 12 19.94 12 17.63 0 22-16.2 22-34.74l27.4-.86c0 19.38-3.82 33.62-10.32 44.87C271 180.13 258.72 191 238.13 191c-12.8 0-24.14-2.78-36.68-14.61-9.64-9.08-20.91-25.21-29.58-39.71L146.08 93.6c-12.94-21.62-24.81-37.74-31.68-45-7.4-7.89-16.89-17.37-32.05-17.37-12.27 0-22.69 8.61-31.41 21.78z" fill="url(#a)"/><path d="M82.35 31.23c-12.27 0-22.69 8.61-31.41 21.78C38.61 71.62 31.06 99.34 31.06 126c0 11 2.41 19.41 5.56 24.51l-26.48 17.4C3.34 156.6 0 141.76 0 124.85 0 94.1 8.44 62.05 24.49 37.3 38.73 15.35 59.28 0 82.85 0z" fill="url(#b)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
1
public/providers/microsoft.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg enable-background="new 0 0 2499.6 2500" viewBox="0 0 2499.6 2500" xmlns="http://www.w3.org/2000/svg"><path d="m1187.9 1187.9h-1187.9v-1187.9h1187.9z" fill="#f1511b"/><path d="m2499.6 1187.9h-1188v-1187.9h1187.9v1187.9z" fill="#80cc28"/><path d="m1187.9 2500h-1187.9v-1187.9h1187.9z" fill="#00adef"/><path d="m2499.6 2500h-1188v-1187.9h1187.9v1187.9z" fill="#fbbc09"/></svg>
|
||||||
|
After Width: | Height: | Size: 378 B |
1
public/providers/mistral.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><path fill="#fff" d="M0 0h192.756v192.756H0V0z"/><path d="M35.69 117.324v-16.918a4.168 4.168 0 0 1 4.169-4.168c2.302 0 4.642 1.865 4.642 4.168v16.918h9.219l-.003-17.627c0-7.392-6.465-13.384-13.857-13.384a13.337 13.337 0 0 0-8.748 3.253 13.333 13.333 0 0 0-8.748-3.253c-7.392 0-13.857 5.992-13.857 13.384l-.003 17.627h9.219v-16.918c0-2.303 2.339-4.168 4.641-4.168a4.167 4.167 0 0 1 4.169 4.168v16.918h9.157zM62.97 85.961a5.726 5.726 0 1 0 0-11.452 5.726 5.726 0 0 0 0 11.452zM58.005 87.269v30.024h21.396c5.556 0 14.658-5.201 14.658-16.312H84.011v2.719c0 2.838-2.482 5.201-5.91 5.201H67.935V87.269h-9.93z"/><path d="M126.947 117.293H114.39h-.354c-13.477 0-16.787-10.047-16.787-14.658v-6.146H86.494c-3.428 0-5.91 2.365-5.91 5.201v2.719H70.536c0-11.111 9.102-16.312 14.658-16.312H97.25V77.812h9.93v10.284h7.803v8.392h-7.803v5.201c0 2.838.473 7.211 9.812 7.211l.029-8.494c0-7.392 7.176-13.384 14.566-13.384 1.08 0 1.773.128 2.779.369l-.049 9.865a4.146 4.146 0 0 0-2.73-1.018c-2.301 0-4.641 1.865-4.641 4.168v16.887h.001z"/><path d="M165.346 102.281c0-8.817-7.148-15.965-15.965-15.965-8.818 0-15.965 7.148-15.965 15.965 0 8.816 7.146 15.965 15.965 15.965.848 0 1.678-.066 2.49-.193l-.035-10.275c-.75.336-1.58.521-2.455.521-3.324 0-6.018-2.693-6.018-6.018s2.693-6.018 6.018-6.018 6.018 2.693 6.018 6.018v15.012h9.939l.008-15.012zM168.412 77.576h9.93v26.006c0 3.191 2.482 5.082 5.91 5.318v8.629c-5.318.119-15.84-3.545-15.84-13.711V77.576z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
public/providers/nvidia.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 271.7 179.7" xmlns="http://www.w3.org/2000/svg" width="2500" height="1653"><path d="M101.3 53.6V37.4c1.6-.1 3.2-.2 4.8-.2 44.4-1.4 73.5 38.2 73.5 38.2S148.2 119 114.5 119c-4.5 0-8.9-.7-13.1-2.1V67.7c17.3 2.1 20.8 9.7 31.1 27l23.1-19.4s-16.9-22.1-45.3-22.1c-3-.1-6 .1-9 .4m0-53.6v24.2l4.8-.3c61.7-2.1 102 50.6 102 50.6s-46.2 56.2-94.3 56.2c-4.2 0-8.3-.4-12.4-1.1v15c3.4.4 6.9.7 10.3.7 44.8 0 77.2-22.9 108.6-49.9 5.2 4.2 26.5 14.3 30.9 18.7-29.8 25-99.3 45.1-138.7 45.1-3.8 0-7.4-.2-11-.6v21.1h170.2V0H101.3zm0 116.9v12.8c-41.4-7.4-52.9-50.5-52.9-50.5s19.9-22 52.9-25.6v14h-.1c-17.3-2.1-30.9 14.1-30.9 14.1s7.7 27.3 31 35.2M27.8 77.4s24.5-36.2 73.6-40V24.2C47 28.6 0 74.6 0 74.6s26.6 77 101.3 84v-14c-54.8-6.8-73.5-67.2-73.5-67.2z" fill="#76b900"/></svg>
|
||||||
|
After Width: | Height: | Size: 771 B |
1
public/providers/openai.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg height="2500" viewBox="-1 -.1 949.1 959.8" width="2474" xmlns="http://www.w3.org/2000/svg"><path d="m925.8 456.3c10.4 23.2 17 48 19.7 73.3 2.6 25.3 1.3 50.9-4.1 75.8-5.3 24.9-14.5 48.8-27.3 70.8-8.4 14.7-18.3 28.5-29.7 41.2-11.3 12.6-23.9 24-37.6 34-13.8 10-28.5 18.4-44.1 25.3-15.5 6.8-31.7 12-48.3 15.4-7.8 24.2-19.4 47.1-34.4 67.7-14.9 20.6-33 38.7-53.6 53.6-20.6 15-43.4 26.6-67.6 34.4-24.2 7.9-49.5 11.8-75 11.8-16.9.1-33.9-1.7-50.5-5.1-16.5-3.5-32.7-8.8-48.2-15.7s-30.2-15.5-43.9-25.5c-13.6-10-26.2-21.5-37.4-34.2-25 5.4-50.6 6.7-75.9 4.1-25.3-2.7-50.1-9.3-73.4-19.7-23.2-10.3-44.7-24.3-63.6-41.4s-35-37.1-47.7-59.1c-8.5-14.7-15.5-30.2-20.8-46.3s-8.8-32.7-10.6-49.6c-1.8-16.8-1.7-33.8.1-50.7 1.8-16.8 5.5-33.4 10.8-49.5-17-18.9-31-40.4-41.4-63.6-10.3-23.3-17-48-19.6-73.3-2.7-25.3-1.3-50.9 4-75.8s14.5-48.8 27.3-70.8c8.4-14.7 18.3-28.6 29.6-41.2s24-24 37.7-34 28.5-18.5 44-25.3c15.6-6.9 31.8-12 48.4-15.4 7.8-24.3 19.4-47.1 34.3-67.7 15-20.6 33.1-38.7 53.7-53.7 20.6-14.9 43.4-26.5 67.6-34.4 24.2-7.8 49.5-11.8 75-11.7 16.9-.1 33.9 1.6 50.5 5.1s32.8 8.7 48.3 15.6c15.5 7 30.2 15.5 43.9 25.5 13.7 10.1 26.3 21.5 37.5 34.2 24.9-5.3 50.5-6.6 75.8-4s50 9.3 73.3 19.6c23.2 10.4 44.7 24.3 63.6 41.4 18.9 17 35 36.9 47.7 59 8.5 14.6 15.5 30.1 20.8 46.3 5.3 16.1 8.9 32.7 10.6 49.6 1.8 16.9 1.8 33.9-.1 50.8-1.8 16.9-5.5 33.5-10.8 49.6 17.1 18.9 31 40.3 41.4 63.6zm-333.2 426.9c21.8-9 41.6-22.3 58.3-39s30-36.5 39-58.4c9-21.8 13.7-45.2 13.7-68.8v-223q-.1-.3-.2-.7-.1-.3-.3-.6-.2-.3-.5-.5-.3-.3-.6-.4l-80.7-46.6v269.4c0 2.7-.4 5.5-1.1 8.1-.7 2.7-1.7 5.2-3.1 7.6s-3 4.6-5 6.5a32.1 32.1 0 0 1 -6.5 5l-191.1 110.3c-1.6 1-4.3 2.4-5.7 3.2 7.9 6.7 16.5 12.6 25.5 17.8 9.1 5.2 18.5 9.6 28.3 13.2 9.8 3.5 19.9 6.2 30.1 8 10.3 1.8 20.7 2.7 31.1 2.7 23.6 0 47-4.7 68.8-13.8zm-455.1-151.4c11.9 20.5 27.6 38.3 46.3 52.7 18.8 14.4 40.1 24.9 62.9 31s46.6 7.7 70 4.6 45.9-10.7 66.4-22.5l193.2-111.5.5-.5q.2-.2.3-.6.2-.3.3-.6v-94l-233.2 134.9c-2.4 1.4-4.9 2.4-7.5 3.2-2.7.7-5.4 1-8.2 1-2.7 0-5.4-.3-8.1-1-2.6-.8-5.2-1.8-7.6-3.2l-191.1-110.4c-1.7-1-4.2-2.5-5.6-3.4-1.8 10.3-2.7 20.7-2.7 31.1s1 20.8 2.8 31.1c1.8 10.2 4.6 20.3 8.1 30.1 3.6 9.8 8 19.2 13.2 28.2zm-50.2-417c-11.8 20.5-19.4 43.1-22.5 66.5s-1.5 47.1 4.6 70c6.1 22.8 16.6 44.1 31 62.9 14.4 18.7 32.3 34.4 52.7 46.2l193.1 111.6q.3.1.7.2h.7q.4 0 .7-.2.3-.1.6-.3l81-46.8-233.2-134.6c-2.3-1.4-4.5-3.1-6.5-5a32.1 32.1 0 0 1 -5-6.5c-1.3-2.4-2.4-4.9-3.1-7.6-.7-2.6-1.1-5.3-1-8.1v-227.1c-9.8 3.6-19.3 8-28.3 13.2-9 5.3-17.5 11.3-25.5 18-7.9 6.7-15.3 14.1-22 22.1-6.7 7.9-12.6 16.5-17.8 25.5zm663.3 154.4c2.4 1.4 4.6 3 6.6 5 1.9 1.9 3.6 4.1 5 6.5 1.3 2.4 2.4 5 3.1 7.6.6 2.7 1 5.4.9 8.2v227.1c32.1-11.8 60.1-32.5 80.8-59.7 20.8-27.2 33.3-59.7 36.2-93.7s-3.9-68.2-19.7-98.5-39.9-55.5-69.5-72.5l-193.1-111.6q-.3-.1-.7-.2h-.7q-.3.1-.7.2-.3.1-.6.3l-80.6 46.6 233.2 134.7zm80.5-121h-.1v.1zm-.1-.1c5.8-33.6 1.9-68.2-11.3-99.7-13.1-31.5-35-58.6-63-78.2-28-19.5-61-30.7-95.1-32.2-34.2-1.4-68 6.9-97.6 23.9l-193.1 111.5q-.3.2-.5.5l-.4.6q-.1.3-.2.7-.1.3-.1.7v93.2l233.2-134.7c2.4-1.4 5-2.4 7.6-3.2 2.7-.7 5.4-1 8.1-1 2.8 0 5.5.3 8.2 1 2.6.8 5.1 1.8 7.5 3.2l191.1 110.4c1.7 1 4.2 2.4 5.6 3.3zm-505.3-103.2c0-2.7.4-5.4 1.1-8.1.7-2.6 1.7-5.2 3.1-7.6 1.4-2.3 3-4.5 5-6.5 1.9-1.9 4.1-3.6 6.5-4.9l191.1-110.3c1.8-1.1 4.3-2.5 5.7-3.2-26.2-21.9-58.2-35.9-92.1-40.2-33.9-4.4-68.3 1-99.2 15.5-31 14.5-57.2 37.6-75.5 66.4-18.3 28.9-28 62.3-28 96.5v223q.1.4.2.7.1.3.3.6.2.3.5.6.2.2.6.4l80.7 46.6zm43.8 294.7 103.9 60 103.9-60v-119.9l-103.8-60-103.9 60z"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
1
public/providers/perplexity.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="1.5 0 21 24" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z"/></svg>
|
||||||
|
After Width: | Height: | Size: 579 B |
1
public/providers/xai.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="2222" height="2500" viewBox="2.983000000000004 8.629 908.019 1007.381"><path d="M827.76 200.32L745.02 318.5l-.01 348.75L745 1016h166.002l-.251-466.93-.251-466.93-82.74 118.18M3.167 365.816c.183.449 102.641 146.926 227.684 325.505l227.35 324.689 100.486-.255 100.485-.255-227.675-325.25L203.822 365H103.328c-55.272 0-100.345.367-100.161.816M801 8.787l-93.5.286-174 248.569c-95.7 136.713-174.388 249.381-174.863 250.374-.686 1.436 9.177 16.156 48.345 72.144 27.065 38.687 49.728 70.88 50.363 71.54 1.033 1.073 37.65-50.44 128.994-181.471 2.112-3.029 54.285-77.557 115.941-165.618C763.937 216.55 815.619 142.7 817.13 140.5c1.51-2.2 22.768-32.575 47.238-67.5L908.86 9.5l-7.18-.5c-3.949-.275-49.255-.371-100.68-.213M103.273 872.277L3.047 1015.5l100.726.21 100.727.21 45.206-64.71c24.864-35.591 47.462-67.909 50.219-71.819l5.013-7.109-49.972-71.391c-27.484-39.265-50.308-71.491-50.719-71.614-.411-.122-45.849 64.228-100.974 143" fill="#000000FF" stroke="#000000FF"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
747
src/app/actions/ai-config.ts
Executable file
@ -0,0 +1,747 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { eq, desc, and, gte } from "drizzle-orm"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import {
|
||||||
|
agentConfig,
|
||||||
|
agentUsage,
|
||||||
|
userModelPreference,
|
||||||
|
} from "@/db/schema-ai-config"
|
||||||
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
import { can } from "@/lib/permissions"
|
||||||
|
|
||||||
|
// --- types ---
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly provider: string
|
||||||
|
readonly contextLength: number
|
||||||
|
readonly promptCost: string
|
||||||
|
readonly completionCost: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderGroup {
|
||||||
|
readonly provider: string
|
||||||
|
readonly models: ReadonlyArray<ModelInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageMetrics {
|
||||||
|
readonly totalRequests: number
|
||||||
|
readonly totalTokens: number
|
||||||
|
readonly totalCost: string
|
||||||
|
readonly dailyBreakdown: ReadonlyArray<{
|
||||||
|
date: string
|
||||||
|
tokens: number
|
||||||
|
cost: string
|
||||||
|
requests: number
|
||||||
|
}>
|
||||||
|
readonly modelBreakdown: ReadonlyArray<{
|
||||||
|
modelId: string
|
||||||
|
tokens: number
|
||||||
|
cost: string
|
||||||
|
requests: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- module-level cache for model list ---
|
||||||
|
|
||||||
|
let cachedModels: ReadonlyArray<ProviderGroup> | null =
|
||||||
|
null
|
||||||
|
let cacheTimestamp = 0
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
// --- actions ---
|
||||||
|
|
||||||
|
export async function getActiveModel(): Promise<{
|
||||||
|
success: true
|
||||||
|
data: {
|
||||||
|
modelId: string
|
||||||
|
modelName: string
|
||||||
|
provider: string
|
||||||
|
promptCost: string
|
||||||
|
completionCost: string
|
||||||
|
contextLength: number
|
||||||
|
maxCostPerMillion: string | null
|
||||||
|
allowUserSelection: boolean
|
||||||
|
isAdmin: boolean
|
||||||
|
} | null
|
||||||
|
} | {
|
||||||
|
success: false
|
||||||
|
error: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = can(user, "agent", "update")
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const config = await db
|
||||||
|
.select()
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: config
|
||||||
|
? {
|
||||||
|
modelId: config.modelId,
|
||||||
|
modelName: config.modelName,
|
||||||
|
provider: config.provider,
|
||||||
|
promptCost: config.promptCost,
|
||||||
|
completionCost: config.completionCost,
|
||||||
|
contextLength: config.contextLength,
|
||||||
|
maxCostPerMillion:
|
||||||
|
config.maxCostPerMillion ?? null,
|
||||||
|
allowUserSelection:
|
||||||
|
config.allowUserSelection === 1,
|
||||||
|
isAdmin,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to get active model",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveModel(
|
||||||
|
modelId: string,
|
||||||
|
modelName: string,
|
||||||
|
provider: string,
|
||||||
|
promptCost: string,
|
||||||
|
completionCost: string,
|
||||||
|
contextLength: number
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
if (!can(user, "agent", "update")) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Permission denied",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: agentConfig.id })
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(agentConfig)
|
||||||
|
.set({
|
||||||
|
modelId,
|
||||||
|
modelName,
|
||||||
|
provider,
|
||||||
|
promptCost,
|
||||||
|
completionCost,
|
||||||
|
contextLength,
|
||||||
|
updatedBy: user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.insert(agentConfig)
|
||||||
|
.values({
|
||||||
|
id: "global",
|
||||||
|
modelId,
|
||||||
|
modelName,
|
||||||
|
provider,
|
||||||
|
promptCost,
|
||||||
|
completionCost,
|
||||||
|
contextLength,
|
||||||
|
updatedBy: user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/")
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to set model",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModelList(): Promise<{
|
||||||
|
success: true
|
||||||
|
data: ReadonlyArray<ProviderGroup>
|
||||||
|
} | {
|
||||||
|
success: false
|
||||||
|
error: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cachedModels &&
|
||||||
|
Date.now() - cacheTimestamp < CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
return { success: true, data: cachedModels }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const apiKey = (
|
||||||
|
env as unknown as Record<string, string>
|
||||||
|
).OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "OPENROUTER_API_KEY not configured",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
"https://openrouter.ai/api/v1/models",
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (cachedModels) {
|
||||||
|
return { success: true, data: cachedModels }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `OpenRouter API error: ${res.status}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
data: ReadonlyArray<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
context_length: number
|
||||||
|
pricing: {
|
||||||
|
prompt: string
|
||||||
|
completion: string
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMap = new Map<string, ModelInfo[]>()
|
||||||
|
|
||||||
|
for (const m of json.data) {
|
||||||
|
const slashIdx = m.id.indexOf("/")
|
||||||
|
const providerSlug =
|
||||||
|
slashIdx > 0 ? m.id.slice(0, slashIdx) : "other"
|
||||||
|
const providerName = formatProvider(providerSlug)
|
||||||
|
|
||||||
|
const info: ModelInfo = {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
provider: providerName,
|
||||||
|
contextLength: m.context_length,
|
||||||
|
promptCost: m.pricing.prompt,
|
||||||
|
completionCost: m.pricing.completion,
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = groupMap.get(providerName)
|
||||||
|
if (existing) {
|
||||||
|
existing.push(info)
|
||||||
|
} else {
|
||||||
|
groupMap.set(providerName, [info])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ReadonlyArray<ProviderGroup> = Array.from(
|
||||||
|
groupMap.entries()
|
||||||
|
)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([provider, models]) => ({
|
||||||
|
provider,
|
||||||
|
models: models.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
cachedModels = groups
|
||||||
|
cacheTimestamp = Date.now()
|
||||||
|
|
||||||
|
return { success: true, data: groups }
|
||||||
|
} catch (err) {
|
||||||
|
if (cachedModels) {
|
||||||
|
return { success: true, data: cachedModels }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to fetch models",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsageMetrics(
|
||||||
|
days = 30
|
||||||
|
): Promise<{
|
||||||
|
success: true
|
||||||
|
data: UsageMetrics
|
||||||
|
} | {
|
||||||
|
success: false
|
||||||
|
error: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
if (!can(user, "agent", "update")) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Permission denied",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const cutoff = new Date()
|
||||||
|
cutoff.setDate(cutoff.getDate() - days)
|
||||||
|
const cutoffStr = cutoff.toISOString()
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(agentUsage)
|
||||||
|
.where(gte(agentUsage.createdAt, cutoffStr))
|
||||||
|
.orderBy(desc(agentUsage.createdAt))
|
||||||
|
.all()
|
||||||
|
|
||||||
|
let totalTokens = 0
|
||||||
|
let totalCost = 0
|
||||||
|
|
||||||
|
const dailyMap = new Map<
|
||||||
|
string,
|
||||||
|
{ tokens: number; cost: number; requests: number }
|
||||||
|
>()
|
||||||
|
const modelMap = new Map<
|
||||||
|
string,
|
||||||
|
{ tokens: number; cost: number; requests: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
totalTokens += row.totalTokens
|
||||||
|
totalCost += parseFloat(row.estimatedCost)
|
||||||
|
|
||||||
|
const date = row.createdAt.slice(0, 10)
|
||||||
|
const daily = dailyMap.get(date) ?? {
|
||||||
|
tokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
requests: 0,
|
||||||
|
}
|
||||||
|
daily.tokens += row.totalTokens
|
||||||
|
daily.cost += parseFloat(row.estimatedCost)
|
||||||
|
daily.requests += 1
|
||||||
|
dailyMap.set(date, daily)
|
||||||
|
|
||||||
|
const model = modelMap.get(row.modelId) ?? {
|
||||||
|
tokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
requests: 0,
|
||||||
|
}
|
||||||
|
model.tokens += row.totalTokens
|
||||||
|
model.cost += parseFloat(row.estimatedCost)
|
||||||
|
model.requests += 1
|
||||||
|
modelMap.set(row.modelId, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalRequests: rows.length,
|
||||||
|
totalTokens,
|
||||||
|
totalCost: totalCost.toFixed(4),
|
||||||
|
dailyBreakdown: Array.from(dailyMap.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([date, d]) => ({
|
||||||
|
date,
|
||||||
|
tokens: d.tokens,
|
||||||
|
cost: d.cost.toFixed(4),
|
||||||
|
requests: d.requests,
|
||||||
|
})),
|
||||||
|
modelBreakdown: Array.from(modelMap.entries())
|
||||||
|
.sort(
|
||||||
|
([, a], [, b]) => b.requests - a.requests
|
||||||
|
)
|
||||||
|
.map(([modelId, d]) => ({
|
||||||
|
modelId,
|
||||||
|
tokens: d.tokens,
|
||||||
|
cost: d.cost.toFixed(4),
|
||||||
|
requests: d.requests,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to get metrics",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversationUsage(
|
||||||
|
conversationId: string
|
||||||
|
): Promise<{
|
||||||
|
success: true
|
||||||
|
data: ReadonlyArray<{
|
||||||
|
modelId: string
|
||||||
|
promptTokens: number
|
||||||
|
completionTokens: number
|
||||||
|
totalTokens: number
|
||||||
|
estimatedCost: string
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
} | {
|
||||||
|
success: false
|
||||||
|
error: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const isAdmin = can(user, "agent", "update")
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(agentUsage)
|
||||||
|
.where(
|
||||||
|
isAdmin
|
||||||
|
? eq(agentUsage.conversationId, conversationId)
|
||||||
|
: and(
|
||||||
|
eq(
|
||||||
|
agentUsage.conversationId,
|
||||||
|
conversationId
|
||||||
|
),
|
||||||
|
eq(agentUsage.userId, user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(agentUsage.createdAt))
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: rows.map((r) => ({
|
||||||
|
modelId: r.modelId,
|
||||||
|
promptTokens: r.promptTokens,
|
||||||
|
completionTokens: r.completionTokens,
|
||||||
|
totalTokens: r.totalTokens,
|
||||||
|
estimatedCost: r.estimatedCost,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to get usage",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- model policy ---
|
||||||
|
|
||||||
|
export async function updateModelPolicy(
|
||||||
|
maxCostPerMillion: string | null,
|
||||||
|
allowUserSelection: boolean
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
if (!can(user, "agent", "update")) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Permission denied",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: agentConfig.id })
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(agentConfig)
|
||||||
|
.set({
|
||||||
|
maxCostPerMillion,
|
||||||
|
allowUserSelection: allowUserSelection
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
updatedBy: user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
const {
|
||||||
|
DEFAULT_MODEL_ID,
|
||||||
|
} = await import("@/lib/agent/provider")
|
||||||
|
await db
|
||||||
|
.insert(agentConfig)
|
||||||
|
.values({
|
||||||
|
id: "global",
|
||||||
|
modelId: DEFAULT_MODEL_ID,
|
||||||
|
modelName: "Default",
|
||||||
|
provider: "default",
|
||||||
|
promptCost: "0",
|
||||||
|
completionCost: "0",
|
||||||
|
contextLength: 0,
|
||||||
|
maxCostPerMillion,
|
||||||
|
allowUserSelection: allowUserSelection
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
updatedBy: user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/")
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to update policy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- user model preference ---
|
||||||
|
|
||||||
|
export async function getUserModelPreference(): Promise<{
|
||||||
|
success: true
|
||||||
|
data: {
|
||||||
|
modelId: string
|
||||||
|
promptCost: string
|
||||||
|
completionCost: string
|
||||||
|
} | null
|
||||||
|
} | {
|
||||||
|
success: false
|
||||||
|
error: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const pref = await db
|
||||||
|
.select()
|
||||||
|
.from(userModelPreference)
|
||||||
|
.where(eq(userModelPreference.userId, user.id))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: pref
|
||||||
|
? {
|
||||||
|
modelId: pref.modelId,
|
||||||
|
promptCost: pref.promptCost,
|
||||||
|
completionCost: pref.completionCost,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to get preference",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserModelPreference(
|
||||||
|
modelId: string,
|
||||||
|
promptCost: string,
|
||||||
|
completionCost: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const config = await db
|
||||||
|
.select()
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const isAdmin = can(user, "agent", "update")
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isAdmin &&
|
||||||
|
config &&
|
||||||
|
config.allowUserSelection !== 1
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "User model selection is disabled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.maxCostPerMillion) {
|
||||||
|
const ceiling = parseFloat(
|
||||||
|
config.maxCostPerMillion
|
||||||
|
)
|
||||||
|
const outputPerMillion =
|
||||||
|
parseFloat(completionCost) * 1_000_000
|
||||||
|
if (outputPerMillion > ceiling) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Model exceeds cost ceiling of $${config.maxCostPerMillion}/M`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(userModelPreference)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
modelId,
|
||||||
|
promptCost,
|
||||||
|
completionCost,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: userModelPreference.userId,
|
||||||
|
set: {
|
||||||
|
modelId,
|
||||||
|
promptCost,
|
||||||
|
completionCost,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to save preference",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearUserModelPreference(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(userModelPreference)
|
||||||
|
.where(eq(userModelPreference.userId, user.id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to clear preference",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
function formatProvider(slug: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
openai: "OpenAI",
|
||||||
|
google: "Google",
|
||||||
|
meta: "Meta",
|
||||||
|
mistralai: "Mistral",
|
||||||
|
qwen: "Alibaba (Qwen)",
|
||||||
|
deepseek: "DeepSeek",
|
||||||
|
cohere: "Cohere",
|
||||||
|
"x-ai": "xAI",
|
||||||
|
nvidia: "NVIDIA",
|
||||||
|
microsoft: "Microsoft",
|
||||||
|
amazon: "Amazon",
|
||||||
|
perplexity: "Perplexity",
|
||||||
|
moonshotai: "Moonshot",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
map[slug] ??
|
||||||
|
slug.charAt(0).toUpperCase() + slug.slice(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,12 +5,16 @@ import {
|
|||||||
type UIMessage,
|
type UIMessage,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
import { getAgentModel } from "@/lib/agent/provider"
|
import {
|
||||||
|
resolveModelForUser,
|
||||||
|
createModelFromId,
|
||||||
|
} from "@/lib/agent/provider"
|
||||||
import { agentTools } from "@/lib/agent/tools"
|
import { agentTools } from "@/lib/agent/tools"
|
||||||
import { githubTools } from "@/lib/agent/github-tools"
|
import { githubTools } from "@/lib/agent/github-tools"
|
||||||
import { buildSystemPrompt } from "@/lib/agent/system-prompt"
|
import { buildSystemPrompt } from "@/lib/agent/system-prompt"
|
||||||
import { loadMemoriesForPrompt } from "@/lib/agent/memory"
|
import { loadMemoriesForPrompt } from "@/lib/agent/memory"
|
||||||
import { getRegistry } from "@/lib/agent/plugins/registry"
|
import { getRegistry } from "@/lib/agent/plugins/registry"
|
||||||
|
import { saveStreamUsage } from "@/lib/agent/usage"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
import { getDb } from "@/db"
|
import { getDb } from "@/db"
|
||||||
|
|
||||||
@ -20,7 +24,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
return new Response("Unauthorized", { status: 401 })
|
return new Response("Unauthorized", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { env } = await getCloudflareContext()
|
const { env, ctx } = await getCloudflareContext()
|
||||||
const db = getDb(env.DB)
|
const db = getDb(env.DB)
|
||||||
const envRecord = env as unknown as Record<string, string>
|
const envRecord = env as unknown as Record<string, string>
|
||||||
|
|
||||||
@ -39,8 +43,18 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
req.headers.get("x-current-page") ?? undefined
|
req.headers.get("x-current-page") ?? undefined
|
||||||
const timezone =
|
const timezone =
|
||||||
req.headers.get("x-timezone") ?? undefined
|
req.headers.get("x-timezone") ?? undefined
|
||||||
|
const conversationId =
|
||||||
|
req.headers.get("x-conversation-id") ||
|
||||||
|
crypto.randomUUID()
|
||||||
|
|
||||||
const model = await getAgentModel()
|
const modelId = await resolveModelForUser(
|
||||||
|
db,
|
||||||
|
user.id
|
||||||
|
)
|
||||||
|
const model = createModelFromId(
|
||||||
|
envRecord.OPENROUTER_API_KEY,
|
||||||
|
modelId
|
||||||
|
)
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
@ -58,5 +72,15 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
stopWhen: stepCountIs(10),
|
stopWhen: stepCountIs(10),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ctx.waitUntil(
|
||||||
|
saveStreamUsage(
|
||||||
|
db,
|
||||||
|
conversationId,
|
||||||
|
user.id,
|
||||||
|
modelId,
|
||||||
|
result
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse()
|
return result.toUIMessageStreamResponse()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ interface ChatStateValue {
|
|||||||
stop: () => void
|
stop: () => void
|
||||||
readonly status: string
|
readonly status: string
|
||||||
readonly isGenerating: boolean
|
readonly isGenerating: boolean
|
||||||
readonly conversationId: string | null
|
readonly conversationId: string
|
||||||
newChat: () => void
|
newChat: () => void
|
||||||
readonly pathname: string
|
readonly pathname: string
|
||||||
}
|
}
|
||||||
@ -158,7 +158,14 @@ export function ChatProvider({
|
|||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = React.useState(false)
|
const [isOpen, setIsOpen] = React.useState(false)
|
||||||
const [conversationId, setConversationId] =
|
const [conversationId, setConversationId] =
|
||||||
React.useState<string | null>(null)
|
React.useState("")
|
||||||
|
|
||||||
|
// generate initial ID client-side only to avoid hydration mismatch
|
||||||
|
React.useEffect(() => {
|
||||||
|
setConversationId((prev) =>
|
||||||
|
prev === "" ? crypto.randomUUID() : prev
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
const [resumeLoaded, setResumeLoaded] =
|
const [resumeLoaded, setResumeLoaded] =
|
||||||
React.useState(false)
|
React.useState(false)
|
||||||
const [dataContext, setDataContext] = React.useState<
|
const [dataContext, setDataContext] = React.useState<
|
||||||
@ -169,13 +176,11 @@ export function ChatProvider({
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const chat = useCompassChat({
|
const chat = useCompassChat({
|
||||||
|
conversationId,
|
||||||
openPanel: () => setIsOpen(true),
|
openPanel: () => setIsOpen(true),
|
||||||
onFinish: async ({ messages: finalMessages }) => {
|
onFinish: async ({ messages: finalMessages }) => {
|
||||||
if (finalMessages.length === 0) return
|
if (finalMessages.length === 0) return
|
||||||
|
|
||||||
const id = conversationId ?? crypto.randomUUID()
|
|
||||||
if (!conversationId) setConversationId(id)
|
|
||||||
|
|
||||||
const serialized = finalMessages.map((m) => ({
|
const serialized = finalMessages.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@ -189,7 +194,7 @@ export function ChatProvider({
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await saveConversation(id, serialized)
|
await saveConversation(conversationId, serialized)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -339,7 +344,7 @@ export function ChatProvider({
|
|||||||
|
|
||||||
const newChat = React.useCallback(() => {
|
const newChat = React.useCallback(() => {
|
||||||
chat.setMessages([])
|
chat.setMessages([])
|
||||||
setConversationId(null)
|
setConversationId(crypto.randomUUID())
|
||||||
setResumeLoaded(true)
|
setResumeLoaded(true)
|
||||||
clearRender()
|
clearRender()
|
||||||
renderDispatchedRef.current.clear()
|
renderDispatchedRef.current.clear()
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react"
|
import { useState, useCallback, useRef, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
SendHorizonal,
|
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
ThumbsUpIcon,
|
ThumbsUpIcon,
|
||||||
ThumbsDownIcon,
|
ThumbsDownIcon,
|
||||||
@ -59,6 +58,7 @@ import { useAudioRecorder } from "@/hooks/use-audio-recorder"
|
|||||||
import type { AudioRecorder } from "@/hooks/use-audio-recorder"
|
import type { AudioRecorder } from "@/hooks/use-audio-recorder"
|
||||||
import { AudioWaveform } from "@/components/ai/audio-waveform"
|
import { AudioWaveform } from "@/components/ai/audio-waveform"
|
||||||
import { useChatState } from "./chat-provider"
|
import { useChatState } from "./chat-provider"
|
||||||
|
import { ModelDropdown } from "./model-dropdown"
|
||||||
import { getRepoStats } from "@/app/actions/github"
|
import { getRepoStats } from "@/app/actions/github"
|
||||||
|
|
||||||
type RepoStats = {
|
type RepoStats = {
|
||||||
@ -373,6 +373,7 @@ function ChatInput({
|
|||||||
<SquarePenIcon className="size-4" />
|
<SquarePenIcon className="size-4" />
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
)}
|
)}
|
||||||
|
<ModelDropdown />
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
@ -431,7 +432,6 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
const recorder = useAudioRecorder(handleTranscription)
|
const recorder = useAudioRecorder(handleTranscription)
|
||||||
|
|
||||||
const [isActive, setIsActive] = useState(false)
|
const [isActive, setIsActive] = useState(false)
|
||||||
const [idleInput, setIdleInput] = useState("")
|
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(
|
const [copiedId, setCopiedId] = useState<string | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
@ -439,8 +439,6 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
// typewriter animation state (page variant only)
|
// typewriter animation state (page variant only)
|
||||||
const [animatedPlaceholder, setAnimatedPlaceholder] =
|
const [animatedPlaceholder, setAnimatedPlaceholder] =
|
||||||
useState("")
|
useState("")
|
||||||
const [animFading, setAnimFading] = useState(false)
|
|
||||||
const [isIdleFocused, setIsIdleFocused] = useState(false)
|
|
||||||
const animTimerRef =
|
const animTimerRef =
|
||||||
useRef<ReturnType<typeof setTimeout>>(undefined)
|
useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
@ -454,14 +452,8 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
|
|
||||||
// typewriter animation for idle input (page variant)
|
// typewriter animation for idle input (page variant)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!isPage || isActive) {
|
||||||
!isPage ||
|
|
||||||
isIdleFocused ||
|
|
||||||
idleInput ||
|
|
||||||
isActive
|
|
||||||
) {
|
|
||||||
setAnimatedPlaceholder("")
|
setAnimatedPlaceholder("")
|
||||||
setAnimFading(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -486,7 +478,6 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
}
|
}
|
||||||
} else if (phase === "pause") {
|
} else if (phase === "pause") {
|
||||||
phase = "fading"
|
phase = "fading"
|
||||||
setAnimFading(true)
|
|
||||||
animTimerRef.current = setTimeout(tick, 400)
|
animTimerRef.current = setTimeout(tick, 400)
|
||||||
} else {
|
} else {
|
||||||
msgIdx =
|
msgIdx =
|
||||||
@ -495,7 +486,6 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
setAnimatedPlaceholder(
|
setAnimatedPlaceholder(
|
||||||
ANIMATED_PLACEHOLDERS[msgIdx].slice(0, 1)
|
ANIMATED_PLACEHOLDERS[msgIdx].slice(0, 1)
|
||||||
)
|
)
|
||||||
setAnimFading(false)
|
|
||||||
phase = "typing"
|
phase = "typing"
|
||||||
animTimerRef.current = setTimeout(tick, 50)
|
animTimerRef.current = setTimeout(tick, 50)
|
||||||
}
|
}
|
||||||
@ -507,7 +497,7 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
if (animTimerRef.current)
|
if (animTimerRef.current)
|
||||||
clearTimeout(animTimerRef.current)
|
clearTimeout(animTimerRef.current)
|
||||||
}
|
}
|
||||||
}, [isPage, isIdleFocused, idleInput, isActive])
|
}, [isPage, isActive])
|
||||||
|
|
||||||
// escape to return to idle when no messages (page)
|
// escape to return to idle when no messages (page)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -535,19 +525,6 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleIdleSubmit = useCallback(
|
|
||||||
(e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const value = idleInput.trim()
|
|
||||||
setIsActive(true)
|
|
||||||
if (value) {
|
|
||||||
chat.sendMessage({ text: value })
|
|
||||||
setIdleInput("")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[idleInput, chat.sendMessage]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSuggestion = useCallback(
|
const handleSuggestion = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
if (isPage) setIsActive(true)
|
if (isPage) setIsActive(true)
|
||||||
@ -608,36 +585,20 @@ export function ChatView({ variant }: ChatViewProps) {
|
|||||||
incomplete or change without notice.
|
incomplete or change without notice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleIdleSubmit}>
|
<ChatInput
|
||||||
<label className="group flex w-full items-center gap-2 rounded-full border bg-background px-5 py-3 text-sm shadow-sm transition-colors hover:border-primary/30 hover:bg-muted/30 cursor-text">
|
textareaRef={textareaRef}
|
||||||
<input
|
placeholder={
|
||||||
value={idleInput}
|
animatedPlaceholder || "Ask anything..."
|
||||||
onChange={(e) =>
|
}
|
||||||
setIdleInput(e.target.value)
|
recorder={recorder}
|
||||||
}
|
status={chat.status}
|
||||||
onFocus={() => setIsIdleFocused(true)}
|
isGenerating={chat.isGenerating}
|
||||||
onBlur={() => setIsIdleFocused(false)}
|
onSend={(text) => {
|
||||||
placeholder={
|
setIsActive(true)
|
||||||
animatedPlaceholder ||
|
chat.sendMessage({ text })
|
||||||
"Ask anything..."
|
}}
|
||||||
}
|
className="rounded-2xl"
|
||||||
className={cn(
|
/>
|
||||||
"flex-1 bg-transparent text-foreground outline-none",
|
|
||||||
"placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-300",
|
|
||||||
animFading
|
|
||||||
? "placeholder:opacity-0"
|
|
||||||
: "placeholder:opacity-100"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="shrink-0"
|
|
||||||
aria-label="Send"
|
|
||||||
>
|
|
||||||
<SendHorizonal className="size-4 text-muted-foreground/60 transition-colors group-hover:text-primary" />
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs text-muted-foreground/70">
|
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs text-muted-foreground/70">
|
||||||
|
|||||||
569
src/components/agent/model-dropdown.tsx
Executable file
@ -0,0 +1,569 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Check,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ProviderIcon, hasLogo } from "./provider-icon"
|
||||||
|
import {
|
||||||
|
getActiveModel,
|
||||||
|
getModelList,
|
||||||
|
getUserModelPreference,
|
||||||
|
setUserModelPreference,
|
||||||
|
} from "@/app/actions/ai-config"
|
||||||
|
|
||||||
|
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
|
||||||
|
const DEFAULT_MODEL_NAME = "Qwen3 Coder"
|
||||||
|
const DEFAULT_PROVIDER = "Alibaba (Qwen)"
|
||||||
|
|
||||||
|
// --- shared state so all instances stay in sync ---
|
||||||
|
|
||||||
|
interface SharedState {
|
||||||
|
readonly display: {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly provider: string
|
||||||
|
}
|
||||||
|
readonly global: {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly provider: string
|
||||||
|
}
|
||||||
|
readonly allowUserSelection: boolean
|
||||||
|
readonly isAdmin: boolean
|
||||||
|
readonly maxCostPerMillion: string | null
|
||||||
|
readonly configLoaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared: SharedState = {
|
||||||
|
display: {
|
||||||
|
id: DEFAULT_MODEL_ID,
|
||||||
|
name: DEFAULT_MODEL_NAME,
|
||||||
|
provider: DEFAULT_PROVIDER,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
id: DEFAULT_MODEL_ID,
|
||||||
|
name: DEFAULT_MODEL_NAME,
|
||||||
|
provider: DEFAULT_PROVIDER,
|
||||||
|
},
|
||||||
|
allowUserSelection: true,
|
||||||
|
isAdmin: false,
|
||||||
|
maxCostPerMillion: null,
|
||||||
|
configLoaded: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
function getSnapshot(): SharedState {
|
||||||
|
return shared
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShared(
|
||||||
|
next: Partial<SharedState>
|
||||||
|
): void {
|
||||||
|
shared = { ...shared, ...next }
|
||||||
|
for (const l of listeners) l()
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(
|
||||||
|
listener: () => void
|
||||||
|
): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly provider: string
|
||||||
|
readonly contextLength: number
|
||||||
|
readonly promptCost: string
|
||||||
|
readonly completionCost: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderGroup {
|
||||||
|
readonly provider: string
|
||||||
|
readonly models: ReadonlyArray<ModelInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputCostPerMillion(
|
||||||
|
completionCost: string
|
||||||
|
): number {
|
||||||
|
return parseFloat(completionCost) * 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOutputCost(
|
||||||
|
completionCost: string
|
||||||
|
): string {
|
||||||
|
const cost = outputCostPerMillion(completionCost)
|
||||||
|
if (cost === 0) return "free"
|
||||||
|
if (cost < 0.01) return "<$0.01/M"
|
||||||
|
return `$${cost.toFixed(2)}/M`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelDropdown(): React.JSX.Element {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const state = React.useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
getSnapshot,
|
||||||
|
getSnapshot
|
||||||
|
)
|
||||||
|
const [groups, setGroups] = React.useState<
|
||||||
|
ReadonlyArray<ProviderGroup>
|
||||||
|
>([])
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [search, setSearch] = React.useState("")
|
||||||
|
const [saving, setSaving] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null)
|
||||||
|
const [listLoaded, setListLoaded] =
|
||||||
|
React.useState(false)
|
||||||
|
const [activeProvider, setActiveProvider] =
|
||||||
|
React.useState<string | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (state.configLoaded) return
|
||||||
|
setShared({ configLoaded: true })
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
getActiveModel(),
|
||||||
|
getUserModelPreference(),
|
||||||
|
]).then(([configResult, prefResult]) => {
|
||||||
|
let gModelId = DEFAULT_MODEL_ID
|
||||||
|
let gModelName = DEFAULT_MODEL_NAME
|
||||||
|
let gProvider = DEFAULT_PROVIDER
|
||||||
|
let canSelect = true
|
||||||
|
let ceiling: string | null = null
|
||||||
|
|
||||||
|
let admin = false
|
||||||
|
|
||||||
|
if (configResult.success && configResult.data) {
|
||||||
|
gModelId = configResult.data.modelId
|
||||||
|
gModelName = configResult.data.modelName
|
||||||
|
gProvider = configResult.data.provider
|
||||||
|
canSelect =
|
||||||
|
configResult.data.allowUserSelection
|
||||||
|
ceiling =
|
||||||
|
configResult.data.maxCostPerMillion
|
||||||
|
admin = configResult.data.isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: Partial<SharedState> = {
|
||||||
|
global: {
|
||||||
|
id: gModelId,
|
||||||
|
name: gModelName,
|
||||||
|
provider: gProvider,
|
||||||
|
},
|
||||||
|
allowUserSelection: canSelect,
|
||||||
|
isAdmin: admin,
|
||||||
|
maxCostPerMillion: ceiling,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
canSelect &&
|
||||||
|
prefResult.success &&
|
||||||
|
prefResult.data
|
||||||
|
) {
|
||||||
|
const prefValid =
|
||||||
|
ceiling === null ||
|
||||||
|
outputCostPerMillion(
|
||||||
|
prefResult.data.completionCost
|
||||||
|
) <= parseFloat(ceiling)
|
||||||
|
|
||||||
|
if (prefValid) {
|
||||||
|
const slashIdx =
|
||||||
|
prefResult.data.modelId.indexOf("/")
|
||||||
|
setShared({
|
||||||
|
...base,
|
||||||
|
display: {
|
||||||
|
id: prefResult.data.modelId,
|
||||||
|
name:
|
||||||
|
slashIdx > 0
|
||||||
|
? prefResult.data.modelId.slice(
|
||||||
|
slashIdx + 1
|
||||||
|
)
|
||||||
|
: prefResult.data.modelId,
|
||||||
|
provider: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShared({
|
||||||
|
...base,
|
||||||
|
display: {
|
||||||
|
id: gModelId,
|
||||||
|
name: gModelName,
|
||||||
|
provider: gProvider,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [state.configLoaded])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open || listLoaded) return
|
||||||
|
setLoading(true)
|
||||||
|
getModelList().then((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
const sorted = [...result.data]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aHas = hasLogo(a.provider) ? 0 : 1
|
||||||
|
const bHas = hasLogo(b.provider) ? 0 : 1
|
||||||
|
if (aHas !== bHas) return aHas - bHas
|
||||||
|
return a.provider.localeCompare(
|
||||||
|
b.provider
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
models: [...g.models].sort(
|
||||||
|
(a, b) =>
|
||||||
|
outputCostPerMillion(
|
||||||
|
a.completionCost
|
||||||
|
) -
|
||||||
|
outputCostPerMillion(
|
||||||
|
b.completionCost
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
setGroups(sorted)
|
||||||
|
}
|
||||||
|
setListLoaded(true)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [open, listLoaded])
|
||||||
|
|
||||||
|
// reset provider filter when popover closes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveProvider(null)
|
||||||
|
setSearch("")
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const query = search.toLowerCase()
|
||||||
|
const ceiling = state.maxCostPerMillion
|
||||||
|
? parseFloat(state.maxCostPerMillion)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
return groups
|
||||||
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
models: g.models.filter((m) => {
|
||||||
|
if (ceiling !== null) {
|
||||||
|
if (
|
||||||
|
outputCostPerMillion(
|
||||||
|
m.completionCost
|
||||||
|
) > ceiling
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
activeProvider &&
|
||||||
|
g.provider !== activeProvider
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!query) return true
|
||||||
|
return (
|
||||||
|
m.name.toLowerCase().includes(query) ||
|
||||||
|
m.id.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.models.length > 0)
|
||||||
|
}, [groups, query, ceiling, activeProvider])
|
||||||
|
|
||||||
|
const totalFiltered = React.useMemo(() => {
|
||||||
|
let count = 0
|
||||||
|
for (const g of groups) {
|
||||||
|
for (const m of g.models) {
|
||||||
|
if (
|
||||||
|
ceiling === null ||
|
||||||
|
outputCostPerMillion(m.completionCost) <=
|
||||||
|
ceiling
|
||||||
|
) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}, [groups, ceiling])
|
||||||
|
|
||||||
|
// sorted groups for provider sidebar (cost-filtered)
|
||||||
|
const sortedGroups = React.useMemo(() => {
|
||||||
|
return groups
|
||||||
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
models: g.models.filter((m) => {
|
||||||
|
if (ceiling === null) return true
|
||||||
|
return (
|
||||||
|
outputCostPerMillion(
|
||||||
|
m.completionCost
|
||||||
|
) <= ceiling
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.models.length > 0)
|
||||||
|
}, [groups, ceiling])
|
||||||
|
|
||||||
|
const handleSelect = async (
|
||||||
|
model: ModelInfo
|
||||||
|
): Promise<void> => {
|
||||||
|
if (model.id === state.display.id) {
|
||||||
|
setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(model.id)
|
||||||
|
const result = await setUserModelPreference(
|
||||||
|
model.id,
|
||||||
|
model.promptCost,
|
||||||
|
model.completionCost
|
||||||
|
)
|
||||||
|
setSaving(null)
|
||||||
|
if (result.success) {
|
||||||
|
setShared({
|
||||||
|
display: {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
provider: model.provider,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.success(`Switched to ${model.name}`)
|
||||||
|
setOpen(false)
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Failed to switch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.allowUserSelection && !state.isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground">
|
||||||
|
<ProviderIcon
|
||||||
|
provider={state.global.provider}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span className="max-w-28 truncate">
|
||||||
|
{state.global.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground",
|
||||||
|
"hover:bg-muted hover:text-foreground transition-colors",
|
||||||
|
open && "bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderIcon
|
||||||
|
provider={state.display.provider}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span className="max-w-28 truncate">
|
||||||
|
{state.display.name}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
className="w-96 p-0"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
{/* search */}
|
||||||
|
<div className="p-2 border-b">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSearch(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Search models..."
|
||||||
|
className="h-8 pl-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* two-panel layout */}
|
||||||
|
<div className="flex h-72">
|
||||||
|
{/* provider sidebar */}
|
||||||
|
<div className="w-11 shrink-0 overflow-y-auto flex flex-col items-center gap-0.5 py-1 border-r">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setActiveProvider(null)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold transition-all shrink-0",
|
||||||
|
activeProvider === null
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p className="text-xs">
|
||||||
|
All providers
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{sortedGroups.map((group) => (
|
||||||
|
<Tooltip key={group.provider}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setActiveProvider(
|
||||||
|
activeProvider ===
|
||||||
|
group.provider
|
||||||
|
? null
|
||||||
|
: group.provider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-8 h-8 rounded-full flex items-center justify-center transition-all shrink-0",
|
||||||
|
activeProvider ===
|
||||||
|
group.provider
|
||||||
|
? "bg-primary/15 scale-110"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderIcon
|
||||||
|
provider={group.provider}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p className="text-xs">
|
||||||
|
{group.provider} (
|
||||||
|
{group.models.length})
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* model list */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
|
||||||
|
No models found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filtered.map((group) =>
|
||||||
|
group.models.map((model) => {
|
||||||
|
const isActive =
|
||||||
|
model.id === state.display.id
|
||||||
|
const isSaving =
|
||||||
|
saving === model.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
saving !== null
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleSelect(model)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left rounded-lg px-2.5 py-2 flex items-center gap-2.5 transition-all",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 ring-1 ring-primary/30"
|
||||||
|
: "hover:bg-muted/70",
|
||||||
|
isSaving && "opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderIcon
|
||||||
|
provider={
|
||||||
|
model.provider
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-medium truncate">
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Check className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1 py-0 h-3.5 mt-0.5 font-normal"
|
||||||
|
>
|
||||||
|
{formatOutputCost(
|
||||||
|
model.completionCost
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* budget footer */}
|
||||||
|
{ceiling !== null && listLoaded && (
|
||||||
|
<div className="border-t px-3 py-1.5">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{totalFiltered} models within $
|
||||||
|
{state.maxCostPerMillion}/M budget
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/components/agent/provider-icon.tsx
Executable file
@ -0,0 +1,78 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// provider logo files in /public/providers/
|
||||||
|
export const PROVIDER_LOGO: Record<string, string> = {
|
||||||
|
Anthropic: "anthropic",
|
||||||
|
OpenAI: "openai",
|
||||||
|
Google: "google",
|
||||||
|
Meta: "meta",
|
||||||
|
Mistral: "mistral",
|
||||||
|
DeepSeek: "deepseek",
|
||||||
|
xAI: "xai",
|
||||||
|
NVIDIA: "nvidia",
|
||||||
|
Microsoft: "microsoft",
|
||||||
|
Amazon: "amazon",
|
||||||
|
Perplexity: "perplexity",
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_ABBR: Record<string, string> = {
|
||||||
|
"Alibaba (Qwen)": "Qw",
|
||||||
|
Cohere: "Co",
|
||||||
|
Moonshot: "Ms",
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderAbbr(name: string): string {
|
||||||
|
return (
|
||||||
|
PROVIDER_ABBR[name] ??
|
||||||
|
name.slice(0, 2).toUpperCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLogo(provider: string): boolean {
|
||||||
|
return provider in PROVIDER_LOGO
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderIcon({
|
||||||
|
provider,
|
||||||
|
size = 24,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
readonly provider: string
|
||||||
|
readonly size?: number
|
||||||
|
readonly className?: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const logo = PROVIDER_LOGO[provider]
|
||||||
|
|
||||||
|
if (logo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`/providers/${logo}.svg`}
|
||||||
|
alt={provider}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={cn(
|
||||||
|
"object-contain",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded-full bg-muted/80 text-muted-foreground font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fontSize: Math.max(9, size * 0.35),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getProviderAbbr(provider)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-statu
|
|||||||
import { SyncControls } from "@/components/netsuite/sync-controls"
|
import { SyncControls } from "@/components/netsuite/sync-controls"
|
||||||
import { MemoriesTable } from "@/components/agent/memories-table"
|
import { MemoriesTable } from "@/components/agent/memories-table"
|
||||||
import { SkillsTab } from "@/components/settings/skills-tab"
|
import { SkillsTab } from "@/components/settings/skills-tab"
|
||||||
|
import { AIModelTab } from "@/components/settings/ai-model-tab"
|
||||||
|
|
||||||
export function SettingsModal({
|
export function SettingsModal({
|
||||||
open,
|
open,
|
||||||
@ -156,6 +157,8 @@ export function SettingsModal({
|
|||||||
|
|
||||||
const slabMemoryPage = <MemoriesTable />
|
const slabMemoryPage = <MemoriesTable />
|
||||||
|
|
||||||
|
const aiModelPage = <AIModelTab />
|
||||||
|
|
||||||
const skillsPage = <SkillsTab />
|
const skillsPage = <SkillsTab />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -164,10 +167,10 @@ export function SettingsModal({
|
|||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
description="Manage your app preferences."
|
description="Manage your app preferences."
|
||||||
className="sm:max-w-xl"
|
className="sm:max-w-2xl"
|
||||||
>
|
>
|
||||||
<ResponsiveDialogBody
|
<ResponsiveDialogBody
|
||||||
pages={[generalPage, notificationsPage, appearancePage, integrationsPage, slabMemoryPage, skillsPage]}
|
pages={[generalPage, notificationsPage, appearancePage, integrationsPage, aiModelPage, slabMemoryPage, skillsPage]}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="general" className="w-full">
|
<Tabs defaultValue="general" className="w-full">
|
||||||
<TabsList className="w-full inline-flex justify-start overflow-x-auto">
|
<TabsList className="w-full inline-flex justify-start overflow-x-auto">
|
||||||
@ -183,6 +186,9 @@ export function SettingsModal({
|
|||||||
<TabsTrigger value="integrations" className="text-xs sm:text-sm shrink-0">
|
<TabsTrigger value="integrations" className="text-xs sm:text-sm shrink-0">
|
||||||
Integrations
|
Integrations
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ai-model" className="text-xs sm:text-sm shrink-0">
|
||||||
|
AI Model
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="slab-memory" className="text-xs sm:text-sm shrink-0">
|
<TabsTrigger value="slab-memory" className="text-xs sm:text-sm shrink-0">
|
||||||
Slab Memory
|
Slab Memory
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@ -307,6 +313,13 @@ export function SettingsModal({
|
|||||||
<SyncControls />
|
<SyncControls />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="ai-model"
|
||||||
|
className="space-y-3 pt-3"
|
||||||
|
>
|
||||||
|
<AIModelTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="slab-memory"
|
value="slab-memory"
|
||||||
className="space-y-3 pt-3"
|
className="space-y-3 pt-3"
|
||||||
|
|||||||
782
src/components/settings/ai-model-tab.tsx
Executable file
@ -0,0 +1,782 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
import {
|
||||||
|
getActiveModel,
|
||||||
|
setActiveModel,
|
||||||
|
getModelList,
|
||||||
|
getUsageMetrics,
|
||||||
|
updateModelPolicy,
|
||||||
|
} from "@/app/actions/ai-config"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
ProviderIcon,
|
||||||
|
hasLogo,
|
||||||
|
} from "@/components/agent/provider-icon"
|
||||||
|
|
||||||
|
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly provider: string
|
||||||
|
readonly contextLength: number
|
||||||
|
readonly promptCost: string
|
||||||
|
readonly completionCost: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderGroup {
|
||||||
|
readonly provider: string
|
||||||
|
readonly models: ReadonlyArray<ModelInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveConfig {
|
||||||
|
readonly modelId: string
|
||||||
|
readonly modelName: string
|
||||||
|
readonly provider: string
|
||||||
|
readonly promptCost: string
|
||||||
|
readonly completionCost: string
|
||||||
|
readonly contextLength: number
|
||||||
|
readonly maxCostPerMillion: string | null
|
||||||
|
readonly allowUserSelection: boolean
|
||||||
|
readonly isAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageMetrics {
|
||||||
|
readonly totalRequests: number
|
||||||
|
readonly totalTokens: number
|
||||||
|
readonly totalCost: string
|
||||||
|
readonly dailyBreakdown: ReadonlyArray<{
|
||||||
|
date: string
|
||||||
|
tokens: number
|
||||||
|
cost: string
|
||||||
|
requests: number
|
||||||
|
}>
|
||||||
|
readonly modelBreakdown: ReadonlyArray<{
|
||||||
|
modelId: string
|
||||||
|
tokens: number
|
||||||
|
cost: string
|
||||||
|
requests: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(costPerToken: string): string {
|
||||||
|
const perMillion =
|
||||||
|
parseFloat(costPerToken) * 1_000_000
|
||||||
|
if (perMillion === 0) return "free"
|
||||||
|
if (perMillion < 0.01) return "<$0.01/M"
|
||||||
|
return `$${perMillion.toFixed(2)}/M`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOutputCost(
|
||||||
|
completionCost: string
|
||||||
|
): string {
|
||||||
|
const cost =
|
||||||
|
parseFloat(completionCost) * 1_000_000
|
||||||
|
if (cost === 0) return "free"
|
||||||
|
if (cost < 0.01) return "<$0.01/M"
|
||||||
|
return `$${cost.toFixed(2)}/M`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContext(length: number): string {
|
||||||
|
if (length >= 1_000_000) {
|
||||||
|
return `${(length / 1_000_000).toFixed(0)}M`
|
||||||
|
}
|
||||||
|
return `${(length / 1000).toFixed(0)}k`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(tokens: number): string {
|
||||||
|
if (tokens >= 1_000_000) {
|
||||||
|
return `${(tokens / 1_000_000).toFixed(1)}M`
|
||||||
|
}
|
||||||
|
if (tokens >= 1000) {
|
||||||
|
return `${(tokens / 1000).toFixed(1)}k`
|
||||||
|
}
|
||||||
|
return String(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputCostPerMillion(
|
||||||
|
completionCost: string
|
||||||
|
): number {
|
||||||
|
return parseFloat(completionCost) * 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- two-panel model picker ---
|
||||||
|
|
||||||
|
function ModelPicker({
|
||||||
|
groups,
|
||||||
|
activeConfig,
|
||||||
|
onSaved,
|
||||||
|
maxCostPerMillion,
|
||||||
|
}: {
|
||||||
|
readonly groups: ReadonlyArray<ProviderGroup>
|
||||||
|
readonly activeConfig: ActiveConfig | null
|
||||||
|
readonly onSaved: () => void
|
||||||
|
readonly maxCostPerMillion: number | null
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = React.useState("")
|
||||||
|
const [activeProvider, setActiveProvider] =
|
||||||
|
React.useState<string | null>(null)
|
||||||
|
const [selected, setSelected] =
|
||||||
|
React.useState<ModelInfo | null>(null)
|
||||||
|
const [saving, setSaving] = React.useState(false)
|
||||||
|
|
||||||
|
const currentId =
|
||||||
|
activeConfig?.modelId ?? DEFAULT_MODEL_ID
|
||||||
|
|
||||||
|
const query = search.toLowerCase()
|
||||||
|
|
||||||
|
// sort: providers with logos first, then alphabetical
|
||||||
|
const sortedGroups = React.useMemo(() => {
|
||||||
|
return [...groups].sort((a, b) => {
|
||||||
|
const aHas = hasLogo(a.provider) ? 0 : 1
|
||||||
|
const bHas = hasLogo(b.provider) ? 0 : 1
|
||||||
|
if (aHas !== bHas) return aHas - bHas
|
||||||
|
return a.provider.localeCompare(b.provider)
|
||||||
|
})
|
||||||
|
}, [groups])
|
||||||
|
|
||||||
|
// filter models by search + active provider + cost ceiling
|
||||||
|
const filteredGroups = React.useMemo(() => {
|
||||||
|
return sortedGroups
|
||||||
|
.map((group) => {
|
||||||
|
if (
|
||||||
|
activeProvider &&
|
||||||
|
group.provider !== activeProvider
|
||||||
|
) {
|
||||||
|
return { ...group, models: [] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
models: [...group.models]
|
||||||
|
.filter((m) => {
|
||||||
|
if (maxCostPerMillion !== null) {
|
||||||
|
if (
|
||||||
|
outputCostPerMillion(
|
||||||
|
m.completionCost
|
||||||
|
) > maxCostPerMillion
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!query) return true
|
||||||
|
return (
|
||||||
|
m.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query) ||
|
||||||
|
m.id.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
outputCostPerMillion(
|
||||||
|
a.completionCost
|
||||||
|
) -
|
||||||
|
outputCostPerMillion(
|
||||||
|
b.completionCost
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((g) => g.models.length > 0)
|
||||||
|
}, [sortedGroups, activeProvider, query, maxCostPerMillion])
|
||||||
|
|
||||||
|
const isDirty =
|
||||||
|
selected !== null && selected.id !== currentId
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
setSaving(true)
|
||||||
|
const result = await setActiveModel(
|
||||||
|
selected.id,
|
||||||
|
selected.name,
|
||||||
|
selected.provider,
|
||||||
|
selected.promptCost,
|
||||||
|
selected.completionCost,
|
||||||
|
selected.contextLength
|
||||||
|
)
|
||||||
|
setSaving(false)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Model updated")
|
||||||
|
setSelected(null)
|
||||||
|
onSaved()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Failed to save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search models..."
|
||||||
|
className="h-10 pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* two-panel layout - no outer border */}
|
||||||
|
<div className="flex gap-2 h-80">
|
||||||
|
{/* provider sidebar */}
|
||||||
|
<div className="w-12 shrink-0 overflow-y-auto flex flex-col items-center gap-1 py-0.5">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setActiveProvider(null)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center text-xs font-semibold transition-all shrink-0",
|
||||||
|
activeProvider === null
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p className="text-xs">
|
||||||
|
All providers
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{sortedGroups.map((group) => (
|
||||||
|
<Tooltip key={group.provider}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setActiveProvider(
|
||||||
|
activeProvider ===
|
||||||
|
group.provider
|
||||||
|
? null
|
||||||
|
: group.provider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center transition-all shrink-0",
|
||||||
|
activeProvider ===
|
||||||
|
group.provider
|
||||||
|
? "bg-primary/15 scale-110"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderIcon
|
||||||
|
provider={group.provider}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p className="text-xs">
|
||||||
|
{group.provider} (
|
||||||
|
{group.models.length})
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* model list */}
|
||||||
|
<div className="flex-1 overflow-y-auto pr-1">
|
||||||
|
{filteredGroups.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||||
|
No models found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{filteredGroups.map((group) =>
|
||||||
|
group.models.map((model) => {
|
||||||
|
const isActive =
|
||||||
|
model.id === currentId
|
||||||
|
const isSelected =
|
||||||
|
selected?.id === model.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSelected(model)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left rounded-lg px-3 py-2.5 flex items-center gap-3 transition-all",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 ring-1 ring-primary/30"
|
||||||
|
: "bg-muted/40 hover:bg-muted/70",
|
||||||
|
isActive &&
|
||||||
|
!isSelected &&
|
||||||
|
"bg-primary/5 ring-1 ring-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderIcon
|
||||||
|
provider={model.provider}
|
||||||
|
size={24}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1.5 py-0 h-4 mt-1 font-normal"
|
||||||
|
>
|
||||||
|
{formatOutputCost(
|
||||||
|
model.completionCost
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* save bar */}
|
||||||
|
{isDirty && (
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Switch to{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{selected?.name}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- usage metrics ---
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
tokens: {
|
||||||
|
label: "Tokens",
|
||||||
|
color: "var(--chart-1)",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
function UsageSection({
|
||||||
|
metrics,
|
||||||
|
}: {
|
||||||
|
readonly metrics: UsageMetrics
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Usage (last 30 days)
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="rounded-md border p-2.5">
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
Requests
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{metrics.totalRequests.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-2.5">
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
Tokens
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{formatTokenCount(metrics.totalTokens)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-2.5">
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
Est. Cost
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
${metrics.totalCost}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{metrics.dailyBreakdown.length > 0 && (
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<p className="text-muted-foreground text-[10px] mb-2">
|
||||||
|
Daily token usage
|
||||||
|
</p>
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="h-32 w-full"
|
||||||
|
>
|
||||||
|
<BarChart
|
||||||
|
data={[...metrics.dailyBreakdown]}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
vertical={false}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v: string) =>
|
||||||
|
v.slice(5)
|
||||||
|
}
|
||||||
|
className="text-[10px]"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatTokenCount}
|
||||||
|
width={40}
|
||||||
|
className="text-[10px]"
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent />}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="tokens"
|
||||||
|
fill="var(--color-tokens)"
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics.modelBreakdown.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
By model
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{metrics.modelBreakdown.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.modelId}
|
||||||
|
className="flex items-center justify-between text-xs rounded-md border px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[60%]">
|
||||||
|
{m.modelId}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-[10px] shrink-0">
|
||||||
|
{m.requests} req ·{" "}
|
||||||
|
{formatTokenCount(m.tokens)} tok
|
||||||
|
· ${m.cost}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main tab ---
|
||||||
|
|
||||||
|
export function AIModelTab() {
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [activeConfig, setActiveConfig] =
|
||||||
|
React.useState<ActiveConfig | null>(null)
|
||||||
|
const [groups, setGroups] = React.useState<
|
||||||
|
ReadonlyArray<ProviderGroup>
|
||||||
|
>([])
|
||||||
|
const [metrics, setMetrics] =
|
||||||
|
React.useState<UsageMetrics | null>(null)
|
||||||
|
const [isAdmin, setIsAdmin] = React.useState(false)
|
||||||
|
const [modelsError, setModelsError] =
|
||||||
|
React.useState<string | null>(null)
|
||||||
|
const [allowUserSelection, setAllowUserSelection] =
|
||||||
|
React.useState(true)
|
||||||
|
const [costCeiling, setCostCeiling] =
|
||||||
|
React.useState<number | null>(null)
|
||||||
|
const [policySaving, setPolicySaving] =
|
||||||
|
React.useState(false)
|
||||||
|
|
||||||
|
const loadData = React.useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const configResult = await getActiveModel()
|
||||||
|
if (configResult.success) {
|
||||||
|
setActiveConfig(configResult.data)
|
||||||
|
if (configResult.data) {
|
||||||
|
setAllowUserSelection(
|
||||||
|
configResult.data.allowUserSelection
|
||||||
|
)
|
||||||
|
setCostCeiling(
|
||||||
|
configResult.data.maxCostPerMillion
|
||||||
|
? parseFloat(
|
||||||
|
configResult.data.maxCostPerMillion
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [modelsResult, metricsResult] =
|
||||||
|
await Promise.all([
|
||||||
|
getModelList(),
|
||||||
|
getUsageMetrics(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (modelsResult.success) {
|
||||||
|
setGroups(modelsResult.data)
|
||||||
|
setModelsError(null)
|
||||||
|
} else {
|
||||||
|
setModelsError(modelsResult.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricsResult.success) {
|
||||||
|
setMetrics(metricsResult.data)
|
||||||
|
setIsAdmin(true)
|
||||||
|
} else if (
|
||||||
|
metricsResult.error !== "Permission denied"
|
||||||
|
) {
|
||||||
|
setIsAdmin(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const SLIDER_MAX = 50
|
||||||
|
|
||||||
|
const filteredModelCount = React.useMemo(() => {
|
||||||
|
if (costCeiling === null) return null
|
||||||
|
let total = 0
|
||||||
|
for (const g of groups) {
|
||||||
|
for (const m of g.models) {
|
||||||
|
if (
|
||||||
|
outputCostPerMillion(
|
||||||
|
m.completionCost
|
||||||
|
) <= costCeiling
|
||||||
|
) {
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}, [groups, costCeiling])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
<div className="flex gap-0 rounded-md border overflow-hidden">
|
||||||
|
<Skeleton className="h-80 w-14 rounded-none" />
|
||||||
|
<Skeleton className="h-80 flex-1 rounded-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Active Model</Label>
|
||||||
|
{activeConfig ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">
|
||||||
|
{activeConfig.modelName}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{activeConfig.provider}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Using default: {DEFAULT_MODEL_ID}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Model Policy
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Allow user model selection
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
When off, all users use the model
|
||||||
|
set above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={allowUserSelection}
|
||||||
|
onCheckedChange={setAllowUserSelection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Maximum cost ($/M tokens)
|
||||||
|
</p>
|
||||||
|
<span className="text-sm font-medium tabular-nums">
|
||||||
|
{costCeiling === null
|
||||||
|
? "No limit"
|
||||||
|
: `$${costCeiling.toFixed(2)}/M`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={SLIDER_MAX}
|
||||||
|
step={0.1}
|
||||||
|
value={[costCeiling ?? SLIDER_MAX]}
|
||||||
|
onValueChange={([v]) =>
|
||||||
|
setCostCeiling(
|
||||||
|
v >= SLIDER_MAX ? null : v
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span>$0</span>
|
||||||
|
<span>
|
||||||
|
{costCeiling !== null &&
|
||||||
|
filteredModelCount !== null
|
||||||
|
? `${filteredModelCount} models available`
|
||||||
|
: "All models available"}
|
||||||
|
</span>
|
||||||
|
<span>$50/M</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={policySaving}
|
||||||
|
onClick={async () => {
|
||||||
|
setPolicySaving(true)
|
||||||
|
const result =
|
||||||
|
await updateModelPolicy(
|
||||||
|
costCeiling !== null
|
||||||
|
? costCeiling.toFixed(2)
|
||||||
|
: null,
|
||||||
|
allowUserSelection
|
||||||
|
)
|
||||||
|
setPolicySaving(false)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Policy updated")
|
||||||
|
loadData()
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
result.error ??
|
||||||
|
"Failed to save policy"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{policySaving && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
Save Policy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Change Model
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Select a model from OpenRouter. Applies
|
||||||
|
to all users.
|
||||||
|
</p>
|
||||||
|
{modelsError ? (
|
||||||
|
<p className="text-destructive text-xs">
|
||||||
|
{modelsError}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ModelPicker
|
||||||
|
groups={groups}
|
||||||
|
activeConfig={activeConfig}
|
||||||
|
onSaved={loadData}
|
||||||
|
maxCostPerMillion={costCeiling}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{metrics &&
|
||||||
|
metrics.totalRequests > 0 ? (
|
||||||
|
<UsageSection metrics={metrics} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Usage</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
No usage data yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,12 +3,14 @@ import * as schema from "./schema"
|
|||||||
import * as netsuiteSchema from "./schema-netsuite"
|
import * as netsuiteSchema from "./schema-netsuite"
|
||||||
import * as pluginSchema from "./schema-plugins"
|
import * as pluginSchema from "./schema-plugins"
|
||||||
import * as agentSchema from "./schema-agent"
|
import * as agentSchema from "./schema-agent"
|
||||||
|
import * as aiConfigSchema from "./schema-ai-config"
|
||||||
|
|
||||||
const allSchemas = {
|
const allSchemas = {
|
||||||
...schema,
|
...schema,
|
||||||
...netsuiteSchema,
|
...netsuiteSchema,
|
||||||
...pluginSchema,
|
...pluginSchema,
|
||||||
...agentSchema,
|
...agentSchema,
|
||||||
|
...aiConfigSchema,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDb(d1: D1Database) {
|
export function getDb(d1: D1Database) {
|
||||||
|
|||||||
73
src/db/schema-ai-config.ts
Executable file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
sqliteTable,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
} from "drizzle-orm/sqlite-core"
|
||||||
|
import { users, agentConversations } from "./schema"
|
||||||
|
|
||||||
|
// singleton config row (id = "global")
|
||||||
|
export const agentConfig = sqliteTable("agent_config", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
modelId: text("model_id").notNull(),
|
||||||
|
modelName: text("model_name").notNull(),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
promptCost: text("prompt_cost").notNull(),
|
||||||
|
completionCost: text("completion_cost").notNull(),
|
||||||
|
contextLength: integer("context_length").notNull(),
|
||||||
|
maxCostPerMillion: text("max_cost_per_million"),
|
||||||
|
allowUserSelection: integer("allow_user_selection")
|
||||||
|
.notNull()
|
||||||
|
.default(1),
|
||||||
|
updatedBy: text("updated_by")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// per-user model preference
|
||||||
|
export const userModelPreference = sqliteTable(
|
||||||
|
"user_model_preference",
|
||||||
|
{
|
||||||
|
userId: text("user_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => users.id),
|
||||||
|
modelId: text("model_id").notNull(),
|
||||||
|
promptCost: text("prompt_cost").notNull(),
|
||||||
|
completionCost: text("completion_cost").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// one row per streamText invocation
|
||||||
|
export const agentUsage = sqliteTable("agent_usage", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
conversationId: text("conversation_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => agentConversations.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
modelId: text("model_id").notNull(),
|
||||||
|
promptTokens: integer("prompt_tokens")
|
||||||
|
.notNull()
|
||||||
|
.default(0),
|
||||||
|
completionTokens: integer("completion_tokens")
|
||||||
|
.notNull()
|
||||||
|
.default(0),
|
||||||
|
totalTokens: integer("total_tokens")
|
||||||
|
.notNull()
|
||||||
|
.default(0),
|
||||||
|
estimatedCost: text("estimated_cost").notNull(),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AgentConfig = typeof agentConfig.$inferSelect
|
||||||
|
export type NewAgentConfig = typeof agentConfig.$inferInsert
|
||||||
|
export type AgentUsage = typeof agentUsage.$inferSelect
|
||||||
|
export type NewAgentUsage = typeof agentUsage.$inferInsert
|
||||||
|
export type UserModelPreference =
|
||||||
|
typeof userModelPreference.$inferSelect
|
||||||
|
export type NewUserModelPreference =
|
||||||
|
typeof userModelPreference.$inferInsert
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/lib/agent/chat-adapter"
|
} from "@/lib/agent/chat-adapter"
|
||||||
|
|
||||||
interface UseCompassChatOptions {
|
interface UseCompassChatOptions {
|
||||||
|
readonly conversationId?: string | null
|
||||||
readonly onFinish?: (params: {
|
readonly onFinish?: (params: {
|
||||||
messages: ReadonlyArray<UIMessage>
|
messages: ReadonlyArray<UIMessage>
|
||||||
}) => void | Promise<void>
|
}) => void | Promise<void>
|
||||||
@ -37,6 +38,8 @@ export function useCompassChat(options?: UseCompassChatOptions) {
|
|||||||
"x-current-page": pathname,
|
"x-current-page": pathname,
|
||||||
"x-timezone":
|
"x-timezone":
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
"x-conversation-id":
|
||||||
|
options?.conversationId ?? "",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onFinish: options?.onFinish,
|
onFinish: options?.onFinish,
|
||||||
|
|||||||
@ -1,7 +1,69 @@
|
|||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import {
|
||||||
|
agentConfig,
|
||||||
|
userModelPreference,
|
||||||
|
} from "@/db/schema-ai-config"
|
||||||
|
|
||||||
const MODEL_ID = "qwen/qwen3-coder-next"
|
export const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
|
||||||
|
|
||||||
|
export async function getActiveModelId(
|
||||||
|
db: ReturnType<typeof getDb>
|
||||||
|
): Promise<string> {
|
||||||
|
const config = await db
|
||||||
|
.select({ modelId: agentConfig.modelId })
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
return config?.modelId ?? DEFAULT_MODEL_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModelFromId(
|
||||||
|
apiKey: string,
|
||||||
|
modelId: string
|
||||||
|
) {
|
||||||
|
const openrouter = createOpenRouter({ apiKey })
|
||||||
|
return openrouter(modelId, {
|
||||||
|
provider: { allow_fallbacks: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveModelForUser(
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
userId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const config = await db
|
||||||
|
.select()
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!config) return DEFAULT_MODEL_ID
|
||||||
|
|
||||||
|
const globalModelId = config.modelId
|
||||||
|
const ceiling = config.maxCostPerMillion
|
||||||
|
? parseFloat(config.maxCostPerMillion)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const pref = await db
|
||||||
|
.select()
|
||||||
|
.from(userModelPreference)
|
||||||
|
.where(eq(userModelPreference.userId, userId))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!pref) return globalModelId
|
||||||
|
|
||||||
|
if (ceiling !== null) {
|
||||||
|
const outputPerMillion =
|
||||||
|
parseFloat(pref.completionCost) * 1_000_000
|
||||||
|
if (outputPerMillion > ceiling) return globalModelId
|
||||||
|
}
|
||||||
|
|
||||||
|
return pref.modelId
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAgentModel() {
|
export async function getAgentModel() {
|
||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
@ -14,10 +76,8 @@ export async function getAgentModel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openrouter = createOpenRouter({ apiKey })
|
const db = getDb(env.DB)
|
||||||
return openrouter(MODEL_ID, {
|
const modelId = await getActiveModelId(db)
|
||||||
provider: {
|
|
||||||
allow_fallbacks: false,
|
return createModelFromId(apiKey, modelId)
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/lib/agent/usage.ts
Executable file
@ -0,0 +1,58 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import type { LanguageModelUsage } from "ai"
|
||||||
|
import type { getDb } from "@/db"
|
||||||
|
import { agentConfig, agentUsage } from "@/db/schema-ai-config"
|
||||||
|
|
||||||
|
interface StreamResult {
|
||||||
|
readonly totalUsage: PromiseLike<LanguageModelUsage>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveStreamUsage(
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
modelId: string,
|
||||||
|
result: StreamResult
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const usage = await result.totalUsage
|
||||||
|
|
||||||
|
const promptTokens = usage.inputTokens ?? 0
|
||||||
|
const completionTokens = usage.outputTokens ?? 0
|
||||||
|
const totalTokens = usage.totalTokens ?? 0
|
||||||
|
|
||||||
|
const config = await db
|
||||||
|
.select({
|
||||||
|
promptCost: agentConfig.promptCost,
|
||||||
|
completionCost: agentConfig.completionCost,
|
||||||
|
})
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const promptRate = config
|
||||||
|
? parseFloat(config.promptCost)
|
||||||
|
: 0
|
||||||
|
const completionRate = config
|
||||||
|
? parseFloat(config.completionCost)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const estimatedCost =
|
||||||
|
promptTokens * promptRate +
|
||||||
|
completionTokens * completionRate
|
||||||
|
|
||||||
|
await db.insert(agentUsage).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
modelId,
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
totalTokens,
|
||||||
|
estimatedCost: estimatedCost.toFixed(8),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// usage tracking must never break the chat
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export type Resource =
|
|||||||
| "customer"
|
| "customer"
|
||||||
| "vendor"
|
| "vendor"
|
||||||
| "finance"
|
| "finance"
|
||||||
|
| "agent"
|
||||||
|
|
||||||
export type Action = "create" | "read" | "update" | "delete" | "approve"
|
export type Action = "create" | "read" | "update" | "delete" | "approve"
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ const PERMISSIONS: RolePermissions = {
|
|||||||
customer: ["create", "read", "update", "delete"],
|
customer: ["create", "read", "update", "delete"],
|
||||||
vendor: ["create", "read", "update", "delete"],
|
vendor: ["create", "read", "update", "delete"],
|
||||||
finance: ["create", "read", "update", "delete", "approve"],
|
finance: ["create", "read", "update", "delete", "approve"],
|
||||||
|
agent: ["create", "read", "update", "delete"],
|
||||||
},
|
},
|
||||||
office: {
|
office: {
|
||||||
project: ["create", "read", "update"],
|
project: ["create", "read", "update"],
|
||||||
@ -50,6 +52,7 @@ const PERMISSIONS: RolePermissions = {
|
|||||||
customer: ["create", "read", "update"],
|
customer: ["create", "read", "update"],
|
||||||
vendor: ["create", "read", "update"],
|
vendor: ["create", "read", "update"],
|
||||||
finance: ["create", "read", "update"],
|
finance: ["create", "read", "update"],
|
||||||
|
agent: ["read"],
|
||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
project: ["read"],
|
project: ["read"],
|
||||||
@ -64,6 +67,7 @@ const PERMISSIONS: RolePermissions = {
|
|||||||
customer: ["read"],
|
customer: ["read"],
|
||||||
vendor: ["read"],
|
vendor: ["read"],
|
||||||
finance: ["read"],
|
finance: ["read"],
|
||||||
|
agent: ["read"],
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
project: ["read"],
|
project: ["read"],
|
||||||
@ -78,6 +82,7 @@ const PERMISSIONS: RolePermissions = {
|
|||||||
customer: ["read"],
|
customer: ["read"],
|
||||||
vendor: ["read"],
|
vendor: ["read"],
|
||||||
finance: ["read"],
|
finance: ["read"],
|
||||||
|
agent: [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||