Skip to content

Commit

Permalink
more controls, better querying/components
Browse files Browse the repository at this point in the history
  • Loading branch information
ryan-williams committed Aug 31, 2024
1 parent e2b1fa2 commit 14f90bd
Show file tree
Hide file tree
Showing 9 changed files with 866 additions and 224 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Client-side Claude Client
A Claude client on a static webpage (with optional API key, which doesn't leave the browser tab).
A [Claude] client on a static webpage (with optional API key, which doesn't leave the browser tab).

Inspired by [Claude’s API now supports CORS requests, enabling client-side applications].

[Claude’s API now supports CORS requests, enabling client-side applications]: https://simonwillison.net/2024/Aug/23/anthropic-dangerous-direct-browser-access/

[Claude]: https://claude.ai/
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "db-chat-vite",
"name": "claude-client",
"private": true,
"version": "0.0.0",
"type": "module",
Expand All @@ -11,10 +11,13 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.27.1",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/material": "^6.0.1",
"@rdub/base": "0.4.0",
"@tanstack/react-query": "^5.53.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-query": "^3.39.3",
"react-router-dom": "^6.26.1",
"use-local-storage-state": "^19.4.0"
},
"devDependencies": {
Expand Down
726 changes: 561 additions & 165 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
border: 1px solid grey;
}

.prompt {
padding: 0.8em;
}

.section {
margin-bottom: 1em;
}

.message {
.lines {
margin-bottom: 1em;
Expand All @@ -26,3 +34,43 @@
margin: 1em;
}
}

.tooltipArrow {
}

.menu {
text-align: center;
p {
margin: 0.1em;
}
a {
color: white;
text-decoration: underline;
transition: all 0.3s ease;
&:hover {
color: white;
text-shadow: 0 0 1px white;
}
}
}

.tooltip {
background-color: pink;
}

label > span {
margin-right: 0.5em;
}

.model {
max-width: 20em;
}
.max-tokens {
max-width: 6em;
text-align: right;
}

.temperature {
max-width: 4em;
text-align: right;
}
237 changes: 183 additions & 54 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,108 @@
import { FormEvent, useEffect, useState, KeyboardEvent, } from 'react'
import { FormEvent, HTMLProps, KeyboardEvent, useCallback, useEffect, useState, } from 'react'
import './App.css'
import Anthropic from "@anthropic-ai/sdk"
import useLocalStorageState from "use-local-storage-state"
import { useQuery } from "react-query"
import { useQuery } from "@tanstack/react-query"

import { State } from "@rdub/base/state";
import Tooltip from "./Tooltip.tsx";
import GitHub from "./Github.tsx";
import A from "@rdub/base/a";

type Message = Anthropic.Message;

const TokenKey = "anthropic-token"
const PromptKey = "anthropic-prompt"
const SystemPromptKey = "anthropic-system-prompt"
const ModelKey = "anthropic-model"

const DefaultPrompt = "What is a Claude?"
const DefaultSystemPrompt = "Respond only with short poems."
const DefaultModel = "claude-3-5-sonnet-20240620"
const MaxTokensKey = "anthropic-max-tokens"
const DefaultMaxTokens = 100
const TemperatureKey = "anthropic-temperature"
const DefaultTemperature = 1

const H = 'h3'

function Div({ children, ...props }: HTMLProps<HTMLDivElement>) {
return <div className={"section"} {...props}>{children}</div>
}

type Input<T = string> = State<T, "val"> & State<T, "saved">

function useInput<T = string>({ k, init }: { k: string, init: T }): Input<T> {
const [ val, setVal ] = useLocalStorageState(k, { defaultValue: init })
const [ saved, setSaved ] = useState(val)
return { val, setVal, saved, setSaved, }
}

function Prompt(
{
k, title,
input: { val, setVal, setSaved, },
handleSubmit,
cols = 50, rows = 5,
...props
}: {
k: string
title: string
input: Input
handleSubmit?: () => void
init?: string
} & HTMLProps<HTMLTextAreaElement>
) {
const handleKeyPress = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
setSaved(val)
if (handleSubmit) {
handleSubmit()
}
}
},
[ val, setSaved, ]
)
return (
<Div>
<label>
<H>{title}</H>
<textarea
className={"prompt"}
rows={rows}
cols={cols}
{...props}
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={handleKeyPress}
/>
</label>
</Div>
)
}

function Num({ label, val, setVal, ...props }: State<number, "val"> & { label: string } & HTMLProps<HTMLInputElement>) {
return (
<Div>
<label>
<span>{label}:</span>
<input
type="number"
{...props}
value={val}
onChange={e => setVal(parseInt(e.target.value))}
/>
</label>
</Div>
)
}

function Message(msg: Message) {
const { content } = msg
return <div className={"message"}>
<h2>Response:</h2>
<H>Response:</H>
{
content.map((block, idx) =>
block.type === "text" ?
Expand All @@ -27,86 +118,124 @@ function Message(msg: Message) {
</details>
</div>
}

function Token({ token, setToken }: State<string, "token">) {
return <Div>
<Tooltip
// open
title={<div>
<p>Only stored in browser <code>localStorage.</code></p>
<a href={"https://console.anthropic.com/dashboard"} target={"_blank"} rel={"noreferrer"}>
Get a token from the Anthropic dashboard.
</a>
</div>}
className={"menu"}
>
<label htmlFor={"token"}>
<span>Token:</span>
</label>
</Tooltip>
<input
id={"token"}
type="password"
value={token ?? ""}
onChange={(e) => setToken(e.target.value)}
/>
</Div>
}

function Submit({ disabled, ...props }: Omit<HTMLProps<HTMLButtonElement>, "type">) {
const button = <button type="submit" disabled={disabled} {...props}>Submit</button>
return <Div>{
disabled ? <Tooltip title={"Prompt, system prompt, and token required to submit."}><span>{button}</span></Tooltip> : button
}</Div>
}

function App() {
const [ token, setToken ] = useLocalStorageState<string | null>(TokenKey, { defaultValue: null })
const [ token, setToken ] = useLocalStorageState<string>(TokenKey, { defaultValue: "" })
const [ anthropic, setAnthropic ] = useState<Anthropic | null>(null)
const [ prompt, setPrompt ] = useLocalStorageState(PromptKey, { defaultValue: "" })
const [ submitPrompt, setSubmitPrompt ] = useState("")

const prompt = useInput({ k: PromptKey, init: DefaultPrompt, })
const system = useInput({ k: SystemPromptKey, init: DefaultSystemPrompt })
const tokens = useInput({ k: MaxTokensKey, init: DefaultMaxTokens })
const model = useInput({ k: ModelKey, init: DefaultModel })
const temperature = useInput({ k: TemperatureKey, init: DefaultTemperature })
const [ nonce, setNonce ] = useState(0)
useEffect(() => {
if (!token) {
setAnthropic(null)
return
}
const anthropic = new Anthropic({ apiKey: token, dangerouslyAllowBrowser: true })
setAnthropic(anthropic)
}, [token])

const response = useQuery({
queryKey: ['query', token, submitPrompt],
queryKey: [ 'query', anthropic?.apiKey, prompt.saved, system.saved, model.saved, tokens.saved, temperature.saved, nonce, ],
queryFn: async () => {
if (!anthropic || !submitPrompt) {
return
if (!anthropic || !prompt.saved || !system.saved) {
return null
}
const message = await anthropic.messages.create({
messages: [{ content: submitPrompt, role: "user" }],
model: "claude-3-5-sonnet-20240620",
system: "Respond only with short poems.",
max_tokens: 100,
temperature: 0,
messages: [{ content: prompt.saved, role: "user" }],
model: model.saved,
system: system.saved,
max_tokens: tokens.saved,
temperature: temperature.saved,
})
console.log("anthropic response", message)
return message
},
})
const { data, refetch, isLoading, isError, error } = response
console.log("response", response)

const handleSubmit = (e?: FormEvent<HTMLFormElement>) => {
e?.preventDefault()
setSubmitPrompt(prompt)
refetch()
console.log("submitting prompt", prompt)
}

const handleKeyPress = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleSubmit()
}
}
// console.log("response", response)

const handleSubmit = useCallback(
(e?: FormEvent<HTMLFormElement>) => {
e?.preventDefault();
([ prompt, system, tokens, model, temperature ] as Input<any>[]).forEach(({ saved, val, setSaved }) => {
if (saved !== val) {
setSaved(val)
}
})
setNonce(prevNonce => prevNonce + 1)
console.log("handleSubmit:", { prompt, system, tokens, model, temperature, nonce, })
},
[ refetch, prompt, system, tokens, model, temperature, nonce, setNonce, ]
)

return (
<div>
<h1>Claude client</h1>
<h2>Client-only interface to <A href={"https://claude.ai/"}>Claude</A></h2>
<form onSubmit={handleSubmit}>
<div>
<label>
<h2>Query:</h2>
<textarea
cols={50}
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyPress}
/>
</label>
</div>
<div>
<button type="submit">Submit</button>
</div>
<div>
<Prompt handleSubmit={handleSubmit} title={"Prompt"} k={PromptKey} input={prompt} />
<Prompt handleSubmit={handleSubmit} title={"System prompt"} k={SystemPromptKey} input={system} rows={2} />
<Submit disabled={!token || !prompt.saved || !system.saved} />
<Div>
{isLoading && <p>Loading...</p>}
{isError && <p>Error: {(error as Error).message}</p>}
{data && <Message {...data} />}
</div>
<div>
</Div>
<Token token={token} setToken={val => {
setToken(val)
setNonce(prevNonce => prevNonce + 1)
}} />
<Div>
<label>
Token:
<span>Model:</span>
<input
type="password"
value={token ?? ""}
onChange={(e) => setToken(e.target.value)}
type="text"
className={"model"}
value={model.val}
onChange={(e) => model.setVal(e.target.value)}
/>
</label>
</div>
</Div>
<Num label={"Max Tokens"} className={"max-tokens"} val={tokens.val} setVal={tokens.setVal} />
<Num label={"Temperature"} className={"temperature"} val={temperature.val} setVal={temperature.setVal} />
</form>
<Div>
<GitHub w={40} />
</Div>
</div>
)
}
Expand Down
Loading

0 comments on commit 14f90bd

Please sign in to comment.