feat: 实现基本功能

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
feie9456 2026-04-26 20:58:52 +08:00
parent 0b130f1d7f
commit 0e9e0c71b5
17 changed files with 14057 additions and 13 deletions

8631
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -16,10 +16,15 @@
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"katex": "^0.16.45",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shadcn": "^4.5.0", "shadcn": "^4.5.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
import * as React from "react"
import { Check, Copy } from "lucide-react"
import ReactMarkdown, { type Components } from "react-markdown"
import rehypeKatex from "rehype-katex"
import remarkGfm from "remark-gfm"
import remarkMath from "remark-math"
import "katex/dist/katex.min.css"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type MarkdownMessageProps = {
content: string
className?: string
}
function CopyCodeButton({ value }: { value: string }) {
const [copied, setCopied] = React.useState(false)
async function handleCopy() {
await navigator.clipboard.writeText(value)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
}
return (
<Button
type="button"
size="icon-xs"
variant="ghost"
title="复制代码"
onClick={handleCopy}
>
{copied ? <Check /> : <Copy />}
</Button>
)
}
function CodeBlock({ language, value }: { language: string; value: string }) {
return (
<div className="code-block my-3 overflow-hidden rounded-lg border bg-muted/40">
<div className="flex h-8 items-center justify-between border-b px-2.5 text-xs text-muted-foreground">
<span>{language || "code"}</span>
<CopyCodeButton value={value} />
</div>
<pre className="overflow-x-auto p-3 text-xs leading-6">
<code>{value}</code>
</pre>
</div>
)
}
const components: Components = {
a({ children, href, ...props }) {
return (
<a href={href} target="_blank" rel="noreferrer" {...props}>
{children}
</a>
)
},
code({ className, children, ...props }) {
const value = String(children).replace(/\n$/, "")
const match = /language-([\w-]+)/.exec(className ?? "")
if (match || value.includes("\n")) {
return <CodeBlock language={match?.[1] ?? ""} value={value} />
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
}
export function MarkdownMessage({ content, className }: MarkdownMessageProps) {
return (
<div className={cn("markdown-body", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={components}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@ -0,0 +1,27 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type BadgeProps = React.ComponentProps<"span"> & {
tone?: "default" | "muted" | "success" | "warning" | "danger"
}
function Badge({ className, tone = "default", ...props }: BadgeProps) {
return (
<span
data-slot="badge"
className={cn(
"inline-flex h-6 shrink-0 items-center rounded-full border px-2 text-xs font-medium",
tone === "default" && "border-border bg-background text-foreground",
tone === "muted" && "border-transparent bg-muted text-muted-foreground",
tone === "success" && "border-transparent bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
tone === "warning" && "border-transparent bg-amber-500/12 text-amber-700 dark:text-amber-300",
tone === "danger" && "border-transparent bg-destructive/12 text-destructive",
className
)}
{...props}
/>
)
}
export { Badge }

View File

@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"

View File

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Select({ className, ...props }: React.ComponentProps<"select">) {
return (
<select
data-slot="select"
className={cn(
"h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Select }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"min-h-20 w-full resize-y rounded-lg border border-input bg-background px-2.5 py-2 text-sm leading-6 outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -123,8 +123,227 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-size: calc(16px * var(--app-font-scale, 1));
} }
html { html {
@apply font-sans; @apply font-sans;
} }
}
.app-shell {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
height: 100svh;
overflow: hidden;
}
.sidebar {
display: flex;
min-height: 0;
flex-direction: column;
}
.main-panel {
display: flex;
min-height: 0;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.content-view {
min-height: 0;
flex: 1;
}
.chat-view {
display: flex;
flex-direction: column;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.app-shell-compact .messages-scroll {
padding-block: 0.75rem;
}
.app-shell-compact .message-bubble {
padding: 0.625rem 0.75rem;
}
.app-shell-compact .message-bubble-assistant {
padding: 0.25rem 0;
}
.message-bubble-user {
border-radius: 0.875rem;
background: #f3f3f3;
color: #171717;
box-shadow:rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2px -1px
}
.message-bubble-assistant {
border: 0;
background: transparent;
box-shadow: none;
}
.message-bubble-tool {
box-shadow: none;
}
.markdown-body {
color: inherit;
font-size: 0.925rem;
line-height: 1.7;
}
.markdown-body > :first-child {
margin-top: 0;
}
.markdown-body > :last-child {
margin-bottom: 0;
}
.markdown-body p,
.markdown-body ul,
.markdown-body ol,
.markdown-body blockquote,
.markdown-body table,
.markdown-body pre {
margin-block: 0.75rem;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--foreground);
font-weight: 650;
line-height: 1.25;
}
.markdown-body h1 {
font-size: 1.3rem;
}
.markdown-body h2 {
font-size: 1.15rem;
}
.markdown-body h3,
.markdown-body h4 {
font-size: 1rem;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 1.25rem;
}
.markdown-body ul {
list-style: disc;
}
.markdown-body ol {
list-style: decimal;
}
.markdown-body blockquote {
border-left: 3px solid var(--border);
color: var(--muted-foreground);
padding-left: 0.875rem;
}
.markdown-body a {
color: var(--foreground);
text-decoration: underline;
text-underline-offset: 3px;
}
.markdown-body :not(pre) > code {
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--muted);
padding: 0.1rem 0.3rem;
font-size: 0.85em;
}
.markdown-body table {
display: block;
width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 0.45rem 0.6rem;
text-align: left;
}
.markdown-body th {
background: var(--muted);
font-weight: 600;
}
.code-block pre {
margin: 0;
}
.katex-display {
overflow-x: auto;
overflow-y: hidden;
padding-block: 0.25rem;
}
@media (max-width: 900px) {
.app-shell {
grid-template-columns: minmax(0, 1fr);
height: auto;
min-height: 100svh;
overflow: visible;
}
.sidebar {
max-height: 42svh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.main-panel {
min-height: 58svh;
}
.topbar,
.chat-header {
align-items: stretch;
flex-direction: column;
}
.chat-header-actions {
width: 100%;
}
.chat-header-actions > button {
flex: 1 1 120px;
}
.message-bubble {
max-width: 100%;
}
} }

161
src/lib/default-data.ts Normal file
View File

@ -0,0 +1,161 @@
import type {
AppData,
AppSettings,
ChatMessage,
ChatSession,
ModelConfig,
ModelParameters,
PromptTemplate,
ServiceConfig,
} from "@/types"
export const APP_VERSION = "0.0.1"
export const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
export const DEFAULT_MODEL_PARAMETERS: ModelParameters = {
temperature: 1,
maxContextLength: 384_000,
maxOutputTokens: 16384,
stream: true,
timeoutSeconds: 120,
reasoningEffort: "default",
}
export function createId(prefix: string) {
const randomId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `${prefix}_${randomId}`
}
export function nowIso() {
return new Date().toISOString()
}
export function createMessage(
role: ChatMessage["role"],
content: string,
status: ChatMessage["status"] = "done"
): ChatMessage {
const timestamp = nowIso()
return {
id: createId("msg"),
role,
content,
createdAt: timestamp,
updatedAt: timestamp,
status,
}
}
export function createSession(title = "新会话"): ChatSession {
const timestamp = nowIso()
return {
id: createId("session"),
title,
createdAt: timestamp,
updatedAt: timestamp,
messages: [],
status: "idle",
}
}
export function createServiceConfig(): ServiceConfig {
return {
id: createId("service"),
name: "OpenAI Compatible",
baseUrl: "",
apiKey: "",
defaultModel: "gpt-4o-mini",
}
}
export function createModelConfig(
serviceId: string,
name = "gpt-4o-mini",
displayName = "GPT-4o mini",
isDefault = true
): ModelConfig {
return {
id: createId("model"),
name,
displayName,
serviceId,
isDefault,
parameters: { ...DEFAULT_MODEL_PARAMETERS },
updatedAt: nowIso(),
}
}
export function createPromptTemplate(
title = "通用助手",
content = DEFAULT_SYSTEM_PROMPT,
isDefault = true
): PromptTemplate {
const timestamp = nowIso()
return {
id: createId("prompt"),
title,
content,
isDefault,
createdAt: timestamp,
updatedAt: timestamp,
}
}
export function createDefaultSettings(
service: ServiceConfig,
model: ModelConfig
): AppSettings {
return {
...DEFAULT_MODEL_PARAMETERS,
activeServiceId: service.id,
activeModelId: model.id,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
theme: "system",
compactMode: false,
fontScale: 1,
showAdvanced: false,
proxyEnabled: false,
proxyUrl: "",
proxyHeaders: "",
tools: {
functionCallingEnabled: true,
toolChoice: "auto",
enabledToolIds: ["askQuestions"],
},
services: [service],
}
}
export function createDefaultData(): AppData {
const service = createServiceConfig()
const model = createModelConfig(service.id)
const prompt = createPromptTemplate()
const session = createSession()
return {
version: 1,
currentSessionId: session.id,
sessions: [session],
settings: createDefaultSettings(service, model),
models: [model],
prompts: [prompt],
}
}
export function createTitleFromMessage(content: string) {
const normalized = content.replace(/\s+/g, " ").trim()
if (!normalized) {
return "新会话"
}
return normalized.length > 28 ? `${normalized.slice(0, 28)}...` : normalized
}

509
src/lib/openai.ts Normal file
View File

@ -0,0 +1,509 @@
import type {
ChatMessage,
ChatToolCall,
ChatToolDefinition,
ModelParameters,
ServiceConfig,
ToolChoiceMode,
TokenUsage,
} from "@/types"
type RawToolCall = {
id: string
type: "function"
function: {
name: string
arguments: string
}
}
type CompletionMessage =
| {
role: "system" | "user"
content: string
}
| {
role: "assistant"
content: string | null
reasoning_content?: string
tool_calls?: RawToolCall[]
}
| {
role: "tool"
content: string
tool_call_id: string
}
type ChatCompletionRequest = {
service: ServiceConfig
modelName: string
parameters: ModelParameters
systemPrompt: string
messages: ChatMessage[]
signal: AbortSignal
tools?: ChatToolDefinition[]
toolChoice?: ToolChoiceMode
onDelta?: (delta: string) => void
onReasoningDelta?: (delta: string) => void
onActivity?: () => void
}
type ChatCompletionResult = {
content: string
reasoningContent?: string
toolCalls?: ChatToolCall[]
usage?: TokenUsage
}
type FetchModelsResult = {
id: string
displayName: string
}[]
function buildEndpoint(baseUrl: string, path: "chat/completions" | "models") {
const trimmed = baseUrl.trim().replace(/\/+$/, "")
if (!trimmed) {
return ""
}
if (path === "chat/completions" && trimmed.endsWith("/chat/completions")) {
return trimmed
}
if (path === "models" && trimmed.endsWith("/models")) {
return trimmed
}
if (trimmed.endsWith("/chat/completions")) {
return `${trimmed.slice(0, -"/chat/completions".length)}/${path}`
}
return `${trimmed}/${path}`
}
function normalizeUsage(value: unknown): TokenUsage | undefined {
if (typeof value !== "object" || value === null) {
return undefined
}
const usage = value as Record<string, unknown>
const promptTokens = usage.prompt_tokens
const completionTokens = usage.completion_tokens
const totalTokens = usage.total_tokens
return {
promptTokens: typeof promptTokens === "number" ? promptTokens : undefined,
completionTokens:
typeof completionTokens === "number" ? completionTokens : undefined,
totalTokens: typeof totalTokens === "number" ? totalTokens : undefined,
}
}
function normalizeToolCalls(value: unknown): ChatToolCall[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const toolCalls = value
.map((item): ChatToolCall | null => {
if (typeof item !== "object" || item === null) {
return null
}
const record = item as Record<string, unknown>
const id = record.id
const functionValue = record.function
if (
typeof id !== "string" ||
typeof functionValue !== "object" ||
functionValue === null
) {
return null
}
const functionRecord = functionValue as Record<string, unknown>
const name = functionRecord.name
const args = functionRecord.arguments
if (typeof name !== "string") {
return null
}
return {
id,
name,
arguments: typeof args === "string" ? args : "{}",
status: "pending" as const,
}
})
.filter((item): item is ChatToolCall => item !== null)
return toolCalls.length ? toolCalls : undefined
}
async function readErrorMessage(response: Response) {
const contentType = response.headers.get("content-type") ?? ""
if (contentType.includes("application/json")) {
const payload = (await response.json().catch(() => null)) as unknown
if (typeof payload === "object" && payload !== null) {
const record = payload as Record<string, unknown>
const error = record.error
if (typeof error === "object" && error !== null) {
const message = (error as Record<string, unknown>).message
if (typeof message === "string") {
return message
}
}
const message = record.message
if (typeof message === "string") {
return message
}
}
}
const text = await response.text().catch(() => "")
return text || `${response.status} ${response.statusText}`
}
function toCompletionMessages(
systemPrompt: string,
messages: ChatMessage[]
): CompletionMessage[] {
const completionMessages: CompletionMessage[] = []
if (systemPrompt.trim()) {
completionMessages.push({ role: "system", content: systemPrompt.trim() })
}
for (const message of messages) {
if (message.role === "tool") {
if (!message.toolCallId) {
continue
}
completionMessages.push({
role: "tool",
content: message.content || "{}",
tool_call_id: message.toolCallId,
})
continue
}
if (message.role === "assistant") {
const content = message.content.trim()
const reasoningContent = message.reasoningContent?.trim()
if (!content && !reasoningContent && !message.toolCalls?.length) {
continue
}
const assistantMessage: CompletionMessage = {
role: "assistant",
content: content || (message.toolCalls?.length ? null : ""),
}
if (reasoningContent) {
assistantMessage.reasoning_content = reasoningContent
}
if (message.toolCalls?.length) {
assistantMessage.tool_calls = message.toolCalls.map((toolCall) => ({
id: toolCall.id,
type: "function",
function: {
name: toolCall.name,
arguments: toolCall.arguments || "{}",
},
}))
}
completionMessages.push(assistantMessage)
continue
}
if (!message.content.trim()) {
continue
}
completionMessages.push({
role: message.role,
content: message.content,
})
}
return completionMessages
}
export function trimContextMessages(
messages: ChatMessage[],
parameters: ModelParameters
) {
if (parameters.maxContextLength <= 0) {
return messages
}
const kept: ChatMessage[] = []
let totalLength = 0
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
const toolCallLength =
message.toolCalls?.reduce(
(length, toolCall) =>
length + toolCall.arguments.length + toolCall.name.length,
0
) ?? 0
const nextLength =
totalLength +
message.content.length +
(message.reasoningContent?.length ?? 0) +
toolCallLength
if (kept.length > 0 && nextLength > parameters.maxContextLength) {
break
}
kept.unshift(message)
totalLength = nextLength
}
return kept
}
export async function createChatCompletion({
service,
modelName,
parameters,
systemPrompt,
messages,
signal,
tools,
toolChoice,
onDelta,
onReasoningDelta,
onActivity,
}: ChatCompletionRequest): Promise<ChatCompletionResult> {
const endpoint = buildEndpoint(service.baseUrl, "chat/completions")
const body: Record<string, unknown> = {
model: modelName,
messages: toCompletionMessages(systemPrompt, messages),
temperature: parameters.temperature,
max_tokens: parameters.maxOutputTokens,
stream: parameters.stream,
}
if (parameters.reasoningEffort !== "default") {
body.reasoning_effort = parameters.reasoningEffort
}
if (tools?.length) {
body.tools = tools
body.tool_choice = toolChoice ?? "auto"
}
const response = await fetch(endpoint, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${service.apiKey}`,
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
if (!parameters.stream) {
const payload = (await response.json()) as Record<string, unknown>
const choices = Array.isArray(payload.choices) ? payload.choices : []
const firstChoice = choices[0] as Record<string, unknown> | undefined
const message = firstChoice?.message as Record<string, unknown> | undefined
const content = typeof message?.content === "string" ? message.content : ""
const reasoningContent =
typeof message?.reasoning_content === "string"
? message.reasoning_content
: ""
return {
content,
reasoningContent: reasoningContent || undefined,
toolCalls: normalizeToolCalls(message?.tool_calls),
usage: normalizeUsage(payload.usage),
}
}
if (!response.body) {
throw new Error("当前浏览器无法读取流式响应")
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
let fullContent = ""
let fullReasoningContent = ""
let usage: TokenUsage | undefined
const streamedToolCalls = new Map<number, ChatToolCall>()
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line.startsWith("data:")) {
continue
}
const data = line.slice(5).trim()
if (!data || data === "[DONE]") {
continue
}
onActivity?.()
const payload = JSON.parse(data) as Record<string, unknown>
const parsedUsage = normalizeUsage(payload.usage)
if (parsedUsage) {
usage = parsedUsage
}
const choices = Array.isArray(payload.choices) ? payload.choices : []
const firstChoice = choices[0] as Record<string, unknown> | undefined
const delta = firstChoice?.delta as Record<string, unknown> | undefined
const content = typeof delta?.content === "string" ? delta.content : ""
const reasoningContent =
typeof delta?.reasoning_content === "string"
? delta.reasoning_content
: ""
const toolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : []
for (const toolCallValue of toolCalls) {
if (typeof toolCallValue !== "object" || toolCallValue === null) {
continue
}
const toolCall = toolCallValue as Record<string, unknown>
const index =
typeof toolCall.index === "number"
? toolCall.index
: streamedToolCalls.size
const current = streamedToolCalls.get(index) ?? {
id: "",
name: "",
arguments: "",
status: "pending" as const,
}
const functionValue = toolCall.function
if (typeof toolCall.id === "string") {
current.id = toolCall.id
}
if (typeof functionValue === "object" && functionValue !== null) {
const functionRecord = functionValue as Record<string, unknown>
if (typeof functionRecord.name === "string") {
current.name += functionRecord.name
}
if (typeof functionRecord.arguments === "string") {
current.arguments += functionRecord.arguments
}
}
streamedToolCalls.set(index, current)
}
if (content) {
fullContent += content
onDelta?.(content)
}
if (reasoningContent) {
fullReasoningContent += reasoningContent
onReasoningDelta?.(reasoningContent)
}
}
}
const toolCalls = [...streamedToolCalls.values()]
.filter((toolCall) => toolCall.id && toolCall.name)
.map((toolCall) => ({
...toolCall,
arguments: toolCall.arguments || "{}",
}))
return {
content: fullContent,
reasoningContent: fullReasoningContent || undefined,
toolCalls: toolCalls.length ? toolCalls : undefined,
usage,
}
}
export async function fetchModels(
service: ServiceConfig,
timeoutSeconds: number
): Promise<FetchModelsResult> {
const endpoint = buildEndpoint(service.baseUrl, "models")
const controller = new AbortController()
const timeoutId = window.setTimeout(
() => controller.abort(),
Math.max(1, timeoutSeconds) * 1000
)
try {
const response = await fetch(endpoint, {
method: "GET",
signal: controller.signal,
headers: {
Authorization: `Bearer ${service.apiKey}`,
},
})
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
const payload = (await response.json()) as Record<string, unknown>
const data = Array.isArray(payload.data) ? payload.data : []
return data
.map((item) => {
if (typeof item !== "object" || item === null) {
return null
}
const record = item as Record<string, unknown>
const id = record.id
if (typeof id !== "string") {
return null
}
return { id, displayName: id }
})
.filter((item): item is FetchModelsResult[number] => item !== null)
} finally {
window.clearTimeout(timeoutId)
}
}

204
src/lib/storage.ts Normal file
View File

@ -0,0 +1,204 @@
import {
DEFAULT_MODEL_PARAMETERS,
createDefaultData,
nowIso,
} from "@/lib/default-data"
import { ASK_QUESTIONS_TOOL_NAME } from "@/lib/tools"
import type {
AppData,
AppSettings,
ChatMessage,
ChatSession,
ModelConfig,
PromptTemplate,
ToolChoiceMode,
ToolSettings,
} from "@/types"
export const APP_STORAGE_KEY = "simple-llm-chat-ui:v1"
type LoadResult = {
data: AppData
warning?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function isToolChoiceMode(value: unknown): value is ToolChoiceMode {
return value === "auto" || value === "none" || value === "required"
}
function normalizeToolSettings(
value: unknown,
fallback: ToolSettings
): ToolSettings {
if (!isRecord(value)) {
return fallback
}
const enabledToolIds = Array.isArray(value.enabledToolIds)
? value.enabledToolIds.filter(
(toolId): toolId is string => typeof toolId === "string"
)
: value.askQuestionsEnabled === false
? []
: value.askQuestionsEnabled === true
? [ASK_QUESTIONS_TOOL_NAME]
: fallback.enabledToolIds
return {
functionCallingEnabled:
typeof value.functionCallingEnabled === "boolean"
? value.functionCallingEnabled
: fallback.functionCallingEnabled,
toolChoice: isToolChoiceMode(value.toolChoice)
? value.toolChoice
: fallback.toolChoice,
enabledToolIds,
}
}
function normalizeMessages(value: unknown): ChatMessage[] {
if (!Array.isArray(value)) {
return []
}
return value.filter(isRecord).map((message) => {
const createdAt =
typeof message.createdAt === "string" ? message.createdAt : nowIso()
return {
...(message as ChatMessage),
createdAt,
updatedAt:
typeof message.updatedAt === "string" ? message.updatedAt : createdAt,
reasoningContent:
typeof message.reasoningContent === "string"
? message.reasoningContent
: typeof message.reasoning_content === "string"
? message.reasoning_content
: undefined,
}
})
}
function normalizeData(value: unknown): AppData {
const fallback = createDefaultData()
if (!isRecord(value)) {
return fallback
}
const sessions = Array.isArray(value.sessions)
? (value.sessions as ChatSession[]).filter((session) => session.id)
: fallback.sessions
const models = Array.isArray(value.models)
? (value.models as ModelConfig[]).filter((model) => model.id)
: fallback.models
const prompts = Array.isArray(value.prompts)
? (value.prompts as PromptTemplate[]).filter((prompt) => prompt.id)
: fallback.prompts
const settings = isRecord(value.settings)
? ({ ...fallback.settings, ...value.settings } as AppSettings)
: fallback.settings
const safeSessions = sessions.length > 0 ? sessions : fallback.sessions
const safeModels = models.length > 0 ? models : fallback.models
const safePrompts = prompts.length > 0 ? prompts : fallback.prompts
const safeServices =
Array.isArray(settings.services) && settings.services.length > 0
? settings.services
: fallback.settings.services
const currentSessionId =
typeof value.currentSessionId === "string" &&
safeSessions.some((session) => session.id === value.currentSessionId)
? value.currentSessionId
: safeSessions[0].id
return {
version: 1,
currentSessionId,
sessions: safeSessions.map((session) => ({
...session,
status: session.status ?? "idle",
updatedAt: session.updatedAt ?? nowIso(),
messages: normalizeMessages(session.messages),
})),
settings: {
...settings,
tools: normalizeToolSettings(settings.tools, fallback.settings.tools),
services: safeServices,
},
models: safeModels.map((model) => ({
...model,
serviceId:
typeof model.serviceId === "string" &&
safeServices.some((service) => service.id === model.serviceId)
? model.serviceId
: safeServices[0]?.id,
parameters: {
...DEFAULT_MODEL_PARAMETERS,
...(isRecord(model.parameters) ? model.parameters : {}),
},
})),
prompts: safePrompts,
}
}
export function loadAppData(): LoadResult {
try {
const stored = localStorage.getItem(APP_STORAGE_KEY)
if (!stored) {
return { data: createDefaultData() }
}
return { data: normalizeData(JSON.parse(stored)) }
} catch (error) {
return {
data: createDefaultData(),
warning: error instanceof Error ? error.message : "本地存储不可用",
}
}
}
export function saveAppData(data: AppData) {
try {
localStorage.setItem(APP_STORAGE_KEY, JSON.stringify(data))
return { ok: true as const }
} catch (error) {
return {
ok: false as const,
message: error instanceof Error ? error.message : "本地存储不可用",
}
}
}
export function clearStoredAppData() {
try {
localStorage.removeItem(APP_STORAGE_KEY)
return { ok: true as const }
} catch (error) {
return {
ok: false as const,
message: error instanceof Error ? error.message : "本地存储不可用",
}
}
}
export function parseImportedJson(text: string) {
try {
return { ok: true as const, value: JSON.parse(text) as unknown }
} catch (error) {
return {
ok: false as const,
message: error instanceof Error ? error.message : "导入数据格式错误",
}
}
}
export function normalizeImportedData(value: unknown) {
return normalizeData(value)
}

173
src/lib/tools.ts Normal file
View File

@ -0,0 +1,173 @@
import type {
AskQuestion,
AskQuestionsAnswer,
AskQuestionsPayload,
ChatToolDefinition,
} from "@/types"
export const ASK_QUESTIONS_TOOL_NAME = "askQuestions"
export const ASK_QUESTIONS_TOOL: ChatToolDefinition = {
type: "function",
function: {
name: ASK_QUESTIONS_TOOL_NAME,
description:
"Ask the user one or more transparent clarifying questions through UI controls. Use this when you need user input before continuing. Supported controls are single choice, multiple choice, and text input.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
title: {
type: "string",
description: "Short title shown above the questions.",
},
description: {
type: "string",
description: "Optional context shown before the questions.",
},
questions: {
type: "array",
minItems: 1,
items: {
type: "object",
additionalProperties: false,
properties: {
id: {
type: "string",
description: "Stable machine-readable identifier for the answer.",
},
label: {
type: "string",
description: "Question text shown to the user.",
},
type: {
type: "string",
enum: ["single", "multiple", "text"],
description: "UI control type: single choice, multiple choice, or text input.",
},
required: {
type: "boolean",
description: "Whether the user must answer before submitting.",
},
placeholder: {
type: "string",
description: "Placeholder text for text inputs.",
},
options: {
type: "array",
description: "Options for single and multiple choice questions.",
items: {
type: "object",
additionalProperties: false,
properties: {
label: { type: "string" },
value: { type: "string" },
description: { type: "string" },
},
required: ["label", "value"],
},
},
},
required: ["id", "label", "type"],
},
},
},
required: ["questions"],
},
},
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function asString(value: unknown) {
return typeof value === "string" ? value : ""
}
function normalizeQuestion(value: unknown, index: number): AskQuestion | null {
if (!isRecord(value)) {
return null
}
const rawType = asString(value.type)
const type = rawType === "single" || rawType === "multiple" ? rawType : "text"
const options = Array.isArray(value.options)
? value.options
.map((option) => {
if (!isRecord(option)) {
return null
}
const label = asString(option.label)
const optionValue = asString(option.value)
if (!label || !optionValue) {
return null
}
return {
label,
value: optionValue,
description: asString(option.description) || undefined,
}
})
.filter((option): option is NonNullable<typeof option> => option !== null)
: []
return {
id: asString(value.id) || `question_${index + 1}`,
label: asString(value.label) || `问题 ${index + 1}`,
type,
required: typeof value.required === "boolean" ? value.required : true,
placeholder: asString(value.placeholder) || undefined,
options,
}
}
export function parseAskQuestionsArguments(argumentsText: string): {
payload?: AskQuestionsPayload
error?: string
} {
try {
const parsed = JSON.parse(argumentsText || "{}") as unknown
if (!isRecord(parsed)) {
return { error: "工具参数不是 JSON 对象" }
}
const questions = Array.isArray(parsed.questions)
? parsed.questions
.map((question, index) => normalizeQuestion(question, index))
.filter((question): question is AskQuestion => question !== null)
: []
if (questions.length === 0) {
return { error: "工具参数中没有可展示的问题" }
}
return {
payload: {
title: asString(parsed.title) || "需要你的确认",
description: asString(parsed.description) || undefined,
questions,
},
}
} catch (error) {
return {
error: error instanceof Error ? error.message : "工具参数解析失败",
}
}
}
export function formatAskQuestionsResult(answers: AskQuestionsAnswer[]) {
return JSON.stringify(
{
tool: ASK_QUESTIONS_TOOL_NAME,
answeredAt: new Date().toISOString(),
answers,
},
null,
2
)
}

168
src/types.ts Normal file
View File

@ -0,0 +1,168 @@
export type ViewKey = "chat" | "settings" | "models" | "prompts" | "data" | "about"
export type MessageRole = "user" | "assistant" | "tool"
export type RequestState =
| "idle"
| "requesting"
| "generating"
| "failed"
| "stopped"
export type ThemeMode = "light" | "dark" | "system"
export type ReasoningEffort = "default" | "minimal" | "low" | "medium" | "high"
export type ToolChoiceMode = "auto" | "none" | "required"
export type ToolExecutionState = "pending" | "completed" | "failed"
export type TokenUsage = {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
export type ModelParameters = {
temperature: number
maxContextLength: number
maxOutputTokens: number
stream: boolean
timeoutSeconds: number
reasoningEffort: ReasoningEffort
}
export type ChatToolCall = {
id: string
name: string
arguments: string
status?: ToolExecutionState
result?: string
error?: string
}
export type AskQuestionOption = {
label: string
value: string
description?: string
}
export type AskQuestion = {
id: string
label: string
type: "single" | "multiple" | "text"
required?: boolean
placeholder?: string
options?: AskQuestionOption[]
}
export type AskQuestionsPayload = {
title?: string
description?: string
questions: AskQuestion[]
}
export type AskQuestionsAnswer = {
questionId: string
label: string
value: string | string[]
}
export type ChatToolDefinition = {
type: "function"
function: {
name: string
description?: string
parameters?: Record<string, unknown>
}
}
export type ToolSettings = {
functionCallingEnabled: boolean
toolChoice: ToolChoiceMode
enabledToolIds: string[]
}
export type ChatMessage = {
id: string
role: MessageRole
content: string
createdAt: string
updatedAt?: string
status?: RequestState | "done"
model?: string
elapsedMs?: number
usage?: TokenUsage
error?: string
reasoningContent?: string
toolCallId?: string
toolName?: string
toolCalls?: ChatToolCall[]
}
export type ServiceConfig = {
id: string
name: string
baseUrl: string
apiKey: string
defaultModel: string
}
export type ModelConfig = {
id: string
name: string
displayName: string
serviceId?: string
isDefault?: boolean
parameters: ModelParameters
updatedAt: string
}
export type PromptTemplate = {
id: string
title: string
content: string
isDefault?: boolean
createdAt: string
updatedAt: string
}
export type ChatSession = {
id: string
title: string
createdAt: string
updatedAt: string
messages: ChatMessage[]
status: RequestState
error?: string
serviceId?: string
modelId?: string
modelName?: string
promptId?: string
systemPrompt?: string
temporary?: boolean
}
export type AppSettings = ModelParameters & {
activeServiceId: string
activeModelId: string
systemPrompt: string
theme: ThemeMode
compactMode: boolean
fontScale: number
showAdvanced: boolean
proxyEnabled: boolean
proxyUrl: string
proxyHeaders: string
tools: ToolSettings
services: ServiceConfig[]
}
export type AppData = {
version: 1
currentSessionId: string
sessions: ChatSession[]
settings: AppSettings
models: ModelConfig[]
prompts: PromptTemplate[]
}

View File

@ -23,7 +23,6 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }

View File

@ -5,7 +5,6 @@
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" }
], ],
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }