diff --git a/STATS.md b/STATS.md index e220e38cf..07c596227 100644 --- a/STATS.md +++ b/STATS.md @@ -88,3 +88,5 @@ | 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) | | 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) | | 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) | +| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) | +| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) | diff --git a/packages/app/src/components/code.tsx b/packages/app/src/components/code.tsx index e6d5ba80d..63f527c46 100644 --- a/packages/app/src/components/code.tsx +++ b/packages/app/src/components/code.tsx @@ -279,7 +279,7 @@ export function Code(props: Props) { }} innerHTML={html()} class=" - font-mono text-xs tracking-wide overflow-y-auto no-scrollbar h-full + font-mono text-xs tracking-wide overflow-y-auto h-full [&]:[counter-reset:line] [&_pre]:focus-visible:outline-none [&_pre]:overflow-x-auto [&_pre]:no-scrollbar @@ -435,7 +435,7 @@ function transformerUnifiedDiff(): ShikiTransformer { out.push(s) } - return out.join("\n") + return out.join("\n").trimEnd() }, code(node) { if (isDiff) this.addClassToHast(node, "code-diff") diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index a3c4f42df..12d357dd8 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -18,7 +18,7 @@ export default function FileTree(props: { strip(props.text), diff --git a/packages/app/src/components/select-dialog.tsx b/packages/app/src/components/select-dialog.tsx new file mode 100644 index 000000000..315fe14e5 --- /dev/null +++ b/packages/app/src/components/select-dialog.tsx @@ -0,0 +1,225 @@ +import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js" +import { Dialog } from "@kobalte/core/dialog" +import { Icon, IconButton } from "@/ui" +import { createStore } from "solid-js/store" +import { entries, flatMap, groupBy, map, pipe } from "remeda" +import { createList } from "solid-list" +import fuzzysort from "fuzzysort" + +interface SelectDialogProps { + items: T[] | ((filter: string) => Promise) + key: (item: T) => string + render: (item: T) => JSX.Element + filter?: string[] + current?: T + placeholder?: string + groupBy?: (x: T) => string + onSelect?: (value: T | undefined) => void + onClose?: () => void +} + +export function SelectDialog(props: SelectDialogProps) { + let scrollRef: HTMLDivElement | undefined + const [store, setStore] = createStore({ + filter: "", + mouseActive: false, + }) + + const [grouped] = createResource( + () => store.filter, + async (filter) => { + const needle = filter.toLowerCase() + const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || [] + const result = pipe( + all, + (x) => { + if (!needle) return x + if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) { + return fuzzysort.go(needle, x).map((x) => x.target) as T[] + } + return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj) + }, + groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), + // mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))), + entries(), + map(([k, v]) => ({ category: k, items: v })), + ) + return result + }, + ) + const flat = createMemo(() => { + return pipe( + grouped() || [], + flatMap((x) => x.items), + ) + }) + const list = createList({ + items: () => flat().map(props.key), + initialActive: props.current ? props.key(props.current) : undefined, + loop: true, + }) + const resetSelection = () => { + const all = flat() + if (all.length === 0) return + list.setActive(props.key(all[0])) + } + + createEffect(() => { + store.filter + scrollRef?.scrollTo(0, 0) + resetSelection() + }) + + createEffect(() => { + const all = flat() + if (store.mouseActive || all.length === 0) return + if (list.active() === props.key(all[0])) { + scrollRef?.scrollTo(0, 0) + return + } + const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + + const handleInput = (value: string) => { + setStore("filter", value) + resetSelection() + } + + const handleSelect = (item: T) => { + props.onSelect?.(item) + props.onClose?.() + } + + const handleKey = (e: KeyboardEvent) => { + setStore("mouseActive", false) + + if (e.key === "Enter") { + e.preventDefault() + const selected = flat().find((x) => props.key(x) === list.active()) + if (selected) handleSelect(selected) + } else if (e.key === "Escape") { + e.preventDefault() + props.onClose?.() + } else { + list.onKeyDown(e) + } + } + + return ( + open || props.onClose?.()}> + + + +
+
+ + handleInput(e.currentTarget.value)} + onKeyDown={handleKey} + placeholder={props.placeholder} + class="w-full pl-10 pr-4 py-2 rounded-t-md + text-sm text-text placeholder-text-muted/70 + focus:outline-none" + autofocus + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> +
+ {/* +
+ +
+
*/} + + { + setStore("filter", "") + resetSelection() + }} + > + + + +
+
+
+
(scrollRef = el)} class="relative flex-1 overflow-y-auto"> + 0} + fallback={
No results
} + > + + {(group) => ( + <> + +
+ {group.category} +
+
+
+ + {(item) => ( + + )} + +
+ + )} +
+
+
+
+
+ + + ↑↓ + + Navigate + + + + ↵ + + Select + + + + ESC + + Close + +
+ {`${flat().length} results`} +
+
+
+
+ ) +} diff --git a/packages/app/src/components/select.tsx b/packages/app/src/components/select.tsx index a99eccbd8..3df8c9999 100644 --- a/packages/app/src/components/select.tsx +++ b/packages/app/src/components/select.tsx @@ -1,46 +1,26 @@ import { Select as KobalteSelect } from "@kobalte/core/select" -import { createEffect, createMemo, Show } from "solid-js" +import { createMemo } from "solid-js" import type { ComponentProps } from "solid-js" import { Icon } from "@/ui/icon" -import fuzzysort from "fuzzysort" import { pipe, groupBy, entries, map } from "remeda" -import { createStore } from "solid-js/store" +import { Button, type ButtonProps } from "@/ui" export interface SelectProps { - variant?: "default" | "outline" - size?: "sm" | "md" | "lg" placeholder?: string - filter?: - | false - | { - placeholder?: string - keys: string[] - } options: T[] current?: T value?: (x: T) => string label?: (x: T) => string groupBy?: (x: T) => string - onFilter?: (query: string) => void onSelect?: (value: T | undefined) => void class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] } -export function Select(props: SelectProps) { - let inputRef: HTMLInputElement | undefined = undefined - let listboxRef: HTMLUListElement | undefined = undefined - const [store, setStore] = createStore({ - filter: "", - }) +export function Select(props: SelectProps & ButtonProps) { const grouped = createMemo(() => { - const needle = store.filter.toLowerCase() const result = pipe( props.options, - (x) => - !needle || !props.filter - ? x - : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj), groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), entries(), @@ -48,19 +28,6 @@ export function Select(props: SelectProps) { ) return result }) - // const flat = createMemo(() => { - // return pipe( - // grouped(), - // flatMap(({ options }) => options), - // ) - // }) - - createEffect(() => { - store.filter - listboxRef?.scrollTo(0, 0) - // setStore("selected", 0) - // scroll.scrollTo(0) - }) return ( @@ -89,36 +56,21 @@ export function Select(props: SelectProps) { {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} - + )} onChange={(v) => { - if (props.onSelect) props.onSelect(v ?? undefined) - if (v !== null) { - // close the select - } + props.onSelect?.(v ?? undefined) }} - onOpenChange={(v) => v || setStore("filter", "")} > @@ -140,13 +92,6 @@ export function Select(props: SelectProps) { { - if (!props.filter) return - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - return - } - inputRef?.focus() - }} classList={{ "min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true, "bg-background-panel p-1 shadow-md z-50": true, @@ -154,33 +99,7 @@ export function Select(props: SelectProps) { "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true, }} > - - (inputRef = el)} - id="select-filter" - type="text" - placeholder={props.filter ? props.filter.placeholder : "Filter items"} - value={store.filter} - onInput={(e) => setStore("filter", e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - listboxRef?.focus() - } - }} - classList={{ - "w-full": true, - "px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true, - }} - /> - - (listboxRef = el)} - classList={{ - "overflow-y-auto max-h-48 no-scrollbar": true, - }} - /> + diff --git a/packages/app/src/components/session-list.tsx b/packages/app/src/components/session-list.tsx index e57562586..e0819780d 100644 --- a/packages/app/src/components/session-list.tsx +++ b/packages/app/src/components/session-list.tsx @@ -7,7 +7,7 @@ export default function SessionList() { const local = useLocal() return ( - + {(session) => ( + + )} + + + @@ -262,10 +299,10 @@ export default function Page() {
-
+
}> {(activeSession) => (
@@ -470,7 +507,7 @@ export default function Page() { type="text" value={store.prompt} onInput={(e) => setStore("prompt", e.currentTarget.value)} - placeholder="It all starts with a prompt..." + placeholder="Placeholder text..." class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none" />
@@ -479,24 +516,13 @@ export default function Page() { options={local.agent.list().map((a) => a.name)} current={local.agent.current().name} onSelect={local.agent.set} - size="sm" class="uppercase" /> - + + + + } > -
+ -
@@ -176,7 +207,7 @@ export function BillingSection() {
We'll load $20 (+$1.23 processing fee) and reload it when it reaches $5. diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 009a525c7..a9f575976 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -41,12 +41,14 @@ export async function POST(input: APIEvent) { const workspaceID = body.data.object.metadata?.workspaceID const customerID = body.data.object.customer as string const paymentID = body.data.object.payment_intent as string + const invoiceID = body.data.object.invoice as string const amount = body.data.object.amount_total if (!workspaceID) throw new Error("Workspace ID not found") if (!customerID) throw new Error("Customer ID not found") if (!amount) throw new Error("Amount not found") if (!paymentID) throw new Error("Payment ID not found") + if (!invoiceID) throw new Error("Invoice ID not found") await Actor.provide("system", { workspaceID }, async () => { const customer = await Billing.get() @@ -86,6 +88,7 @@ export async function POST(input: APIEvent) { id: Identifier.create("payment"), amount: centsToMicroCents(Billing.CHARGE_AMOUNT), paymentID, + invoiceID, customerID, }) }) diff --git a/packages/console/app/src/routes/zen/handler.ts b/packages/console/app/src/routes/zen/handler.ts index 2ff3ca073..8b9a9e55f 100644 --- a/packages/console/app/src/routes/zen/handler.ts +++ b/packages/console/app/src/routes/zen/handler.ts @@ -147,7 +147,10 @@ export async function handler( return ( reader?.read().then(async ({ done, value }) => { if (done) { - logger.metric({ response_length: responseLength }) + logger.metric({ + response_length: responseLength, + "timestamp.last_byte": Date.now(), + }) const usage = opts.getStreamUsage() if (usage) { await trackUsage(authInfo, modelInfo, providerInfo.id, usage) @@ -158,10 +161,13 @@ export async function handler( } if (responseLength === 0) { - logger.metric({ time_to_first_byte: Date.now() - startTimestamp }) + const now = Date.now() + logger.metric({ + time_to_first_byte: now - startTimestamp, + "timestamp.first_byte": now, + }) } responseLength += value.length - console.log(decoder.decode(value, { stream: true })) buffer += decoder.decode(value, { stream: true }) const parts = buffer.split("\n\n") diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index 1fd85d5c7..4a7dda5f7 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -30,22 +30,25 @@ export function POST(input: APIEvent) { let json try { - json = JSON.parse(data.slice(6)) as { usage?: Usage } + json = JSON.parse(data.slice(6)) } catch (e) { return } - if (!json.usage) return + // ie. { type: "message_start"; message: { usage: Usage } } + // ie. { type: "message_delta"; usage: Usage } + const usageUpdate = json.usage ?? json.message?.usage + if (!usageUpdate) return usage = { ...usage, - ...json.usage, + ...usageUpdate, cache_creation: { ...usage?.cache_creation, - ...json.usage.cache_creation, + ...usageUpdate.cache_creation, }, server_tool_use: { ...usage?.server_tool_use, - ...json.usage.server_tool_use, + ...usageUpdate.server_tool_use, }, } }, diff --git a/packages/console/core/migrations/0017_woozy_thaddeus_ross.sql b/packages/console/core/migrations/0017_woozy_thaddeus_ross.sql new file mode 100644 index 000000000..4aafaff69 --- /dev/null +++ b/packages/console/core/migrations/0017_woozy_thaddeus_ross.sql @@ -0,0 +1 @@ +ALTER TABLE `payment` ADD `invoice_id` varchar(255); \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0017_snapshot.json b/packages/console/core/migrations/meta/0017_snapshot.json new file mode 100644 index 000000000..d7687f9c6 --- /dev/null +++ b/packages/console/core/migrations/meta/0017_snapshot.json @@ -0,0 +1,657 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "100a21cf-ff9c-476f-bf7d-100c1824b2b2", + "prevId": "45b67fb4-77ce-4aa2-b883-1971429c69f5", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email": { + "name": "email", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor": { + "name": "actor", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "old_name": { + "name": "old_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + }, + "name": { + "name": "name", + "columns": ["workspace_id", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index b797122d7..d742bac89 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1758663086739, "tag": "0016_cold_la_nuit", "breakpoints": true + }, + { + "idx": 17, + "version": "5", + "when": 1758755183232, + "tag": "0017_woozy_thaddeus_ross", + "breakpoints": true } ] } diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index dda0c539f..4267d3b94 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -10,6 +10,8 @@ import { Identifier } from "./identifier" import { centsToMicroCents } from "./util/price" export namespace Billing { + export const CHARGE_NAME = "opencode credits" + export const CHARGE_FEE_NAME = "processing fee" export const CHARGE_AMOUNT = 2000 // $20 export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30 export const CHARGE_THRESHOLD = 500 // $5 @@ -73,22 +75,39 @@ export namespace Billing { .then((rows) => rows[0]), ) const paymentID = Identifier.create("payment") - let charge + let invoice try { - charge = await Billing.stripe().paymentIntents.create( - { - amount: Billing.CHARGE_AMOUNT + Billing.CHARGE_FEE, - currency: "usd", - customer: customerID!, - payment_method: paymentMethodID!, - off_session: true, - confirm: true, - }, - { idempotencyKey: paymentID }, - ) - - if (charge.status !== "succeeded") throw new Error(charge.last_payment_error?.message) + const draft = await Billing.stripe().invoices.create({ + customer: customerID!, + auto_advance: false, + default_payment_method: paymentMethodID!, + collection_method: "charge_automatically", + currency: "usd", + }) + await Billing.stripe().invoiceItems.create({ + amount: Billing.CHARGE_AMOUNT, + currency: "usd", + customer: customerID!, + description: CHARGE_NAME, + invoice: draft.id!, + }) + await Billing.stripe().invoiceItems.create({ + amount: Billing.CHARGE_FEE, + currency: "usd", + customer: customerID!, + description: CHARGE_FEE_NAME, + invoice: draft.id!, + }) + await Billing.stripe().invoices.finalizeInvoice(draft.id!) + invoice = await Billing.stripe().invoices.pay(draft.id!, { + off_session: true, + payment_method: paymentMethodID!, + expand: ["payments"], + }) + if (invoice.status !== "paid" || invoice.payments?.data.length !== 1) + throw new Error(invoice.last_finalization_error?.message) } catch (e: any) { + console.error(e) await Database.use((tx) => tx .update(BillingTable) @@ -114,7 +133,8 @@ export namespace Billing { workspaceID: Actor.workspace(), id: paymentID, amount: centsToMicroCents(CHARGE_AMOUNT), - paymentID: charge.id, + invoiceID: invoice.id!, + paymentID: invoice.payments?.data[0].payment.payment_intent as string, customerID, }) }) @@ -155,12 +175,13 @@ export namespace Billing { const customer = await Billing.get() const session = await Billing.stripe().checkout.sessions.create({ mode: "payment", + billing_address_collection: "required", line_items: [ { price_data: { currency: "usd", product_data: { - name: "opencode credits", + name: CHARGE_NAME, }, unit_amount: CHARGE_AMOUNT, }, @@ -170,16 +191,13 @@ export namespace Billing { price_data: { currency: "usd", product_data: { - name: "processing fee", + name: CHARGE_FEE_NAME, }, unit_amount: CHARGE_FEE, }, quantity: 1, }, ], - payment_intent_data: { - setup_future_usage: "on_session", - }, ...(customer.customerID ? { customer: customer.customerID, @@ -192,6 +210,12 @@ export namespace Billing { workspaceID: Actor.workspace(), }, currency: "usd", + invoice_creation: { + enabled: true, + }, + payment_intent_data: { + setup_future_usage: "on_session", + }, payment_method_types: ["card"], payment_method_data: { allow_redisplay: "always", diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index f7da84ab7..302e01133 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -28,6 +28,7 @@ export const PaymentTable = mysqlTable( ...workspaceColumns, ...timestamps, customerID: varchar("customer_id", { length: 255 }), + invoiceID: varchar("invoice_id", { length: 255 }), paymentID: varchar("payment_id", { length: 255 }), amount: bigint("amount", { mode: "number" }).notNull(), timeRefunded: utc("time_refunded"), diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts new file mode 100644 index 000000000..119a8c789 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -0,0 +1,224 @@ +import { Global } from "../../global" +import { Provider } from "../../provider/provider" +import { Server } from "../../server/server" +import { UI } from "../ui" +import { cmd } from "./cmd" +import path from "path" +import fs from "fs/promises" +import { Installation } from "../../installation" +import { Config } from "../../config/config" +import { Bus } from "../../bus" +import { Log } from "../../util/log" +import { Ide } from "../../ide" + +import { Flag } from "../../flag/flag" +import { Session } from "../../session" +import { $ } from "bun" +import { bootstrap } from "../bootstrap" + +declare global { + const OPENCODE_TUI_PATH: string +} + +if (typeof OPENCODE_TUI_PATH !== "undefined") { + await import(OPENCODE_TUI_PATH as string, { + with: { type: "file" }, + }) +} + +export const TuiCommand = cmd({ + command: "$0 [project]", + describe: "start opencode tui", + builder: (yargs) => + yargs + .positional("project", { + type: "string", + describe: "path to start opencode in", + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("continue", { + alias: ["c"], + describe: "continue the last session", + type: "boolean", + }) + .option("session", { + alias: ["s"], + describe: "session id to continue", + type: "string", + }) + .option("prompt", { + alias: ["p"], + type: "string", + describe: "prompt to use", + }) + .option("agent", { + type: "string", + describe: "agent to use", + }) + .option("port", { + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + alias: ["h"], + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", + }), + handler: async (args) => { + while (true) { + const cwd = args.project ? path.resolve(args.project) : process.cwd() + try { + process.chdir(cwd) + } catch (e) { + UI.error("Failed to change directory to " + cwd) + return + } + const result = await bootstrap(cwd, async () => { + const sessionID = await (async () => { + if (args.continue) { + const it = Session.list() + try { + for await (const s of it) { + if (s.parentID === undefined) { + return s.id + } + } + return + } finally { + await it.return() + } + } + if (args.session) { + return args.session + } + return undefined + })() + const providers = await Provider.list() + if (Object.keys(providers).length === 0) { + return "needs_provider" + } + + const server = Server.listen({ + port: args.port, + hostname: args.hostname, + }) + + let cmd = [] as string[] + const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File + if (tui) { + let binaryName = tui.name + if (process.platform === "win32" && !binaryName.endsWith(".exe")) { + binaryName += ".exe" + } + const binary = path.join(Global.Path.cache, "tui", binaryName) + const file = Bun.file(binary) + if (!(await file.exists())) { + await Bun.write(file, tui, { mode: 0o755 }) + if (process.platform !== "win32") await fs.chmod(binary, 0o755) + } + cmd = [binary] + } + if (!tui) { + const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url)) + let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}` + await $`go build -o ${binaryName} ./main.go`.cwd(dir) + cmd = [path.join(dir, binaryName)] + } + Log.Default.info("tui", { + cmd, + }) + const proc = Bun.spawn({ + cmd: [ + ...cmd, + ...(args.model ? ["--model", args.model] : []), + ...(args.prompt ? ["--prompt", args.prompt] : []), + ...(args.agent ? ["--agent", args.agent] : []), + ...(sessionID ? ["--session", sessionID] : []), + ], + cwd, + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + env: { + ...process.env, + CGO_ENABLED: "0", + OPENCODE_SERVER: server.url.toString(), + }, + onExit: () => { + server.stop() + }, + }) + + ;(async () => { + if (Installation.isDev()) return + if (Installation.isSnapshot()) return + const config = await Config.global() + if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return + const latest = await Installation.latest().catch(() => {}) + if (!latest) return + if (Installation.VERSION === latest) return + const method = await Installation.method() + if (method === "unknown") return + await Installation.upgrade(method, latest) + .then(() => Bus.publish(Installation.Event.Updated, { version: latest })) + .catch(() => {}) + })() + ;(async () => { + if (Ide.alreadyInstalled()) return + const ide = Ide.ide() + if (ide === "unknown") return + await Ide.install(ide) + .then(() => Bus.publish(Ide.Event.Installed, { ide })) + .catch(() => {}) + })() + + await proc.exited + server.stop() + + return "done" + }) + if (result === "done") break + if (result === "needs_provider") { + UI.empty() + UI.println(UI.logo(" ")) + const result = await Bun.spawn({ + cmd: [...getOpencodeCommand(), "auth", "login"], + cwd: process.cwd(), + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }).exited + if (result !== 0) return + UI.empty() + } + } + }, +}) + +/** + * Get the correct command to run opencode CLI + * In development: ["bun", "run", "packages/opencode/src/index.ts"] + * In production: ["/path/to/opencode"] + */ +function getOpencodeCommand(): string[] { + // Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts) + if (process.env["OPENCODE_BIN_PATH"]) { + return [process.env["OPENCODE_BIN_PATH"]] + } + + const execPath = process.execPath.toLowerCase() + + if (Installation.isDev()) { + // In development, use bun to run the TypeScript entry point + return [execPath, "run", process.argv[1]] + } + + // In production, use the current executable path + return [process.execPath] +} diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 53e2003b9..912f2159e 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -6,9 +6,6 @@ export namespace FileIgnore { "**/.pnpm-store/**", "**/vendor/**", - // vcs - "**/.git/**", - // Build outputs "**/dist/**", "**/build/**", @@ -50,8 +47,12 @@ export namespace FileIgnore { filepath: string, opts: { extra?: Bun.Glob[] + whitelist?: Bun.Glob[] }, ) { + for (const glob of opts.whitelist || []) { + if (glob.match(filepath)) return false + } const extra = opts.extra || [] for (const glob of [...GLOBS, ...extra]) { if (glob.match(filepath)) return true diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 84a80c0fb..652cb7853 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -30,6 +30,7 @@ export namespace FileWatcher { ignoreInitial: true, ignored: (filepath) => { return FileIgnore.match(filepath, { + whitelist: [new Bun.Glob("**/.git/{index,logs/HEAD}")], extra: ignore, }) }, diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 932c57ad6..c1bc73c7c 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -956,13 +956,17 @@ func (a Model) home() (string, int, int) { muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render open := ` -█▀▀█ █▀▀█ █▀▀ █▀▀▄ -█░░█ █░░█ █▀▀ █░░█ -▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ` + +█▀▀█ █▀▀█ █▀▀█ █▀▀▄ +█░░█ █░░█ █▀▀▀ █░░█ +▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀` + code := ` -█▀▀ █▀▀█ █▀▀▄ █▀▀ -█░░ █░░█ █░░█ █▀▀ -▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀` + ▄ +█▀▀▀ █▀▀█ █▀▀█ █▀▀█ +█░░░ █░░█ █░░█ █▀▀▀ +▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀` + logo := lipgloss.JoinHorizontal( lipgloss.Top,