1168 lines
41 KiB
HTML
1168 lines
41 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-Hans">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>本地 ChatGPT 客户端(无后端)</title>
|
||
|
||
<!-- Tailwind Play CDN (快速开发用) -->
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
|
||
<!-- 小工具库 CDN -->
|
||
<script
|
||
src="https://unpkg.com/alpinejs@3.12.0/dist/cdn.min.js"
|
||
defer
|
||
></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/localforage/1.10.0/localforage.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/uuid@9.0.0/dist/umd/uuidv4.min.js"></script>
|
||
|
||
<style>
|
||
/* Minor visual tuning */
|
||
html,
|
||
body,
|
||
#app {
|
||
height: 100%;
|
||
}
|
||
.scrollbar-thin::-webkit-scrollbar {
|
||
height: 8px;
|
||
width: 8px;
|
||
}
|
||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-radius: 8px;
|
||
}
|
||
pre {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="h-full bg-slate-50 text-slate-900">
|
||
<div
|
||
id="app"
|
||
x-data="chatApp()"
|
||
x-init="init()"
|
||
class="h-full flex flex-col"
|
||
>
|
||
<!-- Header -->
|
||
<header
|
||
class="flex items-center justify-between gap-4 px-4 py-3 bg-white border-b"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<h1 class="text-lg font-semibold">本地 ChatGPT 客户端</h1>
|
||
<span class="text-sm text-slate-500"
|
||
>单文件 · LocalStorage · 导入导出 · Prompt 记忆 ✅</span
|
||
>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<label class="text-xs text-slate-600 mr-2">模型</label>
|
||
<input
|
||
x-model="settings.model"
|
||
class="px-2 py-1 border rounded"
|
||
placeholder="gpt-4o-mini / gpt-4o / gpt-3.5-turbo"
|
||
/>
|
||
<input
|
||
type="password"
|
||
x-model="apiKeyInput"
|
||
placeholder="粘贴你的 OpenAI Key(sk-...)"
|
||
class="px-2 py-1 border rounded w-72"
|
||
/>
|
||
<label class="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" x-model="settings.saveKey" class="h-4 w-4" />
|
||
保存 API Key(本地)🔐
|
||
</label>
|
||
<button
|
||
@click="toggleSettingsModal"
|
||
class="px-3 py-1 bg-indigo-600 text-white rounded"
|
||
>
|
||
设置
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Main -->
|
||
<div class="flex flex-1 overflow-hidden">
|
||
<!-- Left: conversations + templates -->
|
||
<aside class="w-72 border-r bg-white overflow-auto p-3">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<button
|
||
@click="newConversation()"
|
||
class="w-full py-2 bg-emerald-500 text-white rounded"
|
||
>
|
||
新会话 ➕
|
||
</button>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<input
|
||
x-model="leftSearch"
|
||
placeholder="搜索会话 / Prompt"
|
||
class="w-full px-2 py-1 border rounded"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 class="text-sm font-medium text-slate-600 mb-2">会话</h3>
|
||
<template x-for="c in filteredConversations" :key="c.id">
|
||
<div
|
||
@click="selectConversation(c.id)"
|
||
:class="{'bg-sky-50': currentConversation && currentConversation.id===c.id}"
|
||
class="p-2 rounded mb-1 cursor-pointer hover:bg-slate-50"
|
||
>
|
||
<div class="flex justify-between items-center">
|
||
<div
|
||
class="text-sm font-medium"
|
||
x-text="c.title || '未命名会话'"
|
||
></div>
|
||
<div
|
||
class="text-xs text-slate-500"
|
||
x-text="new Date(c.updatedAt).toLocaleString()"
|
||
></div>
|
||
</div>
|
||
<div
|
||
class="text-xs text-slate-400"
|
||
x-text="(c.messages?.length || 0) + ' 条消息'"
|
||
></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<hr class="my-3" />
|
||
|
||
<div>
|
||
<h3 class="text-sm font-medium text-slate-600 mb-2">
|
||
Prompt 模板 / 记忆
|
||
</h3>
|
||
<div class="space-y-2">
|
||
<template x-for="p in filteredPrompts" :key="p.id">
|
||
<div class="p-2 border rounded bg-white hover:shadow-sm">
|
||
<div class="flex justify-between items-center">
|
||
<div class="text-sm font-medium" x-text="p.name"></div>
|
||
<div class="flex gap-1 items-center">
|
||
<button
|
||
@click.stop="applyPrompt(p)"
|
||
class="px-2 py-0.5 text-xs bg-sky-500 text-white rounded"
|
||
>
|
||
应用
|
||
</button>
|
||
<button
|
||
@click.stop="editPrompt(p)"
|
||
class="px-2 py-0.5 text-xs border rounded"
|
||
>
|
||
编辑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="text-xs text-slate-500 mt-1"
|
||
x-text="p.tags?.join(', ')"
|
||
></div>
|
||
</div>
|
||
</template>
|
||
<div class="mt-2">
|
||
<button
|
||
@click="createPrompt()"
|
||
class="w-full py-1 border rounded"
|
||
>
|
||
新增 Prompt 模板
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-3" />
|
||
<div class="text-xs text-slate-500 space-y-2">
|
||
<div>导出 / 导入:</div>
|
||
<div class="flex gap-2">
|
||
<button
|
||
@click="exportAll()"
|
||
class="px-2 py-1 border rounded text-sm"
|
||
>
|
||
导出全部
|
||
</button>
|
||
<label class="px-2 py-1 border rounded cursor-pointer text-sm"
|
||
>导入
|
||
<input
|
||
@change="importAll"
|
||
type="file"
|
||
accept=".json"
|
||
class="hidden"
|
||
/></label>
|
||
</div>
|
||
<div class="mt-2">
|
||
<button
|
||
@click="clearLocal(false)"
|
||
class="px-2 py-1 border rounded text-sm"
|
||
>
|
||
清空会话(保留prompts)
|
||
</button>
|
||
<button
|
||
@click="clearLocal(true)"
|
||
class="px-2 py-1 border rounded text-sm"
|
||
>
|
||
完全重置(全部)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Center: chat -->
|
||
<main class="flex-1 flex flex-col">
|
||
<div class="flex-1 overflow-auto p-6" id="chatArea">
|
||
<template x-if="!currentConversation">
|
||
<div
|
||
class="h-full w-full flex items-center justify-center text-slate-400"
|
||
>
|
||
请选择会话或新建一个 ➕
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="currentConversation">
|
||
<div class="space-y-4">
|
||
<div class="flex justify-between items-start">
|
||
<div>
|
||
<h2
|
||
class="text-xl font-semibold"
|
||
x-text="currentConversation.title || '未命名会话'"
|
||
></h2>
|
||
<div class="text-xs text-slate-500">
|
||
模型: <span x-text="settings.model"></span> · 更新:
|
||
<span
|
||
x-text="new Date(currentConversation.updatedAt).toLocaleString()"
|
||
></span>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button
|
||
@click="exportConversation(currentConversation.id)"
|
||
class="px-3 py-1 border rounded text-sm"
|
||
>
|
||
导出会话
|
||
</button>
|
||
<button
|
||
@click="renameConversation()"
|
||
class="px-3 py-1 border rounded text-sm"
|
||
>
|
||
重命名
|
||
</button>
|
||
<button
|
||
@click="deleteConversation(currentConversation.id)"
|
||
class="px-3 py-1 border rounded text-sm text-red-600"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2" id="messages">
|
||
<template
|
||
x-for="m in currentConversation.messages"
|
||
:key="m.id"
|
||
>
|
||
<div
|
||
:class="m.role === 'user' ? 'text-right' : 'text-left'"
|
||
>
|
||
<div
|
||
:class="m.role === 'user' ? 'inline-block bg-blue-600 text-white' : 'inline-block bg-white border' "
|
||
class="p-3 rounded max-w-[70%] break-words"
|
||
>
|
||
<div
|
||
class="prose"
|
||
x-html="renderMarkdown(m.content)"
|
||
></div>
|
||
<div
|
||
class="text-xs text-slate-400 mt-1"
|
||
x-text="new Date(m.createdAt).toLocaleString()"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- reply box -->
|
||
<div class="pt-4">
|
||
<div class="mb-2 text-sm text-slate-500">
|
||
Prompt 编辑器(可用自动生成或模板)
|
||
</div>
|
||
<textarea
|
||
x-model="composeText"
|
||
rows="5"
|
||
placeholder="在此输入要发送的内容,或者使用右侧的 Prompt 工具栏自动生成"
|
||
class="w-full p-3 border rounded"
|
||
></textarea>
|
||
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<button
|
||
@click="sendMessage()"
|
||
class="px-4 py-2 bg-indigo-600 text-white rounded"
|
||
>
|
||
发送 ✉️
|
||
</button>
|
||
<button
|
||
@click="streamedSend()"
|
||
class="px-4 py-2 bg-sky-600 text-white rounded"
|
||
>
|
||
启动流式(推荐)⚡
|
||
</button>
|
||
<button
|
||
@click="applySystemPrompt()"
|
||
class="px-3 py-1 border rounded"
|
||
>
|
||
设为 System 并发送
|
||
</button>
|
||
|
||
<div class="ml-auto text-xs text-slate-500">
|
||
估算: <span x-text="estimateCost(composeText)"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Right: Prompt generator / Prompt editor -->
|
||
<aside class="w-96 border-l bg-white p-4 overflow-auto">
|
||
<h3 class="text-sm font-medium mb-3">Prompt 自动生成器 ✨</h3>
|
||
|
||
<div class="space-y-3">
|
||
<div>
|
||
<label class="block text-xs text-slate-600">任务类型</label>
|
||
<select
|
||
x-model="promptGen.task"
|
||
class="w-full p-2 border rounded"
|
||
>
|
||
<option value="summarize">摘要 (summarize)</option>
|
||
<option value="translate">翻译 (translate)</option>
|
||
<option value="code">生成代码 (code)</option>
|
||
<option value="critique">审查/批评 (critique)</option>
|
||
<option value="plan">生成计划 (plan)</option>
|
||
<option value="qa">问答 / 交互式 (qa)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs text-slate-600"
|
||
>目标语言 / 受众</label
|
||
>
|
||
<input
|
||
x-model="promptGen.audience"
|
||
placeholder="例如:简短中文、给产品经理、面向初学者"
|
||
class="w-full p-2 border rounded"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs text-slate-600"
|
||
>所需格式(如:Markdown / 列表 / 代码块)</label
|
||
>
|
||
<input
|
||
x-model="promptGen.format"
|
||
placeholder="Markdown 列表"
|
||
class="w-full p-2 border rounded"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs text-slate-600">约束(可选)</label>
|
||
<input
|
||
x-model="promptGen.constraints"
|
||
placeholder="字数限制、风格、包含关键点"
|
||
class="w-full p-2 border rounded"
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
<button
|
||
@click="generatePrompt()"
|
||
class="px-3 py-2 bg-emerald-500 text-white rounded"
|
||
>
|
||
生成 Prompt
|
||
</button>
|
||
<button
|
||
@click="quickApplyGenerated()"
|
||
class="px-3 py-2 border rounded"
|
||
>
|
||
应用到编辑器
|
||
</button>
|
||
<button
|
||
@click="saveGeneratedAsPrompt()"
|
||
class="px-3 py-2 border rounded"
|
||
>
|
||
保存为模板
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs text-slate-600">生成结果</label>
|
||
<textarea
|
||
x-model="generatedPrompt"
|
||
rows="6"
|
||
class="w-full p-2 border rounded"
|
||
></textarea>
|
||
</div>
|
||
|
||
<hr />
|
||
|
||
<div>
|
||
<h4 class="text-sm font-medium mb-2">Prompt 编辑 / 评分</h4>
|
||
<div class="space-y-2">
|
||
<label class="block text-xs text-slate-600">模板名称</label>
|
||
<input
|
||
x-model="editingPrompt.name"
|
||
class="w-full p-2 border rounded"
|
||
/>
|
||
<label class="block text-xs text-slate-600"
|
||
>标签(逗号分隔)</label
|
||
>
|
||
<input
|
||
x-model="editingPrompt.tagsInput"
|
||
class="w-full p-2 border rounded"
|
||
/>
|
||
<label class="block text-xs text-slate-600">Prompt 内容</label>
|
||
<textarea
|
||
x-model="editingPrompt.template"
|
||
rows="6"
|
||
class="w-full p-2 border rounded"
|
||
></textarea>
|
||
<div class="flex gap-2">
|
||
<button
|
||
@click="savePrompt(editingPrompt)"
|
||
class="px-3 py-2 bg-indigo-600 text-white rounded"
|
||
>
|
||
保存模板
|
||
</button>
|
||
<button
|
||
@click="improvePrompt(editingPrompt.template)"
|
||
class="px-3 py-2 border rounded"
|
||
>
|
||
用模型改进
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr />
|
||
|
||
<div>
|
||
<h4 class="text-sm font-medium mb-2">设置 & 帮助</h4>
|
||
<div class="text-xs text-slate-600 space-y-2">
|
||
<div>⚠️ 前端调用 OpenAI,请在安全设备上使用。</div>
|
||
<div>提示:你可以把 Prompt 保存为模板,或导出分享给同事。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<!-- Simple modal (settings) -->
|
||
<div
|
||
x-show="settingsModal"
|
||
x-transition
|
||
class="fixed inset-0 flex items-center justify-center bg-black/40"
|
||
>
|
||
<div class="bg-white w-[720px] p-6 rounded shadow">
|
||
<h3 class="text-lg font-semibold mb-2">设置</h3>
|
||
<div class="space-y-3">
|
||
<div>
|
||
<label class="text-sm">是否在 localStorage 保存 API Key</label>
|
||
<div class="text-xs text-slate-500">
|
||
注意:保存意味着任何使用该浏览器的人都能使用你的 Key。
|
||
</div>
|
||
<label class="flex items-center gap-2 mt-1"
|
||
><input type="checkbox" x-model="settings.saveKey" /> 保存 API
|
||
Key</label
|
||
>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm">默认模型</label>
|
||
<input
|
||
x-model="settings.model"
|
||
class="w-full p-2 border rounded"
|
||
/>
|
||
</div>
|
||
<div class="flex justify-end gap-2">
|
||
<button
|
||
@click="settingsModal=false"
|
||
class="px-3 py-1 border rounded"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
@click="applySettings()"
|
||
class="px-3 py-1 bg-indigo-600 text-white rounded"
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast -->
|
||
<div
|
||
x-show="toast"
|
||
x-text="toast"
|
||
class="fixed bottom-4 right-4 bg-black text-white px-4 py-2 rounded shadow"
|
||
></div>
|
||
</div>
|
||
|
||
<script>
|
||
function chatApp() {
|
||
return {
|
||
// state
|
||
conversations: [],
|
||
prompts: [],
|
||
currentConversation: null,
|
||
composeText: "",
|
||
generatedPrompt: "",
|
||
editingPrompt: { id: null, name: "", tagsInput: "", template: "" },
|
||
apiKeyInput: "",
|
||
settingsModal: false,
|
||
toast: "",
|
||
leftSearch: "",
|
||
promptGen: {
|
||
task: "summarize",
|
||
audience: "简短中文",
|
||
format: "Markdown",
|
||
constraints: "",
|
||
},
|
||
settings: { saveKey: false, model: "gpt-4o-mini" },
|
||
settingsKey: "cgpt_settings_v1",
|
||
convoKey: "cgpt_conversations_v1",
|
||
promptsKey: "cgpt_prompts_v1",
|
||
apiKeyStorageKey: "cgpt_api_key_v1",
|
||
|
||
// computed-ish
|
||
init() {
|
||
// load from localforage if available
|
||
this.loadAll();
|
||
// restore saved API key only if settings say so
|
||
localforage
|
||
.getItem(this.settingsKey)
|
||
.then((s) => {
|
||
if (s) {
|
||
try {
|
||
Object.assign(this.settings, JSON.parse(s));
|
||
} catch (e) {}
|
||
}
|
||
})
|
||
.then(() => {
|
||
localforage.getItem(this.apiKeyStorageKey).then((k) => {
|
||
if (k && this.settings.saveKey) {
|
||
this.apiKeyInput = k;
|
||
}
|
||
});
|
||
});
|
||
// lightweight UI focus
|
||
this.$nextTick?.(() => {
|
||
/* optional */
|
||
});
|
||
},
|
||
|
||
async loadAll() {
|
||
try {
|
||
let cs = await localforage.getItem(this.convoKey);
|
||
let ps = await localforage.getItem(this.promptsKey);
|
||
if (cs) this.conversations = cs;
|
||
if (ps) this.prompts = ps;
|
||
if (this.conversations.length)
|
||
this.selectConversation(this.conversations[0].id);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
},
|
||
|
||
saveAll() {
|
||
localforage.setItem(this.convoKey, this.conversations);
|
||
localforage.setItem(this.promptsKey, this.prompts);
|
||
localforage.setItem(
|
||
this.settingsKey,
|
||
JSON.stringify(this.settings)
|
||
);
|
||
if (this.settings.saveKey && this.apiKeyInput)
|
||
localforage.setItem(this.apiKeyStorageKey, this.apiKeyInput);
|
||
if (!this.settings.saveKey)
|
||
localforage.removeItem(this.apiKeyStorageKey);
|
||
},
|
||
|
||
newConversation() {
|
||
const id = uuidv4();
|
||
const now = Date.now();
|
||
const c = {
|
||
id,
|
||
title: "会话 - " + new Date(now).toLocaleString(),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
messages: [],
|
||
};
|
||
this.conversations.unshift(c);
|
||
this.selectConversation(id);
|
||
this.saveAll();
|
||
this.toastMsg("已创建新会话 ✅");
|
||
},
|
||
|
||
selectConversation(id) {
|
||
const c = this.conversations.find((x) => x.id === id);
|
||
if (!c) return;
|
||
this.currentConversation = c;
|
||
this.composeText = "";
|
||
this.scrollToBottom();
|
||
},
|
||
|
||
renameConversation() {
|
||
const name = prompt(
|
||
"请输入会话名称:",
|
||
this.currentConversation.title
|
||
);
|
||
if (name) {
|
||
this.currentConversation.title = name;
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.saveAll();
|
||
this.toastMsg("会话已重命名");
|
||
}
|
||
},
|
||
|
||
deleteConversation(id) {
|
||
if (!confirm("确认删除此会话?")) return;
|
||
this.conversations = this.conversations.filter((c) => c.id !== id);
|
||
if (this.currentConversation && this.currentConversation.id === id)
|
||
this.currentConversation = this.conversations[0] || null;
|
||
this.saveAll();
|
||
this.toastMsg("会话已删除");
|
||
},
|
||
|
||
exportAll() {
|
||
const payload = {
|
||
conversations: this.conversations,
|
||
prompts: this.prompts,
|
||
settings: this.settings,
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||
type: "application/json",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download =
|
||
"chat-client-export-" + new Date().toISOString() + ".json";
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
this.toastMsg("导出已下载");
|
||
},
|
||
|
||
importAll(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
try {
|
||
const json = JSON.parse(ev.target.result);
|
||
if (json.conversations) this.conversations = json.conversations;
|
||
if (json.prompts) this.prompts = json.prompts;
|
||
if (json.settings) Object.assign(this.settings, json.settings);
|
||
this.saveAll();
|
||
this.toastMsg("导入成功");
|
||
} catch (err) {
|
||
alert("导入失败:文件格式错误");
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
},
|
||
|
||
clearLocal(full = false) {
|
||
if (!confirm("确认清空?该操作无法撤销")) return;
|
||
if (full) {
|
||
localforage.clear();
|
||
this.conversations = [];
|
||
this.prompts = [];
|
||
this.currentConversation = null;
|
||
} else {
|
||
localforage.removeItem(this.convoKey);
|
||
this.conversations = [];
|
||
this.currentConversation = null;
|
||
}
|
||
this.toastMsg("已清空本地数据");
|
||
},
|
||
|
||
exportConversation(id) {
|
||
const c = this.conversations.find((x) => x.id === id);
|
||
if (!c) return;
|
||
const blob = new Blob([JSON.stringify(c, null, 2)], {
|
||
type: "application/json",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `conversation-${c.id}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
this.toastMsg("会话已导出");
|
||
},
|
||
|
||
renderMarkdown(md) {
|
||
return marked.parse(md || "");
|
||
},
|
||
|
||
// composing & sending
|
||
async sendMessage() {
|
||
if (!this.currentConversation) return this.toastMsg("请选择会话");
|
||
const text = this.composeText.trim();
|
||
if (!text) return this.toastMsg("请输入内容");
|
||
const userMsg = {
|
||
id: uuidv4(),
|
||
role: "user",
|
||
content: text,
|
||
createdAt: Date.now(),
|
||
};
|
||
this.currentConversation.messages.push(userMsg);
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.composeText = "";
|
||
this.saveAll();
|
||
// non-stream plain request -> wait for full response
|
||
try {
|
||
const resp = await this.askOpenAI(this.messagesForAPI(), false);
|
||
if (resp && resp.choices && resp.choices[0]) {
|
||
const content =
|
||
resp.choices[0].message?.content ||
|
||
resp.choices[0].delta?.content ||
|
||
JSON.stringify(resp);
|
||
const assistantMsg = {
|
||
id: uuidv4(),
|
||
role: "assistant",
|
||
content,
|
||
createdAt: Date.now(),
|
||
};
|
||
this.currentConversation.messages.push(assistantMsg);
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.saveAll();
|
||
} else {
|
||
this.toastMsg("未收到有效响应");
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
this.toastMsg("请求失败:" + (e.message || e));
|
||
}
|
||
this.scrollToBottom();
|
||
},
|
||
|
||
// streaming path
|
||
async streamedSend() {
|
||
if (!this.currentConversation) return this.toastMsg("请选择会话");
|
||
const text = this.composeText.trim();
|
||
if (!text) return this.toastMsg("请输入内容");
|
||
const userMsg = {
|
||
id: uuidv4(),
|
||
role: "user",
|
||
content: text,
|
||
createdAt: Date.now(),
|
||
};
|
||
this.currentConversation.messages.push(userMsg);
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.composeText = "";
|
||
this.saveAll();
|
||
this.scrollToBottom();
|
||
|
||
// create placeholder assistant message
|
||
const assistantPlaceholder = {
|
||
id: uuidv4(),
|
||
role: "assistant",
|
||
content: "",
|
||
createdAt: Date.now(),
|
||
streaming: true,
|
||
};
|
||
this.currentConversation.messages.push(assistantPlaceholder);
|
||
this.saveAll();
|
||
this.scrollToBottom();
|
||
|
||
try {
|
||
await this.streamToAssistant(
|
||
this.messagesForAPI(),
|
||
assistantPlaceholder
|
||
);
|
||
} catch (e) {
|
||
console.error(e);
|
||
this.toastMsg("流式请求失败:" + (e.message || e));
|
||
assistantPlaceholder.content += "\n\n[错误] " + (e.message || e);
|
||
assistantPlaceholder.streaming = false;
|
||
this.saveAll();
|
||
}
|
||
},
|
||
|
||
messagesForAPI() {
|
||
// basic transform: include all messages but convert to Chat API format
|
||
return (this.currentConversation.messages || []).map((m) => ({
|
||
role:
|
||
m.role === "assistant"
|
||
? "assistant"
|
||
: m.role === "user"
|
||
? "user"
|
||
: "system",
|
||
content: m.content,
|
||
}));
|
||
},
|
||
|
||
async askOpenAI(messages, stream = false) {
|
||
const key = this.getApiKey();
|
||
if (!key) throw new Error("缺少 API Key");
|
||
const payload = {
|
||
model: this.settings.model || "gpt-4o-mini",
|
||
messages,
|
||
stream,
|
||
};
|
||
const r = await fetch(
|
||
"https://api.openai.com/v1/chat/completions",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: "Bearer " + key,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(payload),
|
||
}
|
||
);
|
||
if (!r.ok) {
|
||
const text = await r.text();
|
||
throw new Error("API 错误: " + r.status + " - " + text);
|
||
}
|
||
const json = await r.json();
|
||
return json;
|
||
},
|
||
|
||
async streamToAssistant(messages, assistantPlaceholder) {
|
||
const key = this.getApiKey();
|
||
if (!key) throw new Error("缺少 API Key");
|
||
const payload = {
|
||
model: this.settings.model || "gpt-4o-mini",
|
||
messages,
|
||
stream: true,
|
||
};
|
||
const res = await fetch(
|
||
"https://api.openai.com/v1/chat/completions",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: "Bearer " + key,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(payload),
|
||
}
|
||
);
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error("API 错误: " + res.status + " - " + text);
|
||
}
|
||
|
||
// parse SSE-like stream returned as chunks
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
// split on double newline (SSE)
|
||
let parts = buffer.split(/\r?\n\r?\n/);
|
||
buffer = parts.pop(); // remainder
|
||
for (let p of parts) {
|
||
p = p.trim();
|
||
if (!p) continue;
|
||
// each p may contain lines like 'data: {...}' or 'data: [DONE]'
|
||
const lines = p.split(/\r?\n/);
|
||
for (let ln of lines) {
|
||
ln = ln.trim();
|
||
if (!ln.startsWith("data:")) continue;
|
||
const data = ln.replace(/^data:\s*/, "");
|
||
if (data === "[DONE]") {
|
||
assistantPlaceholder.streaming = false;
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.saveAll();
|
||
this.scrollToBottom();
|
||
return;
|
||
}
|
||
try {
|
||
const obj = JSON.parse(data);
|
||
const delta = obj.choices?.[0]?.delta;
|
||
const content = delta?.content ?? "";
|
||
if (content) {
|
||
assistantPlaceholder.content += content;
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.saveAll();
|
||
this.scrollToBottom();
|
||
}
|
||
} catch (e) {
|
||
// sometimes OpenAI streams non-json, ignore
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assistantPlaceholder.streaming = false;
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.saveAll();
|
||
},
|
||
|
||
getApiKey() {
|
||
// if user input present, use that; else attempt local storage
|
||
if (this.apiKeyInput && this.apiKeyInput.startsWith("sk-")) {
|
||
if (this.settings.saveKey)
|
||
localforage.setItem(this.apiKeyStorageKey, this.apiKeyInput);
|
||
return this.apiKeyInput;
|
||
}
|
||
// try storage
|
||
return null;
|
||
},
|
||
|
||
scrollToBottom() {
|
||
setTimeout(() => {
|
||
const el = document.getElementById("messages");
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
}, 120);
|
||
},
|
||
|
||
// Prompt template management
|
||
createPrompt() {
|
||
this.editingPrompt = {
|
||
id: null,
|
||
name: "新模板",
|
||
tagsInput: "",
|
||
template: "请在此填入你的 Prompt 模板(支持插值如 {{topic}})",
|
||
};
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
},
|
||
|
||
savePrompt(p) {
|
||
const tags = (p.tagsInput || "")
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
if (!p.id) {
|
||
p.id = uuidv4();
|
||
this.prompts.unshift({
|
||
id: p.id,
|
||
name: p.name || "模板",
|
||
tags,
|
||
template: p.template,
|
||
});
|
||
} else {
|
||
const idx = this.prompts.findIndex((x) => x.id === p.id);
|
||
if (idx >= 0) {
|
||
this.prompts[idx] = {
|
||
...this.prompts[idx],
|
||
name: p.name,
|
||
tags,
|
||
template: p.template,
|
||
};
|
||
}
|
||
}
|
||
this.editingPrompt = {
|
||
id: null,
|
||
name: "",
|
||
tagsInput: "",
|
||
template: "",
|
||
};
|
||
this.saveAll();
|
||
this.toastMsg("Prompt 模板已保存");
|
||
},
|
||
|
||
editPrompt(p) {
|
||
this.editingPrompt = {
|
||
id: p.id,
|
||
name: p.name,
|
||
tagsInput: (p.tags || []).join(","),
|
||
template: p.template,
|
||
};
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
},
|
||
|
||
applyPrompt(p) {
|
||
this.composeText = p.template;
|
||
this.toastMsg("模板已应用到编辑器");
|
||
},
|
||
|
||
// prompt generation heuristics
|
||
generatePrompt() {
|
||
const t = this.promptGen;
|
||
let lines = [];
|
||
lines.push(`任务: ${t.task}`);
|
||
if (t.audience) lines.push(`目标受众: ${t.audience}`);
|
||
if (t.format) lines.push(`输出格式: ${t.format}`);
|
||
if (t.constraints) lines.push(`约束: ${t.constraints}`);
|
||
// smarter templates based on task
|
||
let body = "";
|
||
switch (t.task) {
|
||
case "summarize":
|
||
body = `请阅读以下文本并给出简明扼要的摘要,面向 ${
|
||
t.audience
|
||
}。输出必须以 ${t.format || "Markdown"} 格式呈现。${
|
||
t.constraints || ""
|
||
}\n\n正文:\n{{input}}`;
|
||
break;
|
||
case "translate":
|
||
body = `请将下列文本翻译成 ${
|
||
t.audience
|
||
},保持原意且适当本地化。输出格式:${t.format || "纯文本"}。${
|
||
t.constraints || ""
|
||
}\n\n文本:\n{{input}}`;
|
||
break;
|
||
case "code":
|
||
body = `请根据任务说明生成示例代码,目标受众:${
|
||
t.audience
|
||
}。输出应包含说明、代码块与运行示例,格式:${
|
||
t.format || "Markdown"
|
||
}。${t.constraints || ""}\n\n需求:\n{{input}}`;
|
||
break;
|
||
case "critique":
|
||
body = `请从专业角度对下列内容进行审查和改进建议,面向:${
|
||
t.audience
|
||
}。输出按 ${t.format || "条目"} 列出,包含改进建议与示例。${
|
||
t.constraints || ""
|
||
}\n\n内容:\n{{input}}`;
|
||
break;
|
||
case "plan":
|
||
body = `请基于下面目标生成一个可执行计划(分步骤、时间估计、实现要点),面向 ${
|
||
t.audience
|
||
}。格式:${t.format || "列表"}。${
|
||
t.constraints || ""
|
||
}\n\n目标:\n{{input}}`;
|
||
break;
|
||
default:
|
||
body = `请执行任务:${t.task},面向 ${t.audience},输出格式 ${
|
||
t.format
|
||
}。${t.constraints || ""}\n\n{{input}}`;
|
||
}
|
||
this.generatedPrompt = lines.join(" | ") + "\n\n" + body;
|
||
this.toastMsg("Prompt 已生成 ✨");
|
||
},
|
||
|
||
quickApplyGenerated() {
|
||
if (!this.generatedPrompt) {
|
||
this.toastMsg("请先生成 Prompt");
|
||
return;
|
||
}
|
||
this.composeText = this.generatedPrompt;
|
||
this.toastMsg("生成的 Prompt 已应用到编辑器");
|
||
},
|
||
|
||
saveGeneratedAsPrompt() {
|
||
if (!this.generatedPrompt) {
|
||
this.toastMsg("请先生成 Prompt");
|
||
return;
|
||
}
|
||
const id = uuidv4();
|
||
this.prompts.unshift({
|
||
id,
|
||
name: "生成模板 " + new Date().toLocaleString(),
|
||
tags: [this.promptGen.task],
|
||
template: this.generatedPrompt,
|
||
});
|
||
this.saveAll();
|
||
this.toastMsg("已保存为模板");
|
||
},
|
||
|
||
applySystemPrompt() {
|
||
if (!this.composeText) return this.toastMsg("编辑器为空");
|
||
// add as system message then ask
|
||
const sys = {
|
||
id: uuidv4(),
|
||
role: "system",
|
||
content: this.composeText,
|
||
createdAt: Date.now(),
|
||
};
|
||
this.currentConversation.messages.push(sys);
|
||
this.currentConversation.updatedAt = Date.now();
|
||
this.saveAll();
|
||
this.toastMsg("System Prompt 已注入会话");
|
||
// clear editor
|
||
this.composeText = "";
|
||
},
|
||
|
||
// improve prompt using the model
|
||
async improvePrompt(promptText) {
|
||
if (!promptText) return this.toastMsg("无 prompt 可改进");
|
||
if (
|
||
!confirm(
|
||
"将使用模型对该 Prompt 进行改进(将消耗 tokens)。继续?"
|
||
)
|
||
)
|
||
return;
|
||
// craft small instruction
|
||
const system = {
|
||
role: "system",
|
||
content:
|
||
"你是一个资深提示工程师(Prompt Engineer)。请在保留原意的前提下,优化下面的 prompt,使其更精确、可复现,并给出 1) 优化后的 prompt 2) 改进说明。",
|
||
};
|
||
const user = { role: "user", content: promptText };
|
||
try {
|
||
const resp = await this.askOpenAI([system, user], false);
|
||
const content =
|
||
resp.choices?.[0]?.message?.content || JSON.stringify(resp);
|
||
// show in editor and optionally save
|
||
this.composeText = content;
|
||
this.toastMsg("已生成改进建议(已应用到编辑器)");
|
||
} catch (e) {
|
||
this.toastMsg("改进失败:" + (e.message || e));
|
||
}
|
||
},
|
||
|
||
// settings modal
|
||
toggleSettingsModal() {
|
||
this.settingsModal = !this.settingsModal;
|
||
},
|
||
applySettings() {
|
||
this.saveAll();
|
||
this.settingsModal = false;
|
||
this.toastMsg("设置已保存");
|
||
},
|
||
|
||
// small helpers
|
||
toastMsg(msg, t = 2500) {
|
||
this.toast = msg;
|
||
setTimeout(() => (this.toast = ""), t);
|
||
},
|
||
|
||
// simple estimation heuristic: characters/4 => tokens, price per 1k tokens (example)
|
||
estimateCost(text) {
|
||
if (!text) return "0 tokens, $0.00";
|
||
const chars = text.length;
|
||
const tokens = Math.ceil(chars / 4);
|
||
const pricePer1k = 0.03; // placeholder $0.03 per 1k tokens
|
||
const cost = (tokens / 1000) * pricePer1k;
|
||
return `${tokens} tokens, ~$${cost.toFixed(4)}`;
|
||
},
|
||
|
||
// streamToAssistant already implements saving assistant content incrementally
|
||
|
||
// filtering lists
|
||
get filteredConversations() {
|
||
const q = (this.leftSearch || "").toLowerCase();
|
||
if (!q) return this.conversations;
|
||
return this.conversations.filter(
|
||
(c) =>
|
||
(c.title || "").toLowerCase().includes(q) ||
|
||
(c.messages || []).some((m) =>
|
||
(m.content || "").toLowerCase().includes(q)
|
||
)
|
||
);
|
||
},
|
||
|
||
get filteredPrompts() {
|
||
const q = (this.leftSearch || "").toLowerCase();
|
||
if (!q) return this.prompts;
|
||
return this.prompts.filter(
|
||
(p) =>
|
||
(p.name || "").toLowerCase().includes(q) ||
|
||
(p.tags || []).join(" ").toLowerCase().includes(q) ||
|
||
(p.template || "").toLowerCase().includes(q)
|
||
);
|
||
},
|
||
|
||
// quick apply editing prompt save
|
||
};
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|