feat: 实现基本功能
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
0b130f1d7f
commit
0e9e0c71b5
8631
package-lock.json
generated
Normal file
8631
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
3826
src/App.tsx
3826
src/App.tsx
File diff suppressed because it is too large
Load Diff
89
src/components/markdown-message.tsx
Normal file
89
src/components/markdown-message.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/components/ui/badge.tsx
Normal file
27
src/components/ui/badge.tsx
Normal 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 }
|
||||||
@ -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"
|
||||||
|
|||||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal 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 }
|
||||||
18
src/components/ui/select.tsx
Normal file
18
src/components/ui/select.tsx
Normal 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 }
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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 }
|
||||||
219
src/index.css
219
src/index.css
@ -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
161
src/lib/default-data.ts
Normal 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
509
src/lib/openai.ts
Normal 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
204
src/lib/storage.ts
Normal 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
173
src/lib/tools.ts
Normal 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
168
src/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
@ -23,7 +23,6 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user