# LLM 客户端使用指南

`llm/` 目录封装了一个统一的 AI 能力客户端 `SkillsClient`，支持聊天补全（非流式/流式）、图片生成、视频生成、AI 搜索和语义重排。底层通过标准 `fetch` 调用远端 Skills API，无需额外依赖。

## 目录结构

```
llm/
  index.ts      # 统一导出入口（SkillsClient + 所有类型）
  client.ts     # SkillsClient 实现（chat / image / video / aiSearch / rerank）
  models.ts     # 所有请求/响应类型定义

app/api/
  llm-client.ts # 项目内共享单例（读取环境变量，server-only）
```

## 环境变量

在 `.env.local` 中添加以下配置：

| 变量名 | 说明 |
|--------|------|
| `LLM_API_KEY` | Skills API 鉴权密钥（Bearer Token） |
| `LLM_BASE_URL` | Skills API 服务地址（如 `https://your-api.example.com`） |

## 快速开始

项目已在 `app/api/llm-client.ts` 中创建好了共享单例，**在 API Route 中直接 import 使用即可**，无需重复实例化：

```typescript
import { llmClient } from '@/app/api/llm-client'
```

如需在其他 server 端模块中单独创建实例：

```typescript
import { SkillsClient } from '@/llm'

const client = new SkillsClient({
  apiKey: process.env.LLM_API_KEY!,
  baseUrl: process.env.LLM_BASE_URL!,
})
```

> `llm-client.ts` 仅可在 server 端使用（Next.js API Route、Server Action、Server Component），不能在客户端组件中 import。

## 功能详解

### 1. 非流式聊天

```typescript
import { llmClient } from '@/app/api/llm-client'

const response = await llmClient.chat({
  model: 'gpt-5.1',       // 可选，默认 'gpt-5.1'
  messages: [
    { role: 'system', content: '你是一个助手。' },
    { role: 'user', content: '你好！' },
  ],
  temperature: 0.7,       // 可选
  maxTokens: 1024,        // 可选
})

const text = response.choices[0]?.message?.content
```

### 2. 流式聊天（SSE）

使用 `chatStream()` 返回一个 `AsyncGenerator`，逐块处理流式响应：

```typescript
import { llmClient } from '@/app/api/llm-client'

// 在 Next.js API Route 中返回流
export async function POST(req: Request) {
  const { messages } = await req.json()

  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      for await (const chunk of llmClient.chatStream({ messages })) {
        const delta = chunk.choices[0]?.delta?.content ?? ''
        if (delta) {
          controller.enqueue(encoder.encode(`data: ${delta}\n\n`))
        }
      }
      controller.close()
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream' },
  })
}
```

### 3. 图片生成

```typescript
const result = await llmClient.imageGenerate({
  prompt: '一只在星空下奔跑的狐狸，写实风格',
  model: 'Nano Banana Pro',   // 可选，默认 'Nano Banana Pro'
  quantityGenerated: 1,       // 可选，生成张数
})

if (result.success) {
  const urls = result.data?.imageList  // string[]
}
```

### 4. 视频生成

视频生成分为**创建任务**和**轮询任务状态**两步：

```typescript
// 第一步：创建视频生成任务
const task = await llmClient.videoCreateTask({
  prompt: '海浪拍打礁石，慢动作，4K',
  model: 'Doubao-Seedance-1.5-pro',  // 可选
  parameters: {
    resolution: '1080p',
    duration: 5,
    generateAudio: true,
  },
})

const taskId = task.data?.taskId

// 第二步：轮询任务状态（pending / running / succeeded / failed）
const result = await llmClient.videoGetTask({ taskId })

if (result.data?.status === 'succeeded') {
  const videoUrl = result.data.videos?.[0]?.videoUrl
}
```

### 5. AI 搜索（非流式）

```typescript
const result = await llmClient.aiSearch({
  query: 'Next.js 15 新特性',
  freshness: 'week',  // 可选：day / week / month
})

if (result.success) {
  const messages = result.data?.messages
}
```

### 6. AI 搜索（流式）

```typescript
for await (const chunk of llmClient.aiSearchStream({ query: 'TypeScript 5.5 新特性' })) {
  if (chunk.isFinish) break
  const content = chunk.message?.content
  process.stdout.write(content ?? '')
}
```

### 7. 语义重排（Rerank）

对候选文档按照与查询的相关性重新排序：

```typescript
const result = await llmClient.rerank({
  query: '如何提升 React 渲染性能',
  documents: [
    '使用 React.memo 避免不必要重渲染',
    '今天天气很好',
    'useMemo 和 useCallback 可以缓存计算结果',
  ],
  topN: 2,            // 可选，返回前 N 条
  returnDocument: true, // 可选，是否在结果中附带原文
  model: 'qwen3-vl-rerank', // 可选
})

const ranked = result.data  // RerankItem[]，按 relevanceScore 降序
```

## API 参考

### `ChatParams`

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `messages` | `Message[]` | ✅ | 对话历史，每条含 `role` 和 `content` |
| `model` | `string` | — | 模型名称，默认 `'gpt-5.1'` |
| `stream` | `boolean` | — | 是否流式，由 `chat`/`chatStream` 自动控制 |
| `maxTokens` | `number` | — | 最大生成 token 数 |
| `temperature` | `number` | — | 采样温度（0–2） |
| `topP` | `number` | — | 核采样概率 |
| `responseFormat` | `object` | — | 返回格式（如 `{ type: 'json_object' }`） |
| `tools` | `unknown[]` | — | Function Calling 工具列表 |
| `toolChoice` | `unknown` | — | 工具调用策略 |

### `ChatResponse`

| 字段 | 类型 | 说明 |
|------|------|------|
| `choices` | `Choice[]` | 生成结果列表 |
| `choices[].message` | `ChoiceMessage` | 非流式时的完整消息 |
| `choices[].delta` | `ChoiceMessage` | 流式时的增量内容 |
| `choices[].finishReason` | `string` | 结束原因（`stop` / `tool_calls` 等） |
| `usage` | `Usage` | Token 用量统计 |
| `success` | `boolean` | 是否成功 |
| `errorMessage` | `string` | 失败时的错误信息 |

### `ImageParams`

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `prompt` | `string` | ✅ | 图片描述 |
| `model` | `string` | — | 默认 `'Nano Banana Pro'` |
| `quantityGenerated` | `number` | — | 生成张数 |
| `imageUrlList` | `string[]` | — | 参考图 URL 列表 |
| `watermark` | `boolean` | — | 是否添加水印 |

### `VideoParams`

| 字段 | 类型 | 说明 |
|------|------|------|
| `prompt` | `string` | 视频描述文本 |
| `model` | `string` | 默认 `'Doubao-Seedance-1.5-pro'` |
| `taskId` | `string` | 查询任务时传入 |
| `firstFrame` | `string` | 首帧图片 URL |
| `lastFrame` | `string` | 尾帧图片 URL |
| `parameters` | `VideoParameters` | 分辨率、时长、音频等参数 |

### `AiSearchParams`

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | `string` | ✅ | 搜索问题 |
| `freshness` | `string` | — | 时效过滤：`day` / `week` / `month` |
| `stream` | `boolean` | — | 由 `aiSearch`/`aiSearchStream` 自动控制 |

### `RerankParams`

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | `string` | ✅ | 查询文本 |
| `documents` | `string[]` | ✅ | 候选文档列表 |
| `topN` | `number` | — | 返回前 N 条结果 |
| `returnDocument` | `boolean` | — | 结果中是否附带原始文档 |
| `model` | `string` | — | 默认 `'qwen3-vl-rerank'` |

## 在 Next.js API Route 中的完整示例

```typescript
// app/api/chat/route.ts
import { llmClient } from '@/app/api/llm-client'
import type { Message } from '@/llm'

export async function POST(req: Request) {
  const { messages }: { messages: Message[] } = await req.json()

  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of llmClient.chatStream({
          model: 'gpt-5.1',
          messages,
          temperature: 0.7,
        })) {
          const delta = chunk.choices[0]?.delta?.content
          if (delta) {
            controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content: delta })}\n\n`))
          }
          if (chunk.choices[0]?.finishReason === 'stop') break
        }
      } catch (err) {
        controller.error(err)
      } finally {
        controller.close()
      }
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  })
}
```

## 注意事项

- `SkillsClient` 所有方法均为 `async`，需在 `server` 端调用（API Route、Server Action）
- 流式方法（`chatStream`、`aiSearchStream`）返回 `AsyncGenerator`，需用 `for await...of` 消费
- 视频生成为异步任务模式，需轮询 `videoGetTask()` 直到 `status === 'succeeded'`
- API Key 通过环境变量注入，不要在客户端代码中引用 `llm-client.ts`
- 所有类型均从 `@/llm` 统一导出，无需从 `@/llm/models` 单独引入
