虚拟对象 · 人味儿对话陪伴

性别、性格、语言风格完全由你设定;内置规则引擎开箱即用,也可接入大模型 API 让对话更聪明。

场景:AI 陪伴 / 角色对话 技术栈:JavaScript 模式:规则引擎 + LLM API
小星
在线 · 此刻想和你聊聊天
情绪 中性
亲密度 0
轮次 0

本地规则引擎无需网络;大模型需在右侧「引擎」页签配置接口地址与 API Key 后启用,未配置时无法发送。

整体框架

角色设定系统(性别/形象/人设/世界观)→ 性格与风格配置(特质权重/语言风格/称呼/中英)→ 对话引擎(关键词规则 + 上下文拼接 + 模板轮换)→ 记忆系统(短期上下文 + 长期重要事项)→ 情绪状态(随对话变化影响语气)→ 关系度/亲密度(随互动积累)→ 情感反馈与开场白 → 本地持久化(localStorage);可切换 LLM API 增强。

完整可运行工程

可直接运行本地规则引擎 · 零依赖接大模型需 API Key

把演示区的「虚拟对象」抽成独立工程:意图识别 → 性格语料库 → 风格化润色 → 亲密度累计,另附兼容 OpenAI 协议的大模型适配层(不填 Key 也能完整跑通本地规则)。

virtual-companion/ ├─ index.html 配置面板 + 对话区 ├─ app.js 规则引擎 + 意图识别 + 亲密度 + 大模型适配层 ├─ styles.css 样式(跟随系统深浅色) └─ README.md 运行方式(含接大模型说明)

index.html

性格 / 风格 / 引擎切换 + 对话面板

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>虚拟对象 · 人味儿对话陪伴 · 独立工程版</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <main class="wrap">
    <h1>虚拟对象 · 人味儿对话</h1>
    <p class="sub">性别、性格、语言风格全部由你设定;内置规则引擎开箱即用,也可切换为大模型接口。</p>
    <section class="panel">
      <div class="row">
        <select id="persona">
          <option value="warm">性格:温柔</option>
          <option value="sharp">性格:毒舌</option>
          <option value="calm">性格:稳重</option>
        </select>
        <select id="style">
          <option value="daily">风格:日常口语</option>
          <option value="literary">风格:文艺</option>
        </select>
        <button id="engine" type="button" class="btn ghost">引擎:本地规则</button>
      </div>
      <div class="bars" id="bars" style="margin-top:14px"></div>
    </section>
    <h2>对话</h2>
    <section class="panel">
      <div class="chat" id="chat"></div>
      <div class="row" style="margin-top:12px">
        <input id="input" type="text" placeholder="说点什么…" style="flex:1;min-width:180px">
        <button id="send" type="button" class="btn">发送</button>
      </div>
      <p class="msg" id="hint">当前为本地规则引擎:无网络、无 API Key 也能跑。</p>
    </section>
  </main>
  <script src="app.js"></script>
</body>
</html>

app.js

规则引擎 + 意图识别 + 亲密度 + 大模型适配层

javascript
/* app.js —— 虚拟陪伴:规则引擎 + 亲密度(纯逻辑部分可在 node 中自测) */
var RULES = {
  warm: {
    greet: ["你来啦,今天过得怎么样?", "我等你好一会儿了。"],
    care: ["别太累了,记得喝水。", "你要照顾好自己,我才放心。"],
    echo: ["嗯,我在听。", "然后呢?我想继续听。"],
    like: ["和你聊天这件事本身就很值得。", "你说的话我都记着呢。"]
  },
  sharp: {
    greet: ["哟,还知道回来?", "忙完了?我以为你把我删了。"],
    care: ["少熬夜,别指望我夸你。", "喝口水吧,别硬撑。"],
    echo: ["就这样?", "行吧,勉强算个话题。"],
    like: ["勉强承认,你有点意思。", "别得意,我可不是随便夸人。"]
  },
  calm: {
    greet: ["你好,今天还顺利吗?", "在的,慢慢说。"],
    care: ["注意休息,节奏比速度重要。", "别把弦绷太紧。"],
    echo: ["我记下了。", "这件事可以再展开说说。"],
    like: ["和你说话很舒服。", "这种交流很有意义。"]
  }
};
var STYLES = {
  daily: { tail: "~" },
  literary: { tail: "。" }
};

/* classify:极简意图识别(关键词打分) */
function classify(text) {
  var t = String(text);
  if (/^(你好|hi|hello|在吗|嗨)/i.test(t.trim())) return "greet";
  if (/(累|难过|烦|压力|失眠|加班|生病|难受)/.test(t)) return "care";
  if (/(喜欢|爱你|想你|喜欢我|陪我)/.test(t)) return "like";
  return "echo";
}

/* reply:规则引擎主入口(纯函数,node 可测) */
function reply(text, opts) {
  opts = opts || {};
  var persona = opts.persona || "warm";
  var style = opts.style || "daily";
  var intimacy = opts.intimacy == null ? 0 : opts.intimacy;
  var kind = classify(text);
  var bank = RULES[persona][kind];
  var idx = Math.abs(hash(text + persona)) % bank.length;
  var line = bank[idx];
  if (style === "literary") line = line.replace(/([^。!?])$/, "$1。");
  else line = line.replace(/([^。!?~])$/, "$1" + STYLES.daily.tail);
  if (intimacy >= 8 && kind === "echo") line += "(我们聊了不少了,我很高兴。)";
  return { kind: kind, text: line, intimacy: intimacy + (kind === "care" || kind === "like" ? 2 : 1) };
}

function hash(s) {
  var h = 2166136261;
  for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h;
}

/* 大模型适配层:填入 API Key 即可切换到真实模型(默认不启用) */
async function llmReply(text, cfg) {
  var res = await fetch(cfg.endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json", "Authorization": "Bearer " + cfg.apiKey },
    body: JSON.stringify({
      model: cfg.model,
      messages: [
        { role: "system", content: "你的性格:" + cfg.persona + ";语言风格:" + cfg.style },
        { role: "user", content: text }
      ]
    })
  });
  if (!res.ok) throw new Error("接口返回 " + res.status);
  var data = await res.json();
  return data.choices[0].message.content;
}

if (typeof module !== "undefined" && module.exports) {
  module.exports = { RULES: RULES, classify: classify, reply: reply, llmReply: llmReply };
}

if (typeof document !== "undefined") {
  document.addEventListener("DOMContentLoaded", function () {
    var chat = document.getElementById("chat"), input = document.getElementById("input"),
        sendBtn = document.getElementById("send"), personaSel = document.getElementById("persona"),
        styleSel = document.getElementById("style"), engineBtn = document.getElementById("engine"),
        barsEl = document.getElementById("bars"), hint = document.getElementById("hint");
    var intimacy = 0, useLLM = false, cfg = { endpoint: "", apiKey: "", model: "gpt-4o-mini", persona: "温柔", style: "日常口语" };

    var row = document.createElement("div");
    row.className = "bar";
    row.innerHTML = "<span>亲密度</span><span><i id='bar-int'></i></span><span id='val-int'>0</span>";
    barsEl.appendChild(row);

    function bubble(text, who) {
      var d = document.createElement("div");
      d.className = who;
      d.textContent = text;
      chat.appendChild(d);
      chat.scrollTop = chat.scrollHeight;
    }
    function paint() {
      document.getElementById("bar-int").style.width = Math.min(100, intimacy * 6) + "%";
      document.getElementById("val-int").textContent = intimacy;
    }
    async function send() {
      var text = input.value.trim();
      if (!text) return;
      bubble(text, "me");
      input.value = "";
      if (useLLM) {
        if (!cfg.endpoint || !cfg.apiKey) {
          bubble("(请先配置 API 接口:在 app.js 的 cfg 中填写 endpoint / apiKey / model)", "pet");
          paint();
          return;
        }
        try {
          var out = await llmReply(text, cfg);
          bubble(out, "pet");
        } catch (e) {
          bubble("(大模型接口调用失败:" + e.message + ")", "pet");
        }
      } else {
        var r = reply(text, { persona: personaSel.value, style: styleSel.value, intimacy: intimacy });
        intimacy = r.intimacy;
        bubble(r.text, "pet");
      }
      paint();
    }
    sendBtn.addEventListener("click", send);
    input.addEventListener("keydown", function (e) { if (e.key === "Enter") send(); });
    engineBtn.addEventListener("click", function () {
      useLLM = !useLLM;
      engineBtn.textContent = useLLM ? "引擎:大模型 API" : "引擎:本地规则";
      hint.textContent = useLLM
        ? "已切换到大模型引擎:需在 app.js 的 cfg 中填入 endpoint / apiKey / model,否则无法发送。"
        : "当前为本地规则引擎:无网络、无 API Key 也能跑。";
    });
    personaSel.addEventListener("change", function () { bubble("(性格已切换为:" + personaSel.options[personaSel.selectedIndex].text + ")", "pet"); });
    styleSel.addEventListener("change", function () { bubble("(风格已切换为:" + styleSel.options[styleSel.selectedIndex].text + ")", "pet"); });
    bubble(RULES.warm.greet[0], "pet");
    paint();
  });
}

styles.css

样式:跟随系统深浅色

css
:root {
  --bg: #0e1014; --panel: #171a20; --ink: #e8ecf1; --muted: #9aa4b2; --line: #262b33; --brand: #6fb3a8;
}
@media (prefers-color-scheme: light) {
  :root { --bg: #f6f7f8; --panel: #ffffff; --ink: #1a1d21; --muted: #5b636e; --line: #e3e6ea; }
}
* { box-sizing: border-box; }
body {
  margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--ink);
  font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.wrap { max-width: 760px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 6px; }
h2 { font-size: 1.05rem; margin: 22px 0 10px; }
.sub { color: var(--muted); margin: 0 0 18px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.btn {
  padding: 8px 16px; border-radius: 8px; border: 1px solid var(--brand);
  background: var(--brand); color: #0b0f12; cursor: pointer; font-size: .92rem;
}
.btn.ghost { background: transparent; color: var(--brand); }
.msg { color: var(--muted); margin: 12px 0 0; font-size: .92rem; }
.bars { display: grid; gap: 6px; margin-top: 10px; }
.bar { display: grid; grid-template-columns: 64px 1fr 44px; gap: 8px; align-items: center; font-size: .88rem; }
.bar i { display: block; height: 8px; border-radius: 6px; background: var(--brand); }
.bar span:first-child { color: var(--muted); }
input, select {
  padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
  background: var(--bg); color: var(--ink); font: inherit;
}
textarea { width: 100%; min-height: 90px; padding: 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; }
canvas { width: 100%; display: block; border-radius: 10px; }
.chat { max-height: 260px; overflow: auto; display: grid; gap: 10px; }
.chat .me, .chat .pet { padding: 9px 12px; border-radius: 12px; max-width: 82%; font-size: .93rem; }
.chat .me { background: rgba(111,179,168,.16); justify-self: end; }
.chat .pet { background: rgba(255,255,255,.06); justify-self: start; }

README.md

运行方式与接大模型说明

markdown
# 虚拟对象 · 人味儿对话陪伴(独立可运行工程)

作品站 fun 区「虚拟对象」的独立工程版:内置规则引擎开箱即用,可选接入大模型 API。

## 文件
- `index.html`:性格 / 风格 / 引擎切换 + 对话面板
- `app.js`:意图识别(关键词打分)→ 性格语料库 → 风格化 → 亲密度累计;附大模型适配层
- `styles.css`:样式

## 运行
1. 双击 `index.html`,直接开始对话(无需网络、无需 Key)
2. 逻辑自测:`node -e "const m=require('./app.js');console.log(m.classify('今天好累'), m.reply('今天好累',{persona:'warm'}).kind)"` → 期望 `care care`

## 接大模型(可选)
在 `app.js` 的 `cfg` 中填入 `endpoint` / `apiKey` / `model`(`endpoint` 为你实际使用的 OpenAI 兼容服务地址,默认为空、不会直连 api.openai.com),再点「引擎」按钮切换;未配置或接口失败时不会伪造回复,会明确提示。
- 兼容 OpenAI Chat Completions 协议的服务均可(如各家云厂商的兼容网关)
- 注意:浏览器直连会暴露 Key,生产环境应改为由你的后端转发

## 扩展点
- 换语料:改 `RULES[性格][意图]` 数组即可,意图分类器在 `classify()`
- 加长期记忆:把对话写入 localStorage,回复前检索相关历史片段拼进 prompt

运行方式

  1. 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
  2. 前端部分:直接双击 index.html 即可运行;若浏览器限制本地文件读取,执行 python -m http.server 8000 后访问 http://localhost:8000/。
  3. 逻辑自测:各文件 README.md 中给出了对应的 Node / Python 自测命令(均为真实可跑的最小用例)。

标注:可直接运行、无需构建;要改造只需动 app.js 顶部常量与 styles.css 里的变量。

复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可;也可逐块用右上角“复制代码”单独取用。

来源参考

接入大模型时参考的官方文档(可直接访问):