多轮对话:messages是Agent的记忆

LLM 接口没有记忆,记忆是你每次自己背过去的那串 messages

接口无状态,记忆在调用方

markdown 复制代码
每一次 HTTP POST 都是**独立**的一次请求:模型回完这一句,服务端什么都不留。  
所谓「上下文 / 记忆」= 调用方每次在 `messages` 数组里**重新提交**的完整对话记录。  
你(调用方)是唯一的记忆体;模型的上下文窗口只是你这次背过去的内容的**容量上限**。

多轮messages长这样:

ini 复制代码
第 1 轮前:  [system]
第 1 轮后:  [system, user1, assistant1]
第 2 轮前:  [system, user1, assistant1, user2]   ← 每轮把上轮 assistant 也带上
第 2 轮后:  [system, user1, assistant1, user2, assistant2]
第 3 轮前:  [system, user1, assistant1, user2, assistant2, user3]
...

三个角色的分工

  • system:一次性设定人格与规则,永远放在数组最前面。
  • user:用户说的话,每轮新增一条。
  • assistant:模型的回答。多轮对话里,你必须把上轮的 assistant 内容原样回填------模型不记得自己说过什么,只有你替它重放,它才接得上。

多轮对话的本质:没轮就三行代码

不要被「对话、记忆、上下文」这些词唬住。去掉流式细节后,一次多轮调用就三件事

php 复制代码
// 一次对话轮次 = 三件事:
messages.push({ role: "user", content: input });        // 1. 把你说的放进去

const reply = await streamOnce(messages);                // 2. 带上【全部历史】问一次

messages.push({ role: "assistant", content: reply.text }); // 3. 关键:把它答的回填
// 下一轮回到第 1 行继续 push------数组越大,它"记得"越多,直到装不进上下文窗口

顺手做个反向实验: 把第 3 行注释掉再聊两轮,你会看到模型要么报错、要么答非所问------ 因为它「手上」根本没有自己上一轮说过的话。那一行,就是多轮对话的全部秘密。

直接上代码

xml 复制代码
<script setup lang="ts">
/**
 * ============================================================================
 * HomeView.vue ------ AI 对话页
 * ============================================================================
 * 阅读顺序建议:
 *   ① 类型设计       → 先定义「数据长什么样」
 *   ② 静态配置       → 常量与默认值
 *   ③ 请求体构造     → 纯函数,无副作用
 *   ④ 发送请求       → 只负责网络 + HTTP 状态
 *   ⑤ SSE 流式解析   → 纯函数,与 UI 完全解耦
 *   ⑥ 页面状态       → 响应式数据
 *   ⑦ 提交编排       → 把上面所有零件串起来
 *   ⑧ template       → 视图层
 *   ⑨ style          → 样式层(设计令牌 → 组件 → 响应式 → 降级)
 * ============================================================================
 */

// vue 的组合式 API 全部是「按需具名导入」,没有全局 `Vue` 对象
// nextTick    : 等 DOM 更新完成后再执行回调(滚动到底必须用它)
// onUnmounted : 组件卸载时的生命周期钩子
// reactive    : 把普通对象/数组变成「深度响应式代理」
// ref         : 把任意值包成 { value } 的响应式引用(模板里自动解包)
import { nextTick, onUnmounted, reactive, ref } from 'vue'

/**
 * ============================================================================
 * 一、类型设计
 * ----------------------------------------------------------------------------
 * 原则:协议层(发给服务器的)和展示层(页面要渲染的)分开定义。
 *       这样以后换模型/换网关,只改协议层;改 UI 只动展示层。
 * ============================================================================
 */

/** OpenAI 兼容 API 的三个角色:system 设定人设,user 用户,assistant 模型 */
type Role = 'system' | 'user' | 'assistant'

/** 发送给接口的「消息」------ 纯协议层结构,和 UI 无关 */
interface ChatMessage {
  role: Role // 谁说的
  content: string // 说了什么
}

/**
 * 页面聊天列表里的一条消息 = 协议字段 + 展示字段
 * extends 关键字表示「继承 ChatMessage 的全部字段,再加自己的」
 * 这样想发请求时,直接 map 一下就能丢给接口,不用来回转换
 */
interface UiMessage extends ChatMessage {
  id: number // 唯一键:v-for 的 :key 必须用它,不能用 index
  time: string // 展示用的时间字符串(已格式化)
}

/** 从 .env 读取的环境配置 */
interface AppEnv {
  apiUrl: string // 接口 base 地址
  apiKey: string // 鉴权密钥
  model: string // 模型名
}

/**
 * 模型行为 / 请求控制参数
 * - 与接口字段的命名差异统一在 buildRequestBody 里做「驼峰 → snake_case」映射
 * - 值为 null 表示「不传给服务器」,交给服务端默认处理
 */
interface ModelParams {
  stream: boolean // 是否流式返回
  temperature: number // 随机性:0 最确定,1+ 最发散
  topP: number // 核采样:只在累计概率前 topP 的词里挑
  presencePenalty: number // 出现惩罚:越大越倾向聊新话题
  frequencyPenalty: number // 频率惩罚:越大越抑制重复用词
  maxTokens: number | null // 最大生成 token 数,null = 不限制
  stop: string[] | null // 遇到这些字符串就停,null = 不设置
  includeUsage: boolean // 流式结束时是否附带 token 用量
}

/** POST /chat/completions 的请求体 ------ 字段名必须和接口文档一致 */
interface RequestBody {
  model: string
  messages: ChatMessage[]
  stream: boolean
  temperature: number
  top_p: number // 注意:接口用 snake_case
  presence_penalty: number
  frequency_penalty: number
  max_tokens?: number | null // 可选字段,问号表示「可以不存在」
  stop?: string[] | null
  stream_options?: { include_usage: boolean } // 只在流式时出现
}

/** SSE 流中单个 data 块的 JSON 结构(只声明我们用得到的字段,其余忽略) */
interface StreamChunk {
  id?: string // 本次响应 id
  choices?: Array<{
    index?: number // 第几个候选(我们只用 [0])
    delta?: { role?: Role; content?: string | null } // 本块的「增量」
    finish_reason?: string | null // 结束原因:stop / length / null
  }>
}

/**
 * ============================================================================
 * 二、静态配置
 * ============================================================================
 */

/** 模型参数的默认值 ------ 集中一处,方便以后做「设置面板」 */
const DEFAULT_PARAMS: ModelParams = {
  stream: true, // 默认走流式,体验更好
  temperature: 0.7, // 0.7 是「有点创意但不胡说」的常用平衡点
  topP: 1, // 1 = 关闭核采样,完全交给 temperature 控制
  presencePenalty: 0, // 0 = 不干预
  frequencyPenalty: 0, // 0 = 不干预
  maxTokens: null, // null 表示不限制长度
  stop: null, // null 表示不设置停止词
  includeUsage: true, // 流结尾带 token 用量(部分兼容网关可能忽略)
}

/** 单次请求超时毫秒数。60_000 是数字分隔符,等价于 60000,只为好读 */
const TIMEOUT_MS = 60_000

/**
 * 惰性读取 .env 环境变量:缺 key 直接 throw。
 * 为什么不放在模块顶层直接读?
 *   → 顶层 throw 会让整个组件渲染失败,整页白屏。
 *   → 放在函数里,由 submit 里 try/catch 兜住,错误只显示在气泡中,页面仍可正常渲染。
 */
function getEnv(): AppEnv {
  // import.meta.env 是 Vite 注入的编译期常量对象
  const e = import.meta.env
  const apiUrl = e.DEEPSEEK_API_URL // 变量名必须以 VITE_ 开头才会被 Vite 注入
  const apiKey = e.DEEPSEEK_API_KEY
  const model = e.DEEPSEEK_MODEL

  // 收集「所有」缺失的变量,而不是遇到第一个就报错 ------ 用户一次就能把 .env 补齐
  const missing = [
    ['DEEPSEEK_API_URL', apiUrl], // 每项是 [变量名, 变量值] 的元组
    ['DEEPSEEK_API_KEY', apiKey],
    ['DEEPSEEK_MODEL', model],
  ]
    .filter(([, v]) => !v) // [, v] 是数组解构:跳过第 0 项,只取第 1 项的值;!v 为空即缺失
    .map(([key]) => key) // 再只留下变量名

  console.log('missing', missing)

  if (missing.length) {
    // 模板字符串里用 ${} 插入变量
    throw new Error(`Missing env vars: ${missing.join(', ')}`)
  }

  // 走到这里说明三个变量都存在,返回强类型对象
  return {
    apiUrl, // 简写语法:等价于 apiUrl: apiUrl
    apiKey,
    model,
  }
}

/**
 * ============================================================================
 * 三、请求体构造 ------ 纯函数:同样输入必然同样输出,不碰网络、不改外部状态
 * ============================================================================
 */
function buildRequestBody(
  messages: ChatMessage[], // 完整对话历史
  params: ModelParams = DEFAULT_PARAMS, // 默认参数:不传就用 DEFAULT_PARAMS
): RequestBody {
  // 必填字段一次性组装好
  const body: RequestBody = {
    model: getEnv().model,
    stream: params.stream,
    messages, // 简写:等价于 messages: messages
    temperature: params.temperature,
    top_p: params.topP, // 驼峰 → snake_case 的映射就发生在这几行
    presence_penalty: params.presencePenalty,
    frequency_penalty: params.frequencyPenalty,
  }
  // 可选字段「按需挂载」:null 就不写进请求体,避免服务端把它当成有效值
  if (params.maxTokens != null) body.max_tokens = params.maxTokens // 用 != null 同时排除 null 和 undefined
  if (params.stop != null) body.stop = params.stop

  // 只有流式时才需要 stream_options,非流式带上可能被网关拒绝
  if (body.stream && params.includeUsage) {
    body.stream_options = {
      include_usage: true,
    }
  }

  return body
}

/**
 * ============================================================================
 * 四、发送请求
 * ============================================================================
 */

/**
 * 判断一个异常是不是「主动取消」。
 * 为什么要单独判?因为超时和用户点「停止」在浏览器里都是 AbortError,
 * 需要和「真正的网络故障」区分开,才能给出不同的提示文案。
 * DOMException 判断是为了兜住旧浏览器:不同实现抛的类不一样
 */
function isAbortError(e: unknown): boolean {
  return e instanceof DOMException
    ? e.name === 'AbortError' // 标准路径:DOMException 的 name 属性
    : e instanceof Error && e.name === 'AbortError' // 兜底路径
}

/**
 * 把 unknown 类型的异常安全地转成可显示的字符串。
 * catch 到的 e 在 TS 里是 unknown(因为 JS 可以 throw 任何东西,不一定是 Error)
 */
function errorMessage(e: unknown): string {
  return e instanceof Error ? e.message : String(e)
}

/**
 * 把 HTTP 错误响应翻译成人能看懂的话。
 * 定义为 const + 箭头函数只是风格选择,和 function 声明等价
 */
const describeHttpError = async (res: Response): Promise<string> => {
  let detail = `HTTP ${res.status} ${res.statusText}` // 兜底文案:至少告诉用户状态码
  try {
    // 网关的报错信息通常放在 JSON 的 { error: { message } } 里,能挖就挖出来
    const data: unknown = await res.json() // 注意:body 只能读一次,所以放在 try 里
    if (data && typeof data === 'object') {
      // 层层收窄类型,每一步都确认存在再往下取,避免 Cannot read property
      const error = (data as { error: unknown }).error
      const msg =
        error && typeof error === 'object' && 'message' in error
          ? (error as { message?: unknown }).message
          : undefined
      detail = typeof msg === 'string' ? msg : JSON.stringify(data)
    }
  } catch {
    // 响应体不是 JSON(比如 HTML 错误页),忽略即可,用上面的兜底文案
  }

  // 按状态码归类:5xx 是服务端问题(可重试),其余多是我们自己的 key/参数问题
  const kind = res.status >= 500 ? '服务器异常(请稍后重试)' : '请求被拒绝(key/参数)'
  return `[${kind}] ${detail}`
}

/**
 * 发送 /chat/completions 请求。
 * 只做「网络 + HTTP 状态」的错误归因:abort 原样上抛,让调用方区分取消 / 超时。
 */
async function sendChat(body: RequestBody, signal: AbortSignal): Promise<Response> {
  const { apiUrl, apiKey } = getEnv() // 对象解构,省掉 res.apiUrl 这种重复前缀
  let res: Response // 先声明后赋值,因为要在 try/catch 外面继续用它
  try {
    res = await fetch(`${apiUrl}/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json', // 告诉服务端我们发的是 JSON
        Authorization: `Bearer ${apiKey}`, // Bearer 是 OpenAI 兼容协议的鉴权约定
      },
      body: JSON.stringify(body), // fetch 只接受字符串,要自己序列化
      signal, // 把 AbortController 的 signal 挂上去,才能被 abort() 打断
    })
  } catch (e) {
    // fetch 只有在「网络层失败」时才 reject(DNS 解析不了、CORS 被拦、断网)
    if (isAbortError(e)) throw e // 主动取消不是错误,原样上抛
    throw new Error(`网络错误:无法连接 ${apiUrl}(${errorMessage(e)})`)
  }
  // fetch 不会因为 4xx/5xx 而 reject,必须自己检查 ok(= status 200~299)
  if (!res.ok) {
    throw new Error(await describeHttpError(res))
  }
  return res
}

/**
 * ============================================================================
 * 五、流式(SSE)解析 ------ 纯函数、与 UI 解耦
 * ============================================================================
 */

/** 安全解析单个 data payload;[DONE] 或解析失败都返回 null */
function parseStreamPayload(payload: string): StreamChunk | null {
  // SSE 协议的结束标志就是字面量 [DONE],它不是合法 JSON,必须提前排除
  if (!payload || payload === '[DONE]') return null
  try {
    return JSON.parse(payload) as StreamChunk
  } catch {
    // 半包 / 空行 / 心跳注释都可能走到这里,静默跳过,绝不能让一行坏数据炸掉整个流
    return null
  }
}

/**
 * 从 chunk 中取出本次增量文本。
 * 链路:chunk.choices[0].delta.content
 * 用 ?. 可选链 + ?? 空值合并:usage 块、finish_reason 块的 delta 是空的,自然返回 ''
 */
function chunkDelta(chunk: StreamChunk): string {
  return chunk.choices?.[0]?.delta?.content ?? ''
}

/**
 * 逐行消费 SSE 流,把每个增量文本交给 onDelta 回调。
 * 用 try/finally 保证无论成功还是出错,都会释放 reader,避免占着底层连接不放。
 */
async function readChatStream(res: Response, onDelta: (delta: string) => void): Promise<void> {
  // 极老的浏览器 / 被 polyfill 过的环境可能没有 body
  if (!res.body) throw new Error('当前浏览器不支持流式读取(response.body 为空)')

  const reader = res.body.getReader() // 拿到流读取器,之后只能通过它读,别人不能再读
  const decoder = new TextDecoder('utf-8') // 字节 → 字符串的解码器
  let buffer = '' // 跨 chunk 的「半行」暂存区

  /** 处理一行原始文本(注意:行内可能带 \r,因为 SSE 用 \r\n 换行) */
  const handleLine = (raw: string) => {
    const line = raw.replace(/\r$/, '') // 去掉行尾的 \r,正则 $ 表示「结尾」
    if (!line.startsWith('data:')) return // 只处理 data: 行,event:/id:/: 心跳统统跳过
    // 'data:' 正好 5 个字符,slice(5) 取后面的内容,trim 掉协议规定的一个前导空格
    const chunk = parseStreamPayload(line.slice(5).trim())
    if (chunk) {
      const delta = chunkDelta(chunk)
      if (delta) onDelta(delta) // 空 delta 不回调,省掉无意义的渲染触发
    }
  }

  try {
    while (true) {
      const { done, value } = await reader.read() // value 是 Uint8Array 字节块
      if (done) break // 流读完了

      // 关键:{ stream: true } 表示「后面还有数据」,
      // 这样多字节的 UTF-8 字符(中文!)被切在两个 chunk 中间时不会被解成乱码
      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n') // 按换行切成若干「行」

      // pop() 拿掉最后一段:它很可能是个半行,留到下一轮再拼
      buffer = lines.pop() ?? '' // ?? '' 是为了满足 TS 的 noUncheckedIndexedAccess
      for (const line of lines) {
        handleLine(line)
      }
    }
    // 流结束:flush 掉 Decoder 内部还残余的字节 + 收尾最后一个没有换行的 data
    buffer += decoder.decode() // 不传 stream:true 就是「收尾模式」
    if (buffer) handleLine(buffer)
  } finally {
    reader.releaseLock() // 释放锁,底层连接才能被回收;放在 finally 里保证一定执行
  }
}

/**
 * ============================================================================
 * 六、页面状态
 * ============================================================================
 */

/** 聊天列表。用 reactive 而不是 ref,是因为我们希望对数组元素做「深度」响应 */
const chatList = reactive<UiMessage[]>([])

let nextId = 1 // 自增 id 计数器,不需要响应式(只用于 :key),所以用普通变量

/**
 * 往列表加一条消息,并返回它在 reactive 数组里的【响应式代理】。
 *
 * ⚠️ 关键坑:不能返回 push 之前的裸对象。
 *   chatList.push({...}) 时,Vue 会把对象包成 Proxy 存进数组;
 *   如果你持有的是外面那个「裸对象」,改它不会触发渲染,
 *   流式内容就会攒到最后一次性出现(而不是一个字一个字往外蹦)。
 */
function addMessage(role: Role, content: string): UiMessage {
  chatList.push({ id: nextId++, role, content, time: formatNow() })
  // [length - 1] 取出刚 push 进去的那个「代理对象」
  // 末尾的 ! 是 TS 的非空断言:告诉编译器「这里一定有值」,不再报 possibly undefined
  return chatList[chatList.length - 1]!
}

/** 输入框内容。ref 在 <script> 里要 .value,在 <template> 里自动解包 */
const inputText = ref('')
/** 是否有请求在途。用来禁用输入框 / 按钮,防止重复提交 */
const isSending = ref(false)
/** 滚动容器的 DOM 引用。名字必须和模板里的 ref="contextBox" 一模一样 */
const contextBox = ref<HTMLElement | null>(null)

/** 当前在途请求的控制器,离开页面时用于中止(避免卸载后回调还在改 state) */
const activeAbort = ref<AbortController | null>(null)

/** 是否已卸载。用普通变量即可 ------ 它只在回调里被读,不需要驱动渲染 */
let unmounted = false

/** rAF 句柄,用于合并高频滚动请求(见 scrollToBottom) */
let scrollFrame = 0

onUnmounted(() => {
  unmounted = true // 打标记,让还在飞的流式回调直接 return
  activeAbort.value?.abort() // 中止在途请求,省流量也防内存泄漏
  if (scrollFrame) cancelAnimationFrame(scrollFrame) // 取消排在下一帧的滚动
})

/** 补零工具:把 7 补成 "07"。padStart(2,'0') = 不足 2 位就在前面补 '0' */
const two = (n: number) => n.toString().padStart(2, '0')

/** 把 Date 格式化成 "YYYY-MM-DD HH:mm"。默认参数让不传参时就是「现在」 */
function formatNow(d = new Date()): string {
  // getMonth() 从 0 开始(1 月是 0),所以 +1
  return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
}

/**
 * 滚动到底部。
 * ⚠️ 必须放 nextTick 里:此刻新消息还只存在于数据里,DOM 还没更新,
 *    直接读 scrollHeight 拿到的是旧高度,会「永远差一条」。
 *
 * 生产优化:流式每秒会回调几十次,每次都 nextTick + scrollTo 会掉帧。
 * 这里用 requestAnimationFrame 做「合帧」------一帧内多次调用只真正滚一次。
 */
function scrollToBottom(): void {
  if (scrollFrame) return // 已经排过队了,直接返回(这就是「合并」发生的地方)
  scrollFrame = requestAnimationFrame(() => {
    scrollFrame = 0 // 先复位句柄,下一帧才允许再次排队
    nextTick(() => {
      const box = contextBox.value
      if (box) box.scrollTop = box.scrollHeight // 直接赋 scrollTop 比 scrollTo 少一次对象创建
    })
  })
}

/**
 * 这条消息是否正在「等第一个字」------ 用于显示打字动画。
 * 抽成函数而不是写在模板里,一是模板更干净,二是这种判断以后会变复杂。
 * 注意:在 <script> 里访问 ref 必须写 .value
 */
function isPending(item: UiMessage): boolean {
  return item.role === 'assistant' && isSending.value && item.content === ''
}

/**
 * 回车发送处理器。
 * ⚠️ 中文输入法的坑:用拼音打字时按回车是「确认候选词」,
 *    此时 keydown 也会触发,直接发送会把没输完的拼音发出去。
 *    e.isComposing 在输入法组合期间为 true,靠它拦住。
 */
function onEnterKey(e: KeyboardEvent): void {
  if (e.isComposing) return
  void submit() // void 表示「我知道这是 Promise,但不打算 await」,明确忽略浮空 Promise
}

/**
 * ============================================================================
 * 七、提交编排 ------ 本节是整个页面的「指挥中心」
 * ============================================================================
 */
async function submit(): Promise<void> {
  const text = inputText.value.trim() // 取出去掉首尾空白的输入
  if (!text || isSending.value) return // 空内容 或 上一轮还没结束,直接忽略(双保险防连点)
  inputText.value = '' // 立刻清空输入框,让用户可以马上打下一句

  addMessage('user', text) // 用户消息先上屏(乐观更新)

  scrollToBottom() // 滚到最新

  // 先把「历史」快照下来,再创建空的 assistant 占位消息。
  // 顺序不能反:若先 addMessage 再快照,空 assistant 会被当成历史发给服务端。
  const history: ChatMessage[] = chatList.map((m) => ({ role: m.role, content: m.content }))

  const assistant = addMessage('assistant', '') // 拿到「响应式代理」,下面靠改它做逐字渲染

  const controller = new AbortController() // 每次请求独立的控制器(不能复用,abort 是一次性的)

  activeAbort.value = controller // 记下来,卸载/超时时能拿到

  // 超时保险丝:到点就 abort,等价于用户手动点停止
  const timeoutId = window.setTimeout(() => controller.abort(), TIMEOUT_MS)

  isSending.value = true // 置为「忙」,输入框和按钮进入禁用态

  try {
    const body = buildRequestBody(history) // 组装请求体
    const res = await sendChat(body, controller.signal) // 发请求(可能 throw)

    if (body.stream) {
      // 流式分支:每收到一段增量就追加到 assistant.content
      await readChatStream(res, (delta) => {
        if (unmounted) return // 组件已卸载,别再改 state
        assistant.content += delta // 改的是响应式代理,所以界面会实时更新
        scrollToBottom() // 跟着内容长高持续贴底
      })
    } else {
      // 非流式分支:一次性拿到完整 JSON
      const data = (await res.json()) as {
        choices?: Array<{ message?: { content?: string } }>
      }
      // ?? 是空值合并:左边为 null/undefined 时才用右边的兜底文案
      assistant.content = data.choices?.[0]?.message?.content ?? '(空响应)'
      scrollToBottom()
    }
  } catch (e) {
    if (unmounted) return // 卸载后不弹错误,避免内存泄漏和无意义更新
    if (isAbortError(e)) {
      // 超时或手动中止:保留已经生成的文字,只在末尾补个说明
      assistant.content += assistant.content ? '(已中断)' : '(请求已取消或超时)'
    } else {
      // 真正的失败:整条替换成错误信息
      assistant.content = `(请求失败): ${errorMessage(e)}`
    }
  } finally {
    // finally 一定会执行 ------ 「无论如何都要收尾」的逻辑放这里最安全
    window.clearTimeout(timeoutId) // 请求已结束,撤掉保险丝
    if (activeAbort.value === controller) activeAbort.value = null // 只清理「属于自己」的那个
    if (!unmounted) {
      // ⚠️ 必须放在 !unmounted 分支里:组件卸载后改 ref 没意义,还可能报警告
      isSending.value = false // 解除禁用,用户才能发下一句
      scrollToBottom()
    }
  }
}
</script>

<template>
  <!--
    根容器:整页的「画布」。
    职责:铺满视口、居中面板、绘制背景氛围(渐变光晕 + 噪点)。
  -->
  <div class="chat-app">
    <!--
      section 表示一个语义完整的区块,比无脑用 div 对屏幕阅读器友好。
      aria-label 给它起一个可朗读的名字。
    -->
    <section class="chat-panel" aria-label="AI 对话">
      <!-- ========== ① 顶部标题栏 ========== -->
      <header class="chat-header">
        <!-- 品牌区:呼吸点 + 文字标 -->
        <div class="brand">
          <!-- aria-hidden="true":纯装饰元素,别让读屏软件念出来 -->
          <span class="brand-mark" aria-hidden="true"></span>
          <!-- h1 是页面主标题;&nbsp; 是不换行空格,防止 "AI AGENT" 被拆成两行 -->
          <h1 class="brand-name">AI&nbsp;AGENT</h1>
        </div>
        <!--
          状态指示灯。
          :class 的对象语法:键是类名,值是布尔表达式 ------ 为 true 才加上这个类。
          比写 `:class="isSending ? 'is-busy' : ''"` 更清晰,且能方便地扩展多个类。
        -->
        <div class="status" :class="{ 'is-busy': isSending }">
          <span class="status-dot" aria-hidden="true"></span>
          <!-- 三元表达式:忙时显示 GENERATING,闲时显示 READY -->
          <span class="status-text">{{ isSending ? 'GENERATING' : 'READY' }}</span>
        </div>
      </header>

      <!-- ========== ② 消息滚动区 ========== -->
      <!--
        ref="contextBox"  → 与 <script> 里的 const contextBox 绑定,用于滚动
        role="log"        → 语义:这是一个持续追加内容的日志区
        aria-live         → 有新内容时读屏软件会播报。
                            (流式场景下每次增量都会触发,若觉得太吵,
                              生产做法是给播报做 500ms 防抖,或只在结束时播报。)
      -->
      <div
        ref="contextBox"
        class="context-box"
        role="log"
        aria-live="polite"
        aria-relevant="additions text"
      >
        <!--
          空状态:一条消息都没有时给用户「怎么用」的引导。
          v-if 会在 DOM 里真正插入 / 移除节点(对比 v-show 只是切 display)。
        -->
        <div v-if="chatList.length === 0" class="empty-state">
          <div class="empty-glyph" aria-hidden="true">◇</div>
          <p class="empty-title">开始一段对话</p>
          <!--
            <kbd> 是「键盘按键」的语义标签,浏览器默认等宽显示,
            这样写既是正确的语义,也方便单独做样式。
          -->
          <p class="empty-hint">按 <kbd>Enter</kbd> 发送,<kbd>Shift</kbd>+<kbd>Enter</kbd> 换行</p>
        </div>

        <!--
          template 标签本身不会渲染成任何元素,只用来承载 v-if/v-else 或 v-for 指令。
          这里和上面的 v-if 构成「空状态 / 消息列表」二选一。
        -->
        <template v-else>
          <!--
            v-for 遍历消息列表。
            ⚠️ :key 必须用 item.id,不能用 index:
               用 index 时,列表头部插入新元素会让所有节点的 key 发生位移,
               Vue 会复用错节点,动画和输入状态都会错乱。
            :class 用模板字符串,动态拼出 msg--user / msg--assistant,样式里好维护。
          -->
          <div v-for="item in chatList" :key="item.id" class="msg" :class="`msg--${item.role}`">
            <!-- 头像:纯装饰,用文字代替图片可省一次网络请求 -->
            <div class="msg-avatar" aria-hidden="true">
              {{ item.role === 'user' ? '你' : 'AI' }}
            </div>

            <!-- 消息主体:包含「角色 + 时间」元信息行和气泡 -->
            <div class="msg-body">
              <div class="msg-meta">
                <span class="msg-role">{{ item.role === 'user' ? 'YOU' : 'ASSISTANT' }}</span>
                <!--
                  <time> 是语义化标签,告诉浏览器 / 爬虫这是时间。
                  这里已经是格式化后的字符串,所以不加 :datetime 属性。
                -->
                <time class="msg-time">{{ item.time }}</time>
              </div>

              <!-- 气泡:内容是「正在生成」还是「普通文本」,二选一 -->
              <div class="msg-bubble">
                <!--
                  等待首字时显示三点跳动动画。
                  三个空 <i> 只是为了挂动画,没有语义,所以标 aria-hidden。
                  (flex 容器会丢弃纯空白文本节点,所以三个标签写在一行或分行都不影响间距,
                    间距由 CSS 的 gap 控制。)
                -->
                <span v-if="isPending(item)" class="typing" aria-label="正在生成">
                  <i aria-hidden="true"></i><i aria-hidden="true"></i><i aria-hidden="true"></i>
                </span>
                <!--
                  v-else 必须紧跟在 v-if 元素后面。
                  文本内容用 <p> 包一层而不是直接写在气泡里:
                  气泡需要 white-space: normal 来吸收模板缩进产生的空白,
                  而真正的内容需要 pre-wrap 来保留 \n ------ 两层各管一件事。
                -->
                <p v-else class="msg-text">{{ item.content }}</p>
              </div>
            </div>
          </div>
        </template>
      </div>

      <!-- ========== ③ 输入区 ========== -->
      <!-- footer 语义:这里是页面的收尾操作区,键盘可达性上也更正确 -->
      <footer class="composer">
        <!--
          用 textarea 而不是 input ------ 生产级聊天必须支持多行。
          rows="1"                → 初始只占一行高,靠 CSS 的 field-sizing 自动长高
          v-model                 → 双向绑定 inputText,输入即同步到 script
          :disabled               → 忙时禁用,防止并发提交
          @keydown.enter.exact    → 只在「没有按任何修饰键的回车」时触发;
                                    .exact 保证 Shift+Enter 不命中,从而能正常换行
          .prevent               → 阻止回车的默认行为(插入换行),否则会先换行再发送
        -->
        <textarea
          v-model="inputText"
          class="composer-input"
          rows="1"
          placeholder="输入消息..."
          :disabled="isSending"
          @keydown.enter.exact.prevent="onEnterKey"
        ></textarea>

        <!--
          type="button" 很重要:默认是 type="submit",
          如果以后外层套了 <form>,点它会触发表单提交导致页面刷新。
          :disabled 用两个条件的或:忙中 或 输入为空,都不该能点。
        -->
        <button
          class="composer-send"
          type="button"
          :disabled="isSending || !inputText.trim()"
          @click="submit"
        >
          <span class="composer-send-label">{{ isSending ? '生成中' : '发送' }}</span>
        </button>
      </footer>
    </section>
  </div>
</template>

<!--
  非 scoped 的全局样式块。
  ⚠️ 为什么需要它:scoped 会给选择器加 [data-v-xxx] 属性,
     所以它永远选不中 <body>、<html> 这些组件之外的标签。
     而 body 默认有 8px margin,不干掉的话整页会露出一圈白边。
  ⚠️ 放在单独一个 <style> 块里(而不是混进下面 scoped 块),
     是为了让「哪些是全局污染」一眼可见 ------ 这是可维护性的关键。
-->
<style>
html,
body {
  height: 100%; /* 让百分比高度能一路传递到 #app */
}

body {
  margin: 0; /* 干掉浏览器默认的 8px 外边距 */
  background: #0a0a0c; /* 兜底底色:页面滚过头时不会闪白 */
  -webkit-font-smoothing: antialiased; /* macOS 上字体更细腻(抗锯齿灰度化) */
}

#app {
  height: 100%; /* 让 .chat-app 的 height:100% / dvh 有参照物 */
}
</style>

<style scoped>
/* ============================================================================
   一、组件级 reset
   ----------------------------------------------------------------------------
   App.vue 里的 `* { margin: 0 }` 因为 scoped 只作用于根元素,
   <p> / <button> / <textarea> 的浏览器默认样式(margin、字体、边框)依然生效。
   这里对组件内所有元素做一次归零,避免到处写「补丁式」的 margin: 0。

   `.chat-app *` 在 scoped 编译后会变成 `.chat-app *[data-v-xxx]`,
   而模板里的每个元素都会带上 data-v-xxx,所以能全部命中。
   ⚠️ 顺序很重要:reset 必须写在所有具体规则之前,
      因为 `.chat-app *` 和 `.composer` 的特异性相同(都是 1 个类),
      后写的规则才能覆盖先写的。
   ============================================================================ */
.chat-app,
.chat-app *,
.chat-app *::before,
.chat-app *::after {
  box-sizing: border-box; /* 让 width 包含 padding 和 border ------ 布局尺寸可预测的前提 */
  margin: 0; /* 清掉 h1 / p / body 的默认外边距 */
  padding: 0; /* 清掉 ul / button 的默认内边距 */
}

/* ============================================================================
   二、设计令牌(Design Tokens)
   ----------------------------------------------------------------------------
   把颜色 / 字体 / 圆角全部抽成 CSS 自定义属性。
   好处:① 换主题只改这一处;② 变量天然向子孙元素继承;
        ③ 命名即文档,"哦这是三级文字色" 一眼能懂。
   主题方向:暖调石墨(graphite)+ 琥珀(amber)强调色。
   刻意避开「白底紫色渐变」那套烂大街的 AI 配色。
   ============================================================================ */
.chat-app {
  /* ---- 背景层次:从最深到最浅,形成「桌面 → 面板 → 卡片」的纵深 ---- */
  --ink-900: #0a0a0c; /* 最底层:页面背景 */
  --ink-800: #101014; /* 面板底色 */
  --ink-700: #16161b; /* 输入框等内凹元素 */
  --ink-600: #1d1d23; /* 卡片 / AI 气泡 */
  --ink-500: #26262e; /* 悬停态 */

  /* ---- 描边:用带透明度的白色,能自动适配任何底色 ---- */
  --line-1: rgba(255, 255, 255, 0.07); /* 常规分割线,几乎「看不见但存在」 */
  --line-2: rgba(255, 255, 255, 0.13); /* 强调分割线 / 悬停态 */

  /* ---- 文字三级层次:正文 / 次要 / 装饰 ---- */
  --fg-1: #ededf2; /* 一级:正文,对比度约 15:1,远超 WCAG AA */
  --fg-2: #a1a1ad; /* 二级:说明文字 */
  --fg-3: #6b6b78; /* 三级:时间戳、状态等装饰性文字 */

  /* ---- 强调色:琥珀。暖色在深色背景上比冷色更「贵」 ---- */
  --amber: #f0a94b;
  --amber-dim: #c8862f;
  --amber-glow: rgba(240, 169, 75, 0.14); /* 低透明度填充,用于用户气泡 */

  /* ---- 圆角梯度:小控件 10px,卡片 14px,面板 18px,气泡胶囊 99px ---- */
  --r-sm: 10px;
  --r-md: 14px;
  --r-lg: 18px;
  --r-full: 99px;

  /* ---- 字体 ----
     为什么用系统字体栈而不是引 Web Font?
       中文字体文件动辄 5~15MB,网络加载会严重拖慢首屏,
       生产环境的通行做法就是「拉丁字形用 Web Font,CJK 回落到系统字体」。
       这里全程使用系统栈,在 macOS 上会命中苹方(PingFang SC),观感足够好。 */
  --font-sans:
    'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', -apple-system,
    BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

  /* 等宽字体用于所有「界面标签」:状态、角色名、时间戳、快捷键。
     这一层是刻意的「终端 / 控制台」气质,是整套设计记忆点的来源。
     全用 ui-monospace 起手,macOS 上命中 SF Mono。 */
  --font-mono:
    ui-monospace, 'SF Mono', SFMono-Regular, 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas,
    monospace;

  /* 缓动曲线:不用默认的 ease,用一条更有「物理感」的曲线 ------ 快出慢停 */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* ---- 容器本身 ---- */
  height: 100vh; /* 旧浏览器兜底 */
  height: 100dvh; /* dvh 会随移动端地址栏收起/展开动态变化,不会出现「底部被裁」 */
  display: flex; /* 弹性布局:用 justify-content 居中,比 absolute + translate 稳得多 */
  justify-content: center; /* 水平居中面板 */
  padding: clamp(0px, 2.2vw, 28px); /* clamp(最小, 理想, 最大):小屏贴边,大屏留白 */
  position: relative; /* 给下面 ::before / ::after 两个背景层当定位参照 */
  overflow: hidden; /* 裁掉背景光晕溢出的部分,避免出现滚动条 */
  font-family: var(--font-sans); /* 字体在这里继承给所有子元素,子元素不用重复写 */
  color: var(--fg-1);
  background: var(--ink-900);
}

/* ---------------------------------------------------------------------------
   背景氛围层 ①:两团大半径径向渐变。
   纯色背景会让界面显得「薄」,加两团几乎看不见的光晕就有了景深感。
   用 % 表示透明度而不是具体色值,方便一眼看出「这层很淡」。
   --------------------------------------------------------------------------- */
.chat-app::before {
  content: ''; /* 伪元素必须写 content 才会生成,空字符串即可 */
  position: absolute; /* 脱离文档流,不影响布局 */
  inset: 0; /* inset:0 = top/right/bottom/left 全为 0,是简写 */
  pointer-events: none; /* 不接收鼠标事件,否则会挡住底下的按钮 */
  background:
    radial-gradient(
      58rem 40rem at 8% -8%,
      /* 尺寸 + 位置:左上角外一点 */ rgba(240, 169, 75, 0.1),
      /* 琥珀光晕,10% 透明度 */ transparent 62% /* 到 62% 处完全透明,形成柔和衰减 */
    ),
    radial-gradient(
      52rem 38rem at 96% 108%,
      /* 右下角外一点 */ rgba(214, 109, 84, 0.08),
      /* 铜红,与琥珀同属暖色系,保持色调统一 */ transparent 62%
    );
}

/* ---------------------------------------------------------------------------
   背景氛围层 ②:噪点(grain)纹理。
   一张内联 SVG 的 feTurbulence 滤镜 ------ 不产生额外网络请求。
   透明度压到 4%,只负责给纯色区域「去电子感」,肉眼几乎察觉不到,
   但去掉之后画面会明显显得发飘。
   ⚠️ data URI 里的 # 必须转义成 %23,否则会被当成 URL 的 fragment 截断。
   --------------------------------------------------------------------------- */
.chat-app::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0.04; /* 全局透明度:这一层比什么参数都关键,超过 6% 就变脏了 */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* ============================================================================
   三、面板骨架
   ============================================================================ */
.chat-panel {
  position: relative; /* 抬到两个背景伪元素之上 */
  z-index: 1; /* 配合 position 使用;背景层没有 z-index 所以自动在下 */
  width: 100%; /* 先占满父容器,再用 max-width 封顶 */
  max-width: 880px; /* 超过约 880px 后视线在气泡间来回扫会累,所以封顶 */
  max-height: 100%; /* 不超出父容器的内边距范围 */
  display: flex; /* 面板内部改成纵向三段式布局 */
  flex-direction: column;
  overflow: hidden; /* 让子元素(头部渐变条)被圆角裁切,不出现方角溢出 */
  background: var(--ink-800);
  border: 1px solid var(--line-1);
  border-radius: var(--r-lg);
  /* 三层阴影叠加大法:
     第 1 层:1px 的描边光环,深色背景下让边缘「立」起来
     第 2 层:大范围投影,营造悬浮感
     第 3 层(inset):顶部一条内高光,模拟「光源在上」的物理直觉 */
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.4),
    0 40px 80px -32px rgba(0, 0, 0, 0.85),
    inset 0 1px 0 rgba(255, 255, 255, 0.05);
}

/* ============================================================================
   四、顶部标题栏
   ============================================================================ */
.chat-header {
  flex: 0 0 auto; /* flex-grow:0 flex-shrink:0 flex-basis:auto ------ 永不伸缩 */
  display: flex;
  align-items: center; /* 交叉轴(垂直方向)居中 */
  justify-content: space-between; /* 主轴两端对齐:品牌靠左,状态靠右 */
  gap: 16px; /* 元素间距交给 gap,不用给某个元素单独写 margin */
  padding: 14px 20px;
  border-bottom: 1px solid var(--line-1); /* 用 border 而不是 hr,少一个 DOM 节点 */
  /* 极淡的顶亮渐变,制造「顶盖」的立体感 */
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent);
}

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* 品牌呼吸灯:用 box-shadow 的多层叠加做出「发光」效果,
   比 filter: drop-shadow 性能好,因为它不影响元素的布局盒。 */
.brand-mark {
  width: 9px;
  height: 9px;
  border-radius: 50%; /* 正方形 + 50% 圆角 = 正圆 */
  background: var(--amber);
  box-shadow:
    0 0 0 3px rgba(240, 169, 75, 0.14),
    /* 紧贴的一圈晕环,像光晕的第一层 */ 0 0 14px 2px rgba(240, 169, 75, 0.55); /* 外扩的柔光 */
  animation: pulse 2.6s ease-in-out infinite; /* 名字 时长 缓动 无限循环 */
}

.brand-name {
  font-family: var(--font-mono); /* 等宽 = 控制台气质 */
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.22em; /* 大写字母之间拉开间距,是「品牌字标」的经典处理 */
  text-transform: uppercase; /* 源文本写小写也行,这里统一转大写 */
  color: var(--fg-1);
}

/* ---------------- 状态胶囊 ---------------- */
.status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--fg-3); /* 空闲时是三级灰,不抢注意力 */
  transition: color 0.25s var(--ease); /* 状态切换时颜色平滑过渡 */
}

.status-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--fg-3); /* 空闲:灰点 */
  transition:
    background-color 0.25s var(--ease),
    box-shadow 0.25s var(--ease);
}

/* .is-busy 是模板里 :class="{ 'is-busy': isSending }" 动态挂上的。
   状态样式全部挂在同一个父类下,比给每个元素单独写三元表达式更内聚。 */
.status.is-busy {
  color: var(--amber); /* 忙碌:整块变成琥珀色 */
}

.status.is-busy .status-dot {
  background: var(--amber);
  box-shadow: 0 0 8px rgba(240, 169, 75, 0.75); /* 发光,像「正在传输」的指示灯 */
  /* steps(2) 是「阶跃缓动」------不做平滑过渡,直接两档跳变,
     制造出老式设备指示灯的闪烁感,比平滑闪烁更「硬件」。 */
  animation: blink 1s steps(2, start) infinite;
}

/* ============================================================================
   五、消息滚动区
   ============================================================================ */
.context-box {
  flex: 1 1 auto; /* 占满头部和输入区之间的所有剩余空间 */
  min-height: 0; /* ⚠️ flex 布局最经典的坑!
                     flex 项的 min-height 默认是 auto,意味着「内容多高我就多高」,
                     结果就是内容撑破容器、overflow-y 完全不生效。
                     必须显式写成 0,滚动才真正生效。 */
  overflow-y: auto; /* 只在纵向滚动 */
  overscroll-behavior: contain; /* 滚到底后不再把滚动「传染」给页面(防止整页跟着弹) */
  display: flex; /* 让空状态可以用 margin:auto 垂直居中 */
  flex-direction: column;
  gap: 22px; /* 消息之间的呼吸感,比逐条写 margin-bottom 更好维护 */
  padding: 26px 24px 30px;

  /* 兼容 Firefox 的滚动条写法(标准属性) */
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.14) transparent; /* 滑块色 轨道色 */
}

/* 兼容 Chrome / Safari 的滚动条写法(私有伪元素)。
   自定义滚动条是「精致感」的廉价来源之一 ------ 默认滚动条在深色主题下特别扎眼。 */
.context-box::-webkit-scrollbar {
  width: 10px; /* 轨道宽 10px */
}

.context-box::-webkit-scrollbar-track {
  background: transparent; /* 轨道完全透明,让它「隐形」 */
}

.context-box::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.13);
  /* border + background-clip 组合技:
     给滑块加 3px 透明边框,再把背景裁到 padding-box,
     视觉上滑块就变窄了 ------ 这是在不换元素的前提下做「细滑块」的标准做法。 */
  border: 3px solid transparent;
  background-clip: padding-box;
  border-radius: var(--r-full);
}

.context-box::-webkit-scrollbar-thumb:hover {
  background: rgba(255, 255, 255, 0.26);
  background-clip: padding-box; /* 悬停也要重复声明,否则会被上面的简写覆盖掉 */
}

/* ---------------- 空状态 ---------------- */
.empty-state {
  margin: auto; /* 在 flex 容器里,auto 外边距会吸收所有剩余空间 → 水平垂直双居中 */
  display: flex;
  flex-direction: column;
  align-items: center; /* 交叉轴居中,让文字和图标对齐中轴 */
  gap: 10px;
  padding: 40px 20px;
  text-align: center; /* 多行文字时也要居中 */
  /* both = 同时应用 from 和 to 两个关键帧的状态(避免动画结束跳回原样) */
  animation: fade-in 0.45s var(--ease) both;
}

.empty-glyph {
  font-size: 26px;
  line-height: 1; /* 设成 1 消除字形上下方的额外行距,方便精确控制间距 */
  color: var(--amber);
  opacity: 0.8;
}

.empty-title {
  font-size: 15px;
  color: var(--fg-2); /* 二级灰:有存在感但不抢戏 */
  letter-spacing: 0.02em;
}

.empty-hint {
  font-family: var(--font-mono); /* 提示里的按键用等宽,和「键帽」的气质一致 */
  font-size: 11.5px;
  color: var(--fg-3);
  line-height: 2; /* 行高放大,给内联的 kbd 留出上下空间,防止挤在一起 */
}

/* 后代选择器:只作用于 .empty-hint 里面的 kbd */
.empty-hint kbd {
  display: inline-block; /* 让 padding 的上下值生效(行内元素的垂直 padding 不撑开行盒) */
  padding: 1px 6px;
  margin: 0 1px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--fg-2);
  background: var(--ink-500); /* 键帽底色比周围亮一档 */
  border: 1px solid var(--line-2);
  border-bottom-width: 2px; /* 底边加粗 → 视觉上像有厚度,是「键帽」的通用画法 */
  border-radius: 5px;
}

/* ============================================================================
   六、消息
   ============================================================================ */
.msg {
  display: flex;
  align-items: flex-start; /* 头像和气泡顶部对齐,而不是垂直居中 */
  gap: 11px;
  /* 入场动画:新消息从下方 6px 处淡入。
     位移量刻意很小 ------ 大幅位移在快速对话时会显得浮躁。 */
  animation: msg-in 0.32s var(--ease) both;
}

/* 自己的消息:整行反向排列 → 头像我右、气泡我右 */
.msg--user {
  flex-direction: row-reverse;
}

.msg-avatar {
  flex: 0 0 auto; /* 不伸缩,永远保持 30×30 */
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center; /* 居中,让里面的文字落在正中 */
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 600;
  border-radius: 50%;
  user-select: none; /* 头像文字不该被用户选中复制 */
  /* 默认(AI)头像:琥珀色调 */
  color: var(--amber);
  background: var(--amber-glow);
  border: 1px solid rgba(240, 169, 75, 0.3);
}

/* 用户头像:改成中性灰,和 AI 形成明确的视觉区分 */
.msg--user .msg-avatar {
  color: var(--fg-2);
  background: var(--ink-500);
  border-color: var(--line-2);
}

.msg-body {
  min-width: 0; /* 同 min-height:0 的道理 ------ 不写的话长文本会把 flex 项撑爆而不换行 */
  /* min() 取两者较小值:窄屏用 78%(跟着容器走),超宽屏封顶 640px(防止一行太长难读) */
  max-width: min(78%, 640px);
  display: flex;
  flex-direction: column;
  gap: 6px; /* 元信息行和气泡之间的间距 */
}

/* 用户侧:内容整体靠右。
   用 align-items 控制交叉轴对齐,比让子元素各自 margin-left:auto 干净得多。 */
.msg--user .msg-body {
  align-items: flex-end;
}

/* ---------------- 元信息行:角色 + 时间 ---------------- */
.msg-meta {
  display: flex;
  align-items: baseline; /* 基线对齐:字号不同的文字底部视觉对齐更自然 */
  gap: 8px;
  padding: 0 3px; /* 微调,让文字和气泡左边缘对齐时不显得太贴 */
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--fg-3);
}

/* 用户侧元信息靠右 */
.msg--user .msg-meta {
  justify-content: flex-end;
}

/* ---------------- 气泡 ---------------- */
.msg-bubble {
  padding: 10px 14px;
  border-radius: var(--r-md);
  /* white-space 交给内层的 .msg-text 处理,
     这样模板缩进产生的空白不会被当成正文渲染出来 */
  font-size: 14.5px;
  line-height: 1.72; /* 中文正文 1.7 左右最舒服,1.5 偏挤、2.0 偏散 */
}

/* AI 气泡:凸起卡片感,左上角切平(朝向头像) */
.msg--assistant .msg-bubble {
  background: var(--ink-600);
  border: 1px solid var(--line-1);
  border-top-left-radius: 5px; /* 只改一个角 → 视觉上「指向」左侧的头像 */
}

/* 用户气泡:琥珀色调的玻璃感,右上角切平 */
.msg--user .msg-bubble {
  background: var(--amber-glow);
  border: 1px solid rgba(240, 169, 75, 0.3);
  border-top-right-radius: 5px;
}

/* 正文文本层 */
.msg-text {
  /* 保留换行和连续空格 ------ 聊天内容必须原样呈现,否则代码块/列表全塌成一行 */
  white-space: pre-wrap;
  /* anywhere:超长英文单词 / 无空格的长链接也能强制断行,防止撑破气泡 */
  overflow-wrap: anywhere;
  color: var(--fg-1);
}

/* ---------------- 打字动画 ---------------- */
.typing {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 20px; /* 固定高度,让「等待中」和「有内容」的气泡高度一致,避免跳动 */
}

.typing i {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--amber);
  /* 用 nth-child 给三个点做出 0 / 0.15s / 0.3s 的相位差,
     就形成了「波浪式」跳动 ------ 这是打字指示器的标准做法。 */
  animation: typing 1.2s ease-in-out infinite;
}

.typing i:nth-child(2) {
  animation-delay: 0.15s;
}

.typing i:nth-child(3) {
  animation-delay: 0.3s;
}

/* ============================================================================
   七、输入区
   ============================================================================ */
.composer {
  flex: 0 0 auto; /* 固定高度,不参与伸缩 */
  display: flex;
  align-items: flex-end; /* 底对齐:textarea 长高时,按钮贴着底部不动,不会跟着上下飘 */
  gap: 10px;
  padding: 14px 16px;
  border-top: 1px solid var(--line-1);
  /* 从透明到底部微暗:让输入区在视觉上「陷下去」,和消息区拉开层次 */
  background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.22));
}

.composer-input {
  flex: 1 1 auto; /* 占满除按钮外的全部宽度 */
  min-width: 0; /* 同上,防止被内容撑爆 */

  /* field-sizing: content ------ 让 textarea 高度随内容自动增长(现代浏览器)。
     不支持的浏览器会退化成固定一行高 + 出现滚动条,功能不受影响,
     属于典型的「渐进增强」。 */
  field-sizing: content;
  min-height: 44px;
  max-height: 160px; /* 超过 160px 就不再长高,转为内部滚动 */
  resize: none; /* 关掉右下角的手动拖拽把手(自动增高后它就多余了) */

  /* padding 上下 11px + line-height 20px + border 上下各 1px = 44px,与 min-height 对齐 */
  padding: 11px 14px;
  /* font: inherit 是必须的!textarea 默认使用浏览器自己的 monospace 13px,
     不继承会让输入框的字体和气泡里的字体完全不一致。 */
  font: inherit;
  font-size: 14.5px;
  line-height: 20px; /* 用固定的 px 行高,保证高度计算精确可控 */

  color: var(--fg-1);
  background: var(--ink-700);
  border: 1px solid var(--line-1);
  border-radius: var(--r-md);
  outline: none; /* 关掉浏览器默认的方块焦点框,改用下面的 box-shadow 自绘(更美观且可控) */

  /* 只写要动的属性,别用 transition: all ------ 它会让所有属性都参与过渡,既慢又不可控 */
  transition:
    border-color 0.18s var(--ease),
    background-color 0.18s var(--ease),
    box-shadow 0.18s var(--ease);

  scrollbar-width: thin; /* 内容超长出现内滚动时,滚动条也保持细的 */
}

/* ::placeholder 是伪元素,只能通过它改占位符颜色 */
.composer-input::placeholder {
  color: var(--fg-3);
}

/* :not(:disabled) 让禁用态不受悬停效果影响 ------ 否则鼠标划过会有「还能点」的误导 */
.composer-input:hover:not(:disabled) {
  border-color: var(--line-2);
}

.composer-input:focus {
  border-color: rgba(240, 169, 75, 0.5); /* 焦点态用强调色描边 */
  background: var(--ink-600); /* 底色提亮一档,暗示「这里是活跃区域」 */
  /* 3px 的柔和外发光环。它不占布局空间(box-shadow 不参与盒模型),
     所以聚焦时不会引起周围元素位移。 */
  box-shadow: 0 0 0 3px rgba(240, 169, 75, 0.13);
}

.composer-input:disabled {
  opacity: 0.5; /* 降低不透明度是最直观的「不可用」信号 */
  cursor: not-allowed;
}

/* ---------------- 发送按钮 ---------------- */
.composer-send {
  flex: 0 0 auto;
  height: 44px; /* 和输入框的 min-height 严格相等,两者底边才齐平 */
  padding: 0 20px;
  font-family: var(--font-sans);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.05em;
  color: #1a1206; /* 深棕色文字配琥珀底 ------ 对比度约 9:1,远超 AA 标准 */
  /* 双色渐变(上浅下深)+ 顶部内高光 = 经典的「实体按钮」质感 */
  background: linear-gradient(180deg, #f6b95f, #e09a35);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: var(--r-md);
  cursor: pointer; /* 鼠标变手型,明确「可点击」 */
  transition:
    filter 0.18s var(--ease),
    transform 0.12s var(--ease),
    box-shadow 0.2s var(--ease);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.3),
    /* 顶部内高光 */ 0 6px 16px -6px rgba(240, 169, 75, 0.6); /* 下方的同色投影,像在发光 */
}

.composer-send:hover:not(:disabled) {
  filter: brightness(1.07); /* 用 filter 提亮,不用改 background ------ 保证渐变关系不变 */
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.3),
    0 10px 24px -8px rgba(240, 169, 75, 0.8);
}

/* :active 是「按下去还没松开」的瞬间,下沉 1px 制造物理按压感 */
.composer-send:active:not(:disabled) {
  transform: translateY(1px);
}

.composer-send:disabled {
  cursor: not-allowed;
  /* grayscale 去色 + brightness 压暗:比单纯调 opacity 更像「失去能量」,
     同时仍保持文字可读,用户能看清按钮上写的是「生成中」 */
  filter: grayscale(0.75) brightness(0.72);
  box-shadow: none; /* 去掉发光,进一步弱化 */
  opacity: 0.65;
}

/* :focus-visible 只在「键盘聚焦」时命中,鼠标点击不会触发 ------
   这是无障碍与美观之间的最佳平衡点:键盘用户看得见焦点,
   鼠标用户不会被一圈虚线框打扰。 */
.composer-send:focus-visible {
  outline: 2px solid var(--amber); /* 用 outline 而不是 border:outline 不占布局空间 */
  outline-offset: 2px; /* 和外框拉开 2px,视觉更透气 */
}

/* ============================================================================
   八、动画关键帧
   ============================================================================ */

/* 品牌呼吸灯:亮度/阴影缓慢起伏 */
@keyframes pulse {
  0%,
  100% {
    opacity: 1;
    box-shadow:
      0 0 0 3px rgba(240, 169, 75, 0.14),
      0 0 14px 2px rgba(240, 169, 75, 0.55);
  }
  50% {
    opacity: 0.7;
    box-shadow:
      0 0 0 5px rgba(240, 169, 75, 0.06),
      0 0 20px 4px rgba(240, 169, 75, 0.35);
  }
}

/* 状态灯闪烁:因为用了 steps 缓动,这里只需要两个极端值 */
@keyframes blink {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0.25;
  }
}

/* 消息入场:淡入 + 上浮 */
@keyframes msg-in {
  from {
    opacity: 0;
    transform: translateY(6px); /* 只位移 6px,克制而不喧宾夺主 */
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* 通用淡入:给空状态复用 */
@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* 打字点:三个点靠 animation-delay 错开,形成波浪 */
@keyframes typing {
  0%,
  60%,
  100% {
    opacity: 0.25;
    transform: translateY(0);
  }
  30% {
    /* 30% 这个时刻只有第一个点处于波峰,另外两个还在延迟中 */
    opacity: 1;
    transform: translateY(-3px);
  }
}

/* ============================================================================
   九、响应式
   ============================================================================ */
@media (max-width: 720px) {
  .chat-app {
    padding: 0; /* 手机上取消外边距,让内容占满整个屏幕 */
  }

  .chat-panel {
    max-width: none;
    max-height: none;
    height: 100%; /* 撑满视口,不再是「一张悬浮卡片」 */
    border: none; /* 贴边时去掉边框和圆角,否则会看到屏幕边缘的细线 */
    border-radius: 0;
    box-shadow: none; /* 贴边后投影也没意义了 */
  }

  .context-box {
    padding: 18px 14px 22px; /* 左右内边距收窄,给气泡让出更多可用宽度 */
    gap: 18px;
  }

  .msg-body {
    max-width: 86%; /* 窄屏上适当放宽气泡占比,避免文字频繁换行 */
  }

  .composer {
    padding: 11px 12px;
    /* 底部安全区:iPhone 全面屏的 Home 指示条会盖住底部,
       env() 是 iOS 提供的内边距变量,桌面端会回落到 0px。 */
    padding-bottom: calc(11px + env(safe-area-inset-bottom, 0px));
  }

  .brand-name {
    letter-spacing: 0.14em; /* 窄屏缩小字距,防止标题挤到状态胶囊 */
  }
}

/* ============================================================================
   十、无障碍降级:尊重系统「减少动态效果」设置
   ----------------------------------------------------------------------------
   前庭功能障碍用户对动效敏感,系统开了这个开关后,
   应该把所有动画时长压到近似 0(而不是彻底删掉动画属性,
   因为完全移除 animation 可能让依赖 fill-mode 的元素回到初始态)。
   !important 在这里是合理的:它是「用户偏好覆盖作者样式」的正当场景。
   ============================================================================ */
@media (prefers-reduced-motion: reduce) {
  .chat-app *,
  .chat-app *::before,
  .chat-app *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}
</style>

上下文窗口与 token:为什么历史不是无限存

模型每次能「看」的输入有个上限 ,叫上下文窗口(context window)。它按 token 计量------token 是模型处理文本的切分单元, 大致可以这么估:一个汉字约 1--2 个 token,一个英文单词约 1--2 个 token (各家分词器不同,别背死这个数,以接口返回的 usage 为准)。

  • prompt_tokens:本次请求实际发送进去的 token 数(含全部历史)。← 控制成本与是否超限,主要看它
  • completion_tokens:模型本次生成的 token 数。
  • 成本按 token 计(输入/输出通常不同价)。所以「聊得越长」=「每轮越贵」------多轮不只是功能问题,是预算问题

上下文超限长什么样:prompt_tokens 逼近窗口上限,请求会失败,OpenAI 兼容接口一般返回HTTP 400 ,错误信息里出现类似 maximum context length / context_length_exceeded 的字样 (结合第 1 课:401 是 key 错,402 是余额不足,429 是频率超限,400 + context 就是历史太长)。 出现它别慌------这是你该做「记忆管理」的信号,不是 bug。

记忆管理三板斧

  1. 截断 :只发 system + 最近 N 轮。实现就是一个「从后往前数够 N 个 user 再切片」的函数。丢最早、保最近、system 常驻。
  2. 摘要压缩:把更早的对话让模型压成两三句摘要,作为一条内容塞回去。比纯截断省 token 且保留主线------代价是细节丢失。面试能说出这步就加分。
  3. 换话题开新会话:把 messages 重置回只有 system。很多「记忆脏了」的问题,一个 /clear 就解决。
相关推荐
大模型真好玩1 小时前
DeepSeek Harness 入门很简单(四)——DeepSeek Harness接入插件
人工智能·agent·deepseek
程序员cxuan1 小时前
GPT-6 Astra 的提示词泄露了,里面居然藏着个保安?
人工智能·后端·程序员
悬木2 小时前
从单体到 AI 搜索:一个电商搜索系统的进化
人工智能
m4Rk_2 小时前
【论文阅读】Agent 记忆机制(69):STITCH——用上下文意图解决“语义相关但情境错误”的记忆检索
论文阅读·人工智能·学习·开源·github
zhikouai2 小时前
删掉提示词之后,AI的表现反而更好
人工智能
skywalk81632 小时前
光明之路_Trae开发宣传_济宁聚会 9.12日《光明之路》讲演稿
人工智能·语言·实践
今天AI了吗2 小时前
什么是 AI Agent?它与直接调用大模型 API 有何区别
java·网络·人工智能·架构·java-ee
Hopetree2 小时前
AI Agent 实战手记 04:给 Agent 选对 Loop 工作方式_AI
人工智能
掘金酱2 小时前
社区排行榜现已上线
前端·人工智能