Files

1 line
22 KiB
JSON

{"repo": "accsci/agentsurfer", "files": {"entrypoints/options/App.tsx": {"f5a57dca2a4f201846ef155a0bc3f93fe353261d": {"t": 1785595219.8035371, "n": 21021, "h": "7151673a06839878", "c": "import { useLiveQuery } from 'dexie-react-hooks';\nimport { Plus, Trash2, Star, CheckCircle2, AlertCircle, Pencil, X } from 'lucide-react';\nimport { useState } from 'react';\n\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';\nimport { Separator } from '@/components/ui/separator';\nimport { db, newId, now } from '@/lib/db';\nimport { useChangeCount } from '@/lib/use-change-count';\nimport { sendToSW } from '@/lib/sw-messenger';\nimport { KnownModels } from '@/lib/llm';\nimport { ToolConfigPanel } from './components/ToolConfigPanel';\nimport {\n DefaultBaseUrl,\n DefaultModelId,\n DEFAULT_REASONING_EFFORT,\n ProviderMeta,\n ProviderSchema,\n REASONING_EFFORT_VALUES,\n type Provider,\n type ReasoningEffort,\n} from '@/types';\nimport { ModelConfigSchema } from '@/types';\n\nasync function dbMsg(message: { type: string; [k: string]: unknown }): Promise<void> {\n const res = await sendToSW(message);\n if (!res.ok) throw new Error(res.error ?? 'db message failed');\n}\n\nexport default function OptionsApp() {\n const configChangeCount = useChangeCount('modelConfigs');\n const configs = useLiveQuery(\n () => db.modelConfigs.orderBy('createdAt').toArray(),\n [configChangeCount],\n [],\n );\n\n return (\n <div className=\"mx-auto max-w-3xl p-6\">\n <header className=\"mb-6 flex items-center justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">AgentSurfer</h1>\n <p className=\"text-sm text-muted-foreground\">Model providers and API keys</p>\n </div>\n <Badge variant=\"outline\" className=\"text-xs\">v0.1</Badge>\n </header>\n\n <ConfigForm />\n\n <Separator className=\"my-6\" />\n\n <section>\n <h2 className=\"mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n Saved configurations\n </h2>\n <ScrollArea className=\"h-[400px] rounded-md border\">\n <div className=\"space-y-2 p-2\">\n {configs?.length === 0 && (\n <p className=\"p-4 text-sm text-muted-foreground\">\n No configurations yet. Add one above to get started.\n </p>\n )}\n {configs?.map((c) => (\n <ConfigRow key={c.id} config={c} />\n ))}\n </div>\n </ScrollArea>\n </section>\n\n <ToolConfigPanel />\n\n <p className=\"mt-6 text-xs text-muted-foreground\">\n API keys are stored locally in this browser via IndexedDB. They never leave your machine\n except to call the provider you configured.\n </p>\n </div>\n );\n}\n\n// ---------- Form: add / edit a config ----------\n\nfunction ConfigForm() {\n const [name, setName] = useState('');\n const [provider, setProvider] = useState<Provider>('openai');\n const [modelId, setModelId] = useState(DefaultModelId.openai);\n const [apiKey, setApiKey] = useState('');\n const [baseUrl, setBaseUrl] = useState('');\n const [maxSteps, setMaxSteps] = useState<number>(99);\n // Reasoning effort — surfaced in the form for StepFun. Other providers\n // silently ignore it (lib/runtime/loop.ts only spreads `providerOptions`\n // when this is set). Reset to DEFAULT_REASONING_EFFORT on provider\n // switch so users don't carry a stale value across providers.\n const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(DEFAULT_REASONING_EFFORT);\n const [makeDefault, setMakeDefault] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [ok, setOk] = useState(false);\n\n const meta = ProviderMeta[provider];\n const needsBaseUrl = meta.needsBaseUrl;\n const knownModels = KnownModels[provider] ?? [];\n\n async function submit(e: React.FormEvent) {\n e.preventDefault();\n setError(null);\n setOk(false);\n\n const candidate = {\n id: newId(),\n name: name.trim() || `${meta.label} (${modelId})`,\n provider,\n modelId: modelId.trim() || DefaultModelId[provider],\n apiKey: apiKey.trim(),\n baseUrl: baseUrl.trim() || null,\n isDefault: makeDefault,\n // Coerce to number explicitly — <input type=\"number\"> returns\n // \"\" when cleared, which Zod rejects; clamp to schema range\n // and let ModelConfigSchema re-validate the final value.\n maxSteps: Number.isFinite(maxSteps) ? maxSteps : 99,\n // Always store reasoningEffort for new configs — the value is\n // provider-agnostic (StepFun honors it; others ignore it via\n // the loop's conditional spread). This avoids schema-vs-form\n // drift when a user re-edits the same config after switching\n // providers.\n reasoningEffort,\n createdAt: now(),\n };\n const parsed = ModelConfigSchema.safeParse(candidate);\n if (!parsed.success) {\n setError(parsed.error.issues[0]?.message ?? 'Invalid configuration');\n return;\n }\n try {\n await dbMsg({ type: 'db:upsert-config', config: parsed.data });\n if (makeDefault) await dbMsg({ type: 'db:set-active-config', id: parsed.data.id });\n // Reset form\n setName('');\n setApiKey('');\n setBaseUrl('');\n setMaxSteps(99);\n setReasoningEffort(DEFAULT_REASONING_EFFORT);\n setOk(true);\n setTimeout(() => setOk(false), 2000);\n } catch (err) {\n setError(err instanceof Error ? err.message : String(err));\n }\n }\n\n return (\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Plus className=\"h-4 w-4\" /> Add model configuration\n </CardTitle>\n <CardDescription>\n Pick a provider, enter your API key, and (optionally) make it the default.\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={submit} className=\"space-y-3\">\n <div className=\"grid grid-cols-2 gap-3\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"provider\">Provider</Label>\n <Select\n value={provider}\n onValueChange={(v) => {\n const next = ProviderSchema.parse(v);\n setProvider(next);\n setModelId(DefaultModelId[next]);\n // Reset reasoning effort to the default on provider\n // change so a stale value from a previous provider\n // doesn't leak into the new config. The field is\n // still saved (other providers ignore it) but the\n // initial UI value reflects the recommended default.\n setReasoningEffort(DEFAULT_REASONING_EFFORT);\n }}\n >\n <SelectTrigger id=\"provider\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {ProviderSchema.options.map((p) => (\n <SelectItem key={p} value={p}>\n {ProviderMeta[p].label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"model\">Model</Label>\n <Input\n id=\"model\"\n value={modelId}\n onChange={(e) => setModelId(e.target.value)}\n placeholder={DefaultModelId[provider]}\n list=\"known-models\"\n />\n {knownModels.length > 0 && (\n <datalist id=\"known-models\">\n {knownModels.map((m: string) => (\n <option key={m} value={m} />\n ))}\n </datalist>\n )}\n </div>\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"name\">Display name (optional)</Label>\n <Input\n id=\"name\"\n value={name}\n onChange={(e) => setName(e.target.value)}\n placeholder={`${meta.label} · ${modelId}`}\n />\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"key\">API key</Label>\n <Input\n id=\"key\"\n type=\"password\"\n value={apiKey}\n onChange={(e) => setApiKey(e.target.value)}\n placeholder={\n meta.authHeader === 'api-key'\n ? 'MIMO_API_KEY'\n : meta.authHeader === 'x-api-key'\n ? 'sk-ant-...'\n : 'sk-...'\n }\n autoComplete=\"off\"\n />\n <p className=\"text-xs text-muted-foreground\">\n Auth header: <code>{meta.authHeader}</code>\n {DefaultBaseUrl[provider] && (\n <>\n {' · '}default URL: <code>{DefaultBaseUrl[provider]}</code>\n </>\n )}\n </p>\n </div>\n\n {needsBaseUrl && (\n <div className=\"space-y-1\">\n <Label htmlFor=\"baseurl\">Base URL</Label>\n <Input\n id=\"baseurl\"\n value={baseUrl}\n onChange={(e) => setBaseUrl(e.target.value)}\n placeholder=\"https://api.example.com/v1\"\n />\n </div>\n )}\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"maxsteps\">Max steps per run</Label>\n <Input\n id=\"maxsteps\"\n type=\"number\"\n min={1}\n max={999}\n step={1}\n value={maxSteps}\n onChange={(e) => {\n // <input type=\"number\"> yields '' when the user clears\n // the field. Treat that as \"leave it alone for now\" and\n // let Zod catch any out-of-range value on submit.\n const n = e.target.value === '' ? 99 : Number.parseInt(e.target.value, 10);\n setMaxSteps(Number.isFinite(n) ? n : 99);\n }}\n placeholder=\"99\"\n />\n <p className=\"text-xs text-muted-foreground\">\n Cap on how many tool-call steps the agent can take in a single run\n (1-999, default 99). Raise for long multi-step tasks like\n \"search → click N results → summarize\".\n </p>\n </div>\n\n {/* Reasoning effort — exposed for StepFun's step-3.7-flash. Other\n providers silently ignore it (see lib/runtime/loop.ts). We\n keep the field visible for all providers so users have one\n mental model; the loop only forwards it to OpenAI-compat\n when set. step-3.5-flash-2603 supports low/high only —\n selecting `medium` for that model will return a 400 from\n the API. */}\n <div className=\"space-y-1\">\n <Label htmlFor=\"reasoning\">Reasoning effort</Label>\n <Select\n value={reasoningEffort}\n onValueChange={(v) => {\n // Cast: Zod-enum produces ReasoningEffort; the Select\n // value comes from REASONING_EFFORT_VALUES which is the\n // exact tuple Zod was built from, so this is safe.\n setReasoningEffort(v as ReasoningEffort);\n }}\n >\n <SelectTrigger id=\"reasoning\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {REASONING_EFFORT_VALUES.map((v) => (\n <SelectItem key={v} value={v}>\n {v}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className=\"text-xs text-muted-foreground\">\n <code>low</code> 简单问答/摘要 · <code>medium</code> 默认推荐 ·\n <code>high</code> 复杂推理/代码. Honored by StepFun step-3.7-flash;\n ignored by other providers.\n </p>\n </div>\n\n <label className=\"flex items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={makeDefault}\n onChange={(e) => setMakeDefault(e.target.checked)}\n className=\"h-4 w-4\"\n />\n Set as default\n </label>\n\n {error && (\n <p className=\"flex items-center gap-1 text-sm text-destructive\">\n <AlertCircle className=\"h-3.5 w-3.5\" /> {error}\n </p>\n )}\n {ok && (\n <p className=\"flex items-center gap-1 text-sm text-emerald-600 dark:text-emerald-400\">\n <CheckCircle2 className=\"h-3.5 w-3.5\" /> Saved\n </p>\n )}\n\n <Button type=\"submit\" className=\"w-full\">\n Save configuration\n </Button>\n </form>\n </CardContent>\n </Card>\n );\n}\n\n// ---------- Row: saved config ----------\n\nfunction ConfigRow({ config }: { config: import('@/types').ModelConfig }) {\n const [editing, setEditing] = useState(false);\n\n if (editing) {\n return <ConfigEditRow config={config} onClose={() => setEditing(false)} />;\n }\n\n return (\n <Card className={config.isDefault ? 'border-primary/50 bg-primary/5' : ''}>\n <CardContent className=\"flex items-center justify-between p-3\">\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-center gap-2\">\n <span className=\"truncate text-sm font-medium\">{config.name}</span>\n {config.isDefault && (\n <Badge variant=\"default\" className=\"text-[10px]\">\n <Star className=\"mr-1 h-3 w-3\" /> active\n </Badge>\n )}\n </div>\n <p className=\"truncate text-xs text-muted-foreground\">\n {ProviderMeta[config.provider].label} · <code>{config.modelId}</code>\n {config.baseUrl && (\n <>\n {' · '}\n <code>{config.baseUrl}</code>\n </>\n )}\n {' · max steps: '}\n <code>{config.maxSteps ?? 99}</code>\n </p>\n </div>\n <div className=\"flex shrink-0 items-center gap-1\">\n {!config.isDefault && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => dbMsg({ type: 'db:set-active-config', id: config.id })}\n title=\"Set as default\"\n >\n <Star className=\"h-3.5 w-3.5\" />\n </Button>\n )}\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditing(true)}\n title=\"Edit\"\n >\n <Pencil className=\"h-3.5 w-3.5\" />\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => dbMsg({ type: 'db:delete-config', id: config.id })}\n title=\"Delete\"\n >\n <Trash2 className=\"h-3.5 w-3.5 text-destructive\" />\n </Button>\n </div>\n </CardContent>\n </Card>\n );\n}\n\n// ---------- Row: inline-edit an existing config ----------\n\nfunction ConfigEditRow({\n config,\n onClose,\n}: {\n config: import('@/types').ModelConfig;\n onClose: () => void;\n}) {\n const meta = ProviderMeta[config.provider];\n const knownModels = KnownModels[config.provider] ?? [];\n\n const [name, setName] = useState(config.name);\n const [modelId, setModelId] = useState(config.modelId);\n const [apiKey, setApiKey] = useState(config.apiKey);\n const [baseUrl, setBaseUrl] = useState(config.baseUrl ?? '');\n const [maxSteps, setMaxSteps] = useState<number>(config.maxSteps ?? 99);\n const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(\n config.reasoningEffort ?? DEFAULT_REASONING_EFFORT,\n );\n const [error, setError] = useState<string | null>(null);\n const [saving, setSaving] = useState(false);\n\n async function save() {\n setError(null);\n // Re-validate against the same schema the add-form uses. Preserve the\n // config's id/provider/isDefault/createdAt so this is an update, not a\n // new row — `db:upsert-config` is a put keyed on `id`.\n const candidate = {\n ...config,\n name: name.trim() || `${meta.label} (${modelId})`,\n modelId: modelId.trim() || config.modelId,\n apiKey: apiKey.trim(),\n baseUrl: baseUrl.trim() || null,\n maxSteps: Number.isFinite(maxSteps) ? maxSteps : 99,\n reasoningEffort,\n };\n const parsed = ModelConfigSchema.safeParse(candidate);\n if (!parsed.success) {\n setError(parsed.error.issues[0]?.message ?? 'Invalid configuration');\n return;\n }\n setSaving(true);\n try {\n await dbMsg({ type: 'db:upsert-config', config: parsed.data });\n onClose();\n } catch (err) {\n setError(err instanceof Error ? err.message : String(err));\n setSaving(false);\n }\n }\n\n return (\n <Card className={config.isDefault ? 'border-primary/50 bg-primary/5' : ''}>\n <CardContent className=\"space-y-3 p-3\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n Editing · {meta.label}\n </span>\n <Button size=\"sm\" variant=\"ghost\" onClick={onClose} title=\"Cancel\">\n <X className=\"h-3.5 w-3.5\" />\n </Button>\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor={`edit-name-${config.id}`}>Display name</Label>\n <Input\n id={`edit-name-${config.id}`}\n value={name}\n onChange={(e) => setName(e.target.value)}\n placeholder={`${meta.label} · ${modelId}`}\n />\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor={`edit-model-${config.id}`}>Model</Label>\n <Input\n id={`edit-model-${config.id}`}\n value={modelId}\n onChange={(e) => setModelId(e.target.value)}\n list={`edit-known-models-${config.id}`}\n />\n {knownModels.length > 0 && (\n <datalist id={`edit-known-models-${config.id}`}>\n {knownModels.map((m: string) => (\n <option key={m} value={m} />\n ))}\n </datalist>\n )}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor={`edit-key-${config.id}`}>API key</Label>\n <Input\n id={`edit-key-${config.id}`}\n type=\"password\"\n value={apiKey}\n onChange={(e) => setApiKey(e.target.value)}\n autoComplete=\"off\"\n />\n </div>\n\n {meta.needsBaseUrl && (\n <div className=\"space-y-1\">\n <Label htmlFor={`edit-baseurl-${config.id}`}>Base URL</Label>\n <Input\n id={`edit-baseurl-${config.id}`}\n value={baseUrl}\n onChange={(e) => setBaseUrl(e.target.value)}\n placeholder=\"https://api.example.com/v1\"\n />\n </div>\n )}\n\n <div className=\"space-y-1\">\n <Label htmlFor={`edit-maxsteps-${config.id}`}>Max steps per run</Label>\n <Input\n id={`edit-maxsteps-${config.id}`}\n type=\"number\"\n min={1}\n max={999}\n step={1}\n value={maxSteps}\n onChange={(e) => {\n const n = e.target.value === '' ? 99 : Number.parseInt(e.target.value, 10);\n setMaxSteps(Number.isFinite(n) ? n : 99);\n }}\n placeholder=\"99\"\n />\n <p className=\"text-xs text-muted-foreground\">\n 一次回复中 agent 最多允许的工具调用步数 (1-999, 默认 99).\n </p>\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor={`edit-reasoning-${config.id}`}>Reasoning effort</Label>\n <Select\n value={reasoningEffort}\n onValueChange={(v) => setReasoningEffort(v as ReasoningEffort)}\n >\n <SelectTrigger id={`edit-reasoning-${config.id}`}>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {REASONING_EFFORT_VALUES.map((v) => (\n <SelectItem key={v} value={v}>\n {v}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {error && (\n <p className=\"flex items-center gap-1 text-sm text-destructive\">\n <AlertCircle className=\"h-3.5 w-3.5\" /> {error}\n </p>\n )}\n\n <div className=\"flex gap-2\">\n <Button type=\"button\" className=\"flex-1\" onClick={save} disabled={saving}>\n {saving ? 'Saving…' : 'Save changes'}\n </Button>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={saving}>\n Cancel\n </Button>\n </div>\n </CardContent>\n </Card>\n );\n}\n"}}}}