虚拟对象 · 人味儿对话陪伴
性别、性格、语言风格完全由你设定;内置规则引擎开箱即用,也可接入大模型 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
运行方式
- 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
- 前端部分:直接双击
index.html即可运行;若浏览器限制本地文件读取,执行python -m http.server 8000后访问http://localhost:8000/。 - 逻辑自测:各文件
README.md中给出了对应的 Node / Python 自测命令(均为真实可跑的最小用例)。
标注:可直接运行、无需构建;要改造只需动 app.js 顶部常量与 styles.css 里的变量。
来源参考
接入大模型时参考的官方文档(可直接访问):