Files
ai_chat/index.html
2025-10-21 08:40:49 +08:00

1168 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 Keysk-..."
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>