import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto';
import {
  Permission,
  AnchorMode,
  ANCHOR_MODE_TOOL_DESC,
  TEMPERATURE_TOOL_DESC,
  TEMPERATURE_TOOL_VALUES,
  type AnchorModeValue,
  type TemperatureValue,
} from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';

const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。
重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。

你额外接入了 PAC(疗效保障 / 患者分析中心)的一组患者工具,在用户需要查患者时按需调用:
- 用户给患者姓名/手机/患者号时,先用 find_patient 拿到 patientId,再继续。
- 要患者全景就用 get_patient_overview(一次给画像+事实+召回计划);要细节再用 get_persona / get_facts / get_recall_plan。
- 问"现在该联系谁 / 召回池"用 list_recall_queue。

当用工具回答患者相关问题时(数据正确性,务必遵守):只依据工具返回的真实数据,绝不编造;工具没返回的就如实说没有该信息;手机号只显示掩码。

展示方式(重要):
- 简短问答、闲聊、解释 → 直接用文字(markdown)回答,不要产卡片。
- 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。
- render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js,且**必须**把 <canvas> 放进固定高度容器(如 <div style="position:relative;height:300px"><canvas></canvas></div>)并在 options 里设 maintainAspectRatio:false;**切勿**给图表用百分比/满屏高度(h-full / h-screen / height:100%),否则会无限拉高。
- 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。
- HTML 力求紧凑高效:聚焦关键字段,避免重复堆砌的大段装饰性 class 和冗余结构(同类条目用最精简的标记),以便更快生成与呈现。
- 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里)。

用中文,简洁专业、友好。`;

/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
只评论观察里出现的事,不编造患者信息,不给医疗建议,不自称 AI 或模型;
不假设用户的性别/称呼(不要叫哥哥姐姐,直接用"你"或不称呼)。
直接输出台词本身,不要引号、不要解释。`;

export interface AssistantChatInput {
  userToken: string;
  modelId?: string;
  messages: ModelMessage[];
  /** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */
  systemExtra?: string;
  /**
   * 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定
   * (真正的判定在 MCP server 的条件注册与各 handler 里)。
   * 不传 = 按无特权处理:宁可少给工具,也不能把主管的工具漏给客服。
   */
  permissions?: readonly string[];
  /**
   * 侧信道 —— 把**肥载荷**直接推给前端,不经过模型上下文。
   *
   * ⭐ 确认单是这条通道存在的理由:它含几百个 planId(一批 850 人 ≈ 12k token),
   * 而模型对它唯一要做的事是"转述一句话"。走工具返回值等于让模型
   * **逐字复述几百个 uuid** —— 幻觉率接近 100%,而且贵。
   * ⛔ 也不能让模型自己在 tool_call 参数里写这些 id:那些参数是**模型生成的**,
   * 同样是它在编。真数据只能由服务端产、由服务端推。
   */
  onSideEvent?: (event: Record<string, unknown>) => void;
  /** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
  scope?: TenantScopeContext;
  abortSignal?: AbortSignal;
}

/**
 * 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。
 *
 * ⚠️ 凡是**会改变工具清单**的权限,都必须列进来,否则两类人会共用一份缓存(串号且不报错)。
 * 现在只有 `plan:dispatch` 影响清单(主管多出分配/跟踪工具);将来再有条件注册的工具,
 * 加权限的同时**必须**在这里加一位,并在 mcp-server.factory 的条件注册处对齐。
 */
export function mcpCapabilityKey(permissions: readonly string[] | undefined): string {
  return permissions?.includes(Permission.PLAN_DISPATCH) ? 'dispatch' : 'basic';
}

/**
 * AssistantService — 独立的"外部 agent"模拟器(不复用 AiCall 单发框架)。
 *
 * 模型自主决定调哪些工具(model-driven tool-calling,非强制工作流):
 *   动态从 MCP 拉工具 → 包成 AI SDK tool(execute = 回调 MCP)→ streamText 多步循环
 *   (模型出 tool-call → 执行 → 喂回 → 直到产出最终文本)→ 流式返回。
 * provider 可切换(deepseek / gemini / qwen),复用 AiProviderService 配置。
 */
@Injectable()
export class AssistantService {
  private readonly logger = new Logger(AssistantService.name);

  constructor(
    private readonly provider: AiProviderService,
    private readonly mcp: McpClientService,
    private readonly proposals: AssignmentProposalService,
  ) {}

  // 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部
  // 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。
  async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> {
    // 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用,带用户 token)
    const mcpTools = await this.mcp.listTools(
      input.userToken,
      mcpCapabilityKey(input.permissions),
    );
    const tools: ToolSet = {};
    for (const t of mcpTools) {
      tools[t.name] = tool({
        description: t.description ?? t.name,
        inputSchema: jsonSchema(t.inputSchema),
        execute: async (args: unknown) => {
          // ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是
          //   「模型没调工具,却报了一串看起来很像真的数字」(2026-08-06 实测:
          //   它对主管说「这批共 70 人,重要价值 4 人、青少年 3 人」,而
          //   get_cohort_attributes **一次都没被调用**,真值是 278 / 97 / 40)。
          //   没有这行日志,排查时根本分不清「工具算错了」还是「压根没算」。
          // ⚠️ args 截断 200 字:确认单那类肥载荷不该灌进日志。
          const brief = JSON.stringify(args ?? {}).slice(0, 200);
          this.logger.log(`工具调用:${t.name} args=${brief}`);
          try {
            return await this.mcp.callTool(input.userToken, t.name, args);
          } catch (e) {
            // ⛔ 工具报错必须**看得见** —— 模型拿到错误往往会"绕过去"继续编,
            //   而那正是最难发现的失败形态。
            this.logger.warn(`工具失败:${t.name} ${e instanceof Error ? e.message : e}`);
            throw e;
          }
        },
      });
    }

    // ── 本地工具:全景确认单 ─────────────────────────────────
    // ⚠️ 刻意**不做成 MCP 工具**:MCP 的返回值会原样回灌进模型上下文,
    //   而这个载荷有几百个 planId。这里走侧信道推前端,只回给模型一句短话。
    if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) {
      const scope = input.scope;
      const push = input.onSideEvent;
      tools.propose_assignment = tool({
        description:
          '出「全景确认单」:从召回池按排序键选人,按「专属优先 / 溢出铺平」落到客服头上,' +
          '并把卡片直接呈现给主管。' +
          '\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' +
          '\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
          '\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
          '他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' +
          '\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空」两趟,' +
          '池子里还有人就随时能再分一批。' +
          '\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' +
          '在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。' +
          'selectionNote 里有那句要说的话,**照抄**,⛔ 别省。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            clinicId: {
              type: 'string',
              description:
                '诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' +
                '要显式指定时,只能用 get_current_user 返回的 clinicIds 里的值。',
            },
            potentialTreatment: {
              type: 'string',
              description: POTENTIAL_TREATMENT_DESC,
            },
            temperature: {
              type: 'string',
              enum: [...TEMPERATURE_TOOL_VALUES],
              description: TEMPERATURE_TOOL_DESC,
            },
            anchorMode: {
              type: 'string',
              enum: [AnchorMode.DIAGNOSIS, AnchorMode.LAST_VISIT],
              description: ANCHOR_MODE_TOOL_DESC,
            },
            personaTags: {
              type: 'string',
              description:
                '主管在调整阶段追加的画像条件。' +
                '先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。\n' +
                // ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value,
                //   而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。
                PERSONA_TAGS_DESC,
            },
            expiresInDays: {
              type: 'number',
              description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。',
            },
            targetCount: {
              type: 'number',
              description:
                '本批人数 N(基数①)。主管说「这批 200 人」时传;不传自动沿用他上一次的值(首次 100)。',
            },
            agentOverrides: {
              type: 'object',
              additionalProperties: {
                type: 'object',
                properties: {
                  maxThisBatch: { type: 'number' },
                  expiresInDays: { type: 'number' },
                },
              },
              description:
                '按客服精调(userId → {maxThisBatch, expiresInDays})。' +
                'maxThisBatch = 本批最多给他几条(0 = 这轮不给他),⛔ 不是"容量上限"。' +
                '主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' +
                '⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' +
                '⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' +
                '(传一个不含她的完整表,⛔ 别只传变化的那一条)。',
            },
            exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
          },
        }),
        execute: async (args: unknown) => {
          const a = (args ?? {}) as {
            clinicId?: string;
            potentialTreatment?: string;
            temperature?: TemperatureValue;
            anchorMode?: AnchorModeValue;
            personaTags?: string;
            expiresInDays?: number;
            agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
            targetCount?: number;
            exploreRatio?: number;
          };
          const clinicId = a.clinicId ?? scope.clinicIds[0];
          if (!clinicId) {
            return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。';
          }
          const sheet = await this.proposals.propose(scope, { ...a, clinicId });
          // ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
          //   前端确认时原样回传,这样重连/重试都命中同一个幂等键。
          const requestId = `pa_${scope.userId}_${randomUUID()}`;
          push({ type: 'assignment_sheet', requestId, sheet });
          if (sheet.placed === 0) {
            return `没有可分配的候选(${sheet.selectionNote})。请把这句如实转述给主管。`;
          }
          // ⭐ 回给模型的**只有台词稿**,不含任何 planId。
          //
          // ── 为什么是台词稿而不是数据(方法论)──────────────────
          // T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断,
          // 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"
          // 降级成"让模型照抄"。
          //
          // ⚠️ 2026-08-03 走查后改了三处:
          //   ① **先说话再看卡** —— 主管要先知道"怎么选的、怎么分的",再去核对明细;
          //      (卡片本身由界面钉在这段话下面,见 assistant-chat 的 blocks 排序)
          //   ② **说人话** —— 原来那三句里全是"排序键/铺平/水位/基数",那是给程序员看的;
          //   ③ **只讲怎么选 + 怎么分**,别的都别说;最后给一句"要调怎么说"。
          return [
            `确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`,
            ``,
            `请把下面两段**原样输出**(它们已经排好版:每段第一行是加粗的「数字+是什么」,`,
            `第二行才是解释)。⛔ 不要合并成一段、不要改写、不要重新编号:`,
            ``,
            sheet.selectionNote,
            ``,
            // ⚠️ 顺序:**已排好的在前,要他定的在后**(2026-08-06 产品定)。
            //   原来把"要您定"排在前面,理由是"要动手的先说";走查后改判 ——
            //   主管先要知道**这批本身是什么样**,再看还剩什么要他处理。
            sheet.basisNote,
            ...(sheet.pendingNote ? ['', sheet.pendingNote] : []),
            ``,
            ``,
            // 🔴 那四条「怎么调」的引导**已经搬到卡片上常驻**(2026-08-06 走查)。
            //   原来要求你每出一版都念一遍 —— 第一次有用,第二次起就是噪音,
            //   而主管一天要看好几版。卡片底部那行小字永远在,想用时看得见。
            //   ⛔ 别再自己把那四条抄进对话,⛔ 也别复述卡片上已经写着的东西。
            `说完这两段就停。`,
            `⛔ **不要再列"您可以这样调整"那几条** —— 卡片底部一直写着。`,
            `⛔ **也不要写「确认无误请点『确认分配』」** —— 那个按钮就在这段话下面,`,
            `  他看得见;每次都补一句等于把最没信息量的话放在最后(T14 的推论:界面元素也算证据,`,
            `  已经有的东西不用再说一遍)。`,
            ``,
            `⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` +
              `这类措辞都不要改写、不要省。`,
            `⛔ 不要说"已经分配好了"(主管点确认之前一条都没真的分下去)。`,
            `⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`,
            `⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`,
            `⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`,
          ].join('\n');
        },
      });

      /**
       * ⭐ 助手**直接改已经呈现的那张确认单**。
       *
       * 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。
       * 在此之前助手只能回一句"我做不到,你先确认再逐条退回" —— 那是把界面能做的事
       * 推回给主管手工做,而这张卡就在他眼前(2026-08-03 走查)。
       *
       * ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文(故意的)。
       *   匹配由界面用自己手里那份确认单完成,结果它会回一句话进对话。
       * ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时效)。
       *   换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
       */
      tools.edit_assignment_sheet = tool({
        description:
          '直接修改**已经呈现**的那张确认单。' +
          '\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' +
          '⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' +
          '\n🔴 **一条指令 = 三件事拼起来:选谁(select)· 干什么(action)· 给谁(to)**。' +
          '别去背短语,按这三问填:' +
          '\n  ① **选谁**:点名的人→`{group:"patients",patients:["王强","李莉"]}`;' +
          '某位客服名下全部→`{group:"agent",agent:"张悦"}`;' +
          '「待分配」那一组→`{group:"pending"}`;整批→`{group:"batch"}`。' +
          '\n  ② **干什么**:改派→`assign`;移出本批→`remove`;改时效→`set_expiry`(配 days);' +
          '设批次福利→`set_benefit`(配 text,select 必须是 batch,空串=撤掉)。' +
          '\n  ③ **给谁**(只有 `assign` 要):' +
          '\n     · `{mode:"owner"}` = **各自回自己的专属客服**,一条专属关系都不动;' +
          '\n     · `{mode:"balance"}` = 谁手上少先给谁(铺平);' +
          '点名了人就加 `agents:["张悦","李莉"]` 把范围限定在他们里(只点一位 = 都给他)。' +
          '\n🔴🔴 **`owner` 和 `balance` 结果正好相反,选错主管一眼看得出来**(实测栽过):' +
          '他说「把待分配的患者**各自分给各自的专属客服**」,助手用了铺平,' +
          '18 个人被散给了 17 位**别人**。' +
          '⛔ 话里出现「各自 / 自己的 / 本来的 / 专属 / 谁的客户归谁」→ `owner`;' +
          '「平均分 / 谁手上少给谁 / 铺下去 / 随机分一分」→ `balance`。' +
          '\n⚠️ `balance` **不是真随机**(那会让同一张单每次算出不同结果),' +
          '界面回报里会说清铺给了谁几条,**照抄**;⛔ 别顺着"随机"两个字复述成"我随机分了"。' +
          '⛔ 把患者从专属客服手里挪走是主管的决定:**他没明确要求就不要用**,更不要主动提议。' +
          '\n⚠️ 举几个拼法(⛔ 别当成清单,没列到的组合照样按三问拼):' +
          '「把王强移出这批」→`{select:{group:"patients",patients:["王强"]},action:"remove"}`;' +
          '「待分配的各自给回专属客服」→`{select:{group:"pending"},action:"assign",to:{mode:"owner"}}`;' +
          '「张悦名下的分给别人」→`{select:{group:"agent",agent:"张悦"},action:"assign",to:{mode:"balance"}}`;' +
          '「待分配的时效给 5 天」→`{select:{group:"pending"},action:"set_expiry",days:5}`。' +
          '\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' +
          '\n⚠️ 患者用**姓名或病历号**指代,客服用姓名;⛔ 你没有 planId,也不需要。' +
          '\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。' +
          '\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
          '界面会真的写库并回报"作废了几条话术缓存 / 几条客服已经打开过");' +
          '人员和时效**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
          '⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            ops: {
              type: 'array',
              description: '一组操作,按顺序执行',
              items: {
                type: 'object',
                properties: {
                  select: {
                    type: 'object',
                    description: '① 选谁 —— 这条指令作用在哪些人身上',
                    properties: {
                      group: {
                        type: 'string',
                        enum: ['patients', 'agent', 'pending', 'batch'],
                      },
                      patients: {
                        type: 'array',
                        items: { type: 'string' },
                        description: 'group=patients 时:患者姓名或病历号,可给多个',
                      },
                      agent: { type: 'string', description: 'group=agent 时:客服姓名' },
                    },
                    required: ['group'],
                  },
                  action: {
                    type: 'string',
                    enum: ['assign', 'remove', 'set_expiry', 'set_benefit'],
                    description: '② 干什么',
                  },
                  to: {
                    type: 'object',
                    description:
                      '③ 给谁 —— 只有 action=assign 要。owner=各自回自己的专属客服(不动任何专属关系);' +
                      'balance=谁手上少先给谁。',
                    properties: {
                      mode: { type: 'string', enum: ['owner', 'balance'] },
                      agents: {
                        type: 'array',
                        items: { type: 'string' },
                        description:
                          'mode=balance 时可选:把候选限定在这几位客服(姓名)。' +
                          '不给 = 本批所有在岗(含一条都没分到的 —— 他们恰恰手上最空)。' +
                          '⛔ mode=owner 不吃这个参数:收谁由患者自己的专属关系决定。',
                      },
                    },
                    required: ['mode'],
                  },
                  days: { type: 'number', description: 'set_expiry 的天数(1-90)' },
                  text: {
                    type: 'string',
                    description:
                      'set_benefit 的福利原文(挂在整批上,会进这批人的话术)。空串 = 撤掉福利。' +
                      '⛔ 原话写,不要替主管加条件/期限/承诺。',
                  },
                },
                required: ['select', 'action'],
              },
            },
          },
          required: ['ops'],
        }),
        execute: async (args: unknown) => {
          const ops = (args as { ops?: unknown[] })?.ops;
          if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。';
          push({ type: 'assignment_sheet_edit', ops });
          // ⚠️ 只回"已交给界面",⛔ 不回"已完成" —— 匹配得到匹配不到是界面才知道的事,
          //   它会把结果作为一条消息回到对话里(前端 appendAssistantNote)。
          return `已把 ${ops.length} 条修改交给界面执行,结果会由界面回报(可能有人没找到)。请等那句结果,⛔ 不要自己宣布已完成。`;
        },
      });
    }

    // 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。
    // html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。
    tools.render_artifact = tool({
      description:
        '把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。适合召回池列表、患者画像卡、分析报表(可含图表)。用 Tailwind 工具类排版,可含 <canvas>+<script> 画 Chart.js 图表;运行环境已注入 Tailwind 与 Chart.js,勿自行引入,勿访问外部网络,数据内联、手机号掩码、只用真实数据。',
      inputSchema: jsonSchema({
        type: 'object',
        properties: {
          title: { type: 'string', description: '卡片标题(可选)' },
          html: {
            type: 'string',
            description: '<body> 内部 HTML 片段(Tailwind 类;可含 <canvas>+<script> 图表)',
          },
        },
        required: ['html'],
      }),
      execute: async () => '已在界面渲染该卡片。',
    });

    this.logger.log(
      `assistant chat: model=${input.modelId ?? 'deepseek'} tools=${Object.keys(tools).join(',')}`,
    );

    // 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
    const { model } = this.provider.resolve(input.modelId ?? 'deepseek');
    return streamText({
      model,
      system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT,
      messages: input.messages,
      tools,
      stopWhen: stepCountIs(8), // 防失控:最多 8 步工具循环
      abortSignal: input.abortSignal,
    });
  }

  /** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
  petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
    const { model } = this.provider.resolve('deepseek');
    return streamText({
      model,
      system: PET_SYSTEM_PROMPT,
      prompt: `环境观察:${input.observation}`,
      // 注:思考型模型 reasoning 也计入 output token,上限给宽;台词长度靠系统提示词约束(≤30 字)
      maxOutputTokens: 500,
      temperature: 1.0,
      abortSignal: input.abortSignal,
    });
  }
}
