commit
03bf8450e2
@ -0,0 +1,6 @@
|
|||||||
|
## Information
|
||||||
|
you can use this code at your own risk.
|
||||||
|
|
||||||
|
## markup.umd.js
|
||||||
|
This file is downloaded from [https://github.com/markedjs/marked](https://github.com/markedjs/marked) which is used unter its MIT license.
|
||||||
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
# KaI - Ollama Chat
|
||||||
|
|
||||||
|
a simple webpage which will connect to a remote ollama instance and allowes you to talk to any model aviable in ollama.
|
||||||
|
|
||||||
|
## configuration
|
||||||
|
|
||||||
|
all configuration is done in the file default.php
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
// CHANGE THIS to your remote Ollama server IP or domain (e.g., http://192.168.1.50:11434)
|
||||||
|
define('OLLAMA_URL', 'http://10.2.0.3:11434');
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// 1. Handle fetching available models
|
||||||
|
if (isset($_GET['action']) && $_GET['action'] === 'models') {
|
||||||
|
$ch = curl_init(OLLAMA_URL . '/api/tags');
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
echo json_encode(['error' => 'Could not connect to Ollama server.']);
|
||||||
|
} else {
|
||||||
|
echo $response;
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle sending chat messages
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$model = $input['model'] ?? 'llama3';
|
||||||
|
$messages = $input['messages'] ?? [];
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => $messages,
|
||||||
|
'stream' => false
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init(OLLAMA_URL . '/api/chat');
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
echo json_encode(['error' => 'Error communicating with remote Ollama: ' . curl_error($ch)]);
|
||||||
|
} else {
|
||||||
|
echo $response;
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['error' => 'Invalid request']);
|
||||||
|
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '/default.php';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function readConfig() {
|
||||||
|
if (!file_exists(CONFIG_FILE)) {
|
||||||
|
return ['defaultModel' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents(CONFIG_FILE);
|
||||||
|
if ($content === false) {
|
||||||
|
return ['defaultModel' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return ['defaultModel' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'defaultModel' => isset($data['defaultModel']) ? (string) $data['defaultModel'] : ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig($config) {
|
||||||
|
$data = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
return file_put_contents(CONFIG_FILE, $data) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'load':
|
||||||
|
echo json_encode(readConfig());
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'save':
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
echo json_encode(['error' => 'POST required.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($input)) {
|
||||||
|
echo json_encode(['error' => 'Invalid JSON.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = readConfig();
|
||||||
|
$config['defaultModel'] = isset($input['defaultModel']) ? (string) $input['defaultModel'] : '';
|
||||||
|
|
||||||
|
if (!writeConfig($config)) {
|
||||||
|
echo json_encode(['error' => 'Unable to save config.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'defaultModel' => $config['defaultModel']]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo json_encode(['error' => 'Unknown action.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@ -0,0 +1,301 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:#1e1e2e;
|
||||||
|
color:#cdd6f4;
|
||||||
|
min-height:100vh;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display:flex;
|
||||||
|
height:100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================
|
||||||
|
LEFT SIDEBAR
|
||||||
|
========================== */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width:280px;
|
||||||
|
background:#11111b;
|
||||||
|
border-right:1px solid #313244;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding:15px;
|
||||||
|
font-size:18px;
|
||||||
|
font-weight:bold;
|
||||||
|
border-bottom:1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-list {
|
||||||
|
flex:1;
|
||||||
|
overflow-y:auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
padding:12px;
|
||||||
|
cursor:pointer;
|
||||||
|
border-bottom:1px solid #313244;
|
||||||
|
transition:background .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background:#313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item.active {
|
||||||
|
background:#45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-size:14px;
|
||||||
|
font-weight:bold;
|
||||||
|
color:#cdd6f4;
|
||||||
|
margin-bottom:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date {
|
||||||
|
font-size:11px;
|
||||||
|
color:#888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
flex:1;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding:15px 20px;
|
||||||
|
background:#11111b;
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
border-bottom:1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display:flex;
|
||||||
|
gap:10px;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
background:#313244;
|
||||||
|
color:#cdd6f4;
|
||||||
|
border:1px solid #45475a;
|
||||||
|
padding:8px 12px;
|
||||||
|
border-radius:6px;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.new-chat-btn {
|
||||||
|
background:#f38ba8;
|
||||||
|
color:#11111b;
|
||||||
|
font-weight:bold;
|
||||||
|
border:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.send-btn {
|
||||||
|
background:#89b4fa;
|
||||||
|
color:#11111b;
|
||||||
|
font-weight:bold;
|
||||||
|
border:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex:1;
|
||||||
|
overflow-y:auto;
|
||||||
|
padding:20px;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap:15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width:90%;
|
||||||
|
padding:12px 16px;
|
||||||
|
border-radius:8px;
|
||||||
|
word-break:break-word;
|
||||||
|
line-height:1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
align-self:flex-end;
|
||||||
|
background:#89b4fa;
|
||||||
|
color:#11111b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.system {
|
||||||
|
align-self:center;
|
||||||
|
color:#999;
|
||||||
|
background:transparent;
|
||||||
|
font-style:italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message pre {
|
||||||
|
background:#11111b;
|
||||||
|
padding:10px;
|
||||||
|
overflow:auto;
|
||||||
|
border-radius:5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message code {
|
||||||
|
background:#11111b;
|
||||||
|
padding:2px 5px;
|
||||||
|
border-radius:3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message ul,
|
||||||
|
.message ol {
|
||||||
|
margin-left:20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
display:flex;
|
||||||
|
gap:10px;
|
||||||
|
padding:15px;
|
||||||
|
background:#11111b;
|
||||||
|
border-top:1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex:1;
|
||||||
|
resize:none;
|
||||||
|
height:60px;
|
||||||
|
background:#313244;
|
||||||
|
color:white;
|
||||||
|
border:1px solid #45475a;
|
||||||
|
border-radius:6px;
|
||||||
|
padding:12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline:none;
|
||||||
|
border-color:#89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --border-angle {
|
||||||
|
syntax:'<angle>';
|
||||||
|
inherits:false;
|
||||||
|
initial-value:0deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate-border {
|
||||||
|
to {
|
||||||
|
--border-angle:360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border: 3px solid transparent; /* Platzhalter für den Rahmen */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant.thinking-state {
|
||||||
|
background-image: linear-gradient(#313244, #313244),
|
||||||
|
conic-gradient(from var(--border-angle), #00ffff, #000000, #ff00ff, #000000, #ffffff, #000000, #00ffff);
|
||||||
|
background-origin: border-box;
|
||||||
|
background-clip: padding-box, border-box;
|
||||||
|
animation: rotate-border 1s linear infinite; /* Von 3s auf 1s verkürzt für mehr Speed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-text span {
|
||||||
|
animation:blink 1.4s infinite both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-text span:nth-child(2){
|
||||||
|
animation-delay:.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-text span:nth-child(3){
|
||||||
|
animation-delay:.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink{
|
||||||
|
0%{opacity:.2;}
|
||||||
|
20%{opacity:1;}
|
||||||
|
100%{opacity:.2;}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 900px) {
|
||||||
|
body {
|
||||||
|
overflow:auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex-direction:column;
|
||||||
|
height:auto;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width:100%;
|
||||||
|
max-height:320px;
|
||||||
|
border-right:none;
|
||||||
|
border-bottom:1px solid #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
height:auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
flex-wrap:wrap;
|
||||||
|
gap:10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
width:100%;
|
||||||
|
justify-content:space-between;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
padding:16px;
|
||||||
|
min-height:280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
flex-direction:column;
|
||||||
|
gap:10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height:100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.send-btn,
|
||||||
|
button.new-chat-btn,
|
||||||
|
select {
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monospace font for inline code and pre blocks */
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
tt,
|
||||||
|
.message code,
|
||||||
|
.message pre {
|
||||||
|
font-family: Consolas, "Andale Mono", "Lucida Console", Monaco, "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
define('CONFIG_PATH', '/home/www/data/kai-ollama');
|
||||||
|
define('CONFIG_FILE', CONFIG_PATH . '/config.json');
|
||||||
|
define('CHAT_HISTORY_FILE', CONFIG_PATH . '/chat.history.data');
|
||||||
|
|
||||||
@ -0,0 +1,379 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="default.css">
|
||||||
|
<title>KaI (Ollama Remote Chat)</title>
|
||||||
|
<script src="marked.umd.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
Chat History
|
||||||
|
</div>
|
||||||
|
<div id="history-list">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="chat-header">
|
||||||
|
<h2>I'm KaI your - Ollama Chat</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<select id="model-select">
|
||||||
|
<option>Loading models...</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="new-chat-btn"
|
||||||
|
id="new-chat-btn">
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
<div class="message system">Conversation started.</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<textarea id="user-input" placeholder="Type your message..."></textarea>
|
||||||
|
<button class="send-btn" id="send-btn">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
const historyList = document.getElementById('history-list');
|
||||||
|
|
||||||
|
const userInput = document.getElementById('user-input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const newChatBtn = document.getElementById('new-chat-btn');
|
||||||
|
const modelSelect = document.getElementById('model-select');
|
||||||
|
|
||||||
|
let chatHistory = [];
|
||||||
|
let currentChatId = null;
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
breaks:true
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Message Output
|
||||||
|
========================================================== */
|
||||||
|
|
||||||
|
function appendMessage(role, text) {
|
||||||
|
const msgDiv = document.createElement("div");
|
||||||
|
msgDiv.classList.add("message", role);
|
||||||
|
|
||||||
|
if (role === "assistant") {
|
||||||
|
msgDiv.innerHTML = marked.parse(text);
|
||||||
|
} else {
|
||||||
|
msgDiv.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatMessages.appendChild(msgDiv);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
|
||||||
|
return msgDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Models
|
||||||
|
========================================================== */
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("config.php?action=load");
|
||||||
|
const data = await response.json();
|
||||||
|
return data.defaultModel || "";
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(defaultModel) {
|
||||||
|
try {
|
||||||
|
await fetch("config.php?action=save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ defaultModel })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("chat.php?action=models");
|
||||||
|
const data = await response.json();
|
||||||
|
const defaultModel = await loadConfig();
|
||||||
|
|
||||||
|
modelSelect.innerHTML = "";
|
||||||
|
|
||||||
|
if (data.models) {
|
||||||
|
let selectedModel = "";
|
||||||
|
|
||||||
|
data.models.forEach(model => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = model.name;
|
||||||
|
option.textContent = model.name;
|
||||||
|
modelSelect.appendChild(option);
|
||||||
|
|
||||||
|
if (!selectedModel && model.name === defaultModel) {
|
||||||
|
selectedModel = model.name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackModel = selectedModel || (data.models[0] && data.models[0].name) || "";
|
||||||
|
if (fallbackModel) {
|
||||||
|
modelSelect.value = fallbackModel;
|
||||||
|
} else {
|
||||||
|
modelSelect.innerHTML = "<option>No Models</option>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modelSelect.innerHTML = "<option>No Models</option>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
modelSelect.innerHTML = "<option>Connection failed</option>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
History
|
||||||
|
========================================================== */
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("savechat.php?action=list");
|
||||||
|
const chats = await response.json();
|
||||||
|
|
||||||
|
historyList.innerHTML = "";
|
||||||
|
chats.forEach(chat => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "history-item";
|
||||||
|
if (chat.id === currentChatId) item.classList.add("active");
|
||||||
|
|
||||||
|
item.innerHTML = `<div class="history-title">${chat.title}</div>`;
|
||||||
|
|
||||||
|
const historyDateDiv = document.createElement("div");
|
||||||
|
const historyDate = chat.created || '';
|
||||||
|
historyDateDiv.textContent = historyDate;
|
||||||
|
historyDateDiv.className = "history-date";
|
||||||
|
item.appendChild(historyDateDiv);
|
||||||
|
|
||||||
|
const historyModelSpan = document.createElement("span");
|
||||||
|
historyModelSpan.textContent = `${chat.model}`;
|
||||||
|
historyModelSpan.style.display = "inline-block";
|
||||||
|
historyModelSpan.style.marginLeft = "10px";
|
||||||
|
historyModelSpan.style.float = "right";
|
||||||
|
historyModelSpan.className = "history-date";
|
||||||
|
item.appendChild(historyModelSpan);
|
||||||
|
item.appendChild(historyDateDiv);
|
||||||
|
|
||||||
|
item.onclick = () => {
|
||||||
|
loadChat(chat.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// add a delete button here
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.textContent = 'Delete';
|
||||||
|
deleteBtn.onclick = async () => {
|
||||||
|
// remove the chat from the list and from the database
|
||||||
|
historyList.removeChild(item);
|
||||||
|
await fetch('savechat.php?action=delete&id=' + encodeURIComponent(chat.id));
|
||||||
|
loadHistory(); // reload the history to update the list
|
||||||
|
};
|
||||||
|
|
||||||
|
item.appendChild(deleteBtn);
|
||||||
|
historyList.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Load Chat
|
||||||
|
========================================================== */
|
||||||
|
async function loadChat(id) {
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
await fetch(
|
||||||
|
"savechat.php?action=load&id="
|
||||||
|
+ encodeURIComponent(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const chat = await response.json();
|
||||||
|
|
||||||
|
if (chat.error) {
|
||||||
|
alert(chat.error);
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
currentChatId = chat.id;
|
||||||
|
chatHistory = chat.messages;
|
||||||
|
chatMessages.innerHTML = "";
|
||||||
|
chat.messages.forEach(msg => {
|
||||||
|
appendMessage(
|
||||||
|
msg.role,
|
||||||
|
msg.content
|
||||||
|
);
|
||||||
|
});
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Save Chat
|
||||||
|
========================================================== */
|
||||||
|
async function saveChat() {
|
||||||
|
if (chatHistory.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch( "savechat.php?action=save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type":"application/json"},
|
||||||
|
body: JSON.stringify({id: currentChatId, model: modelSelect.value, messages: chatHistory})});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.id) currentChatId = result.id;
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Send Message
|
||||||
|
========================================================== */
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const text = userInput.value.trim();
|
||||||
|
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
if (!modelSelect.value) {
|
||||||
|
alert("Please select a model.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendMessage("user", text);
|
||||||
|
chatHistory.push({role: "user", content: text});
|
||||||
|
userInput.value = "";
|
||||||
|
|
||||||
|
const thinking =
|
||||||
|
appendMessage( "assistant",'<div class="thinking-text">Thinking<span>.</span><span>.</span><span>.</span></div>');
|
||||||
|
|
||||||
|
thinking.classList.add("thinking-state");
|
||||||
|
const startTime = performance.now();
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
await fetch("chat.php", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type":"application/json" },
|
||||||
|
body: JSON.stringify({ model: modelSelect.value, messages: chatHistory})
|
||||||
|
});
|
||||||
|
|
||||||
|
thinking.classList.remove("thinking-state");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const endTime = performance.now();
|
||||||
|
const totalDuration = ((endTime-startTime)/1000).toFixed(2);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
thinking.textContent = data.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = marked.parse(data.message.content);
|
||||||
|
const tokens = data.eval_count || 0;
|
||||||
|
const promptTokens = data.prompt_eval_count || 0;
|
||||||
|
const generationSeconds = data.eval_duration ? data.eval_duration / 1000000000 : 0;
|
||||||
|
const tps = (tokens>0 && generationSeconds>0) ? (tokens/generationSeconds).toFixed(1) : 0;
|
||||||
|
|
||||||
|
let footer = "Time: " + totalDuration + " sec";
|
||||||
|
if(tokens>0){
|
||||||
|
footer += " | Prompt " + promptTokens + " t | Answer " + tokens
|
||||||
|
+ " t (" + tps + " t/s)";
|
||||||
|
}
|
||||||
|
|
||||||
|
thinking.innerHTML =
|
||||||
|
html +
|
||||||
|
'<div style="margin-top:8px;font-size:11px;color:#999;text-align:right;font-style:italic;">'
|
||||||
|
+ footer +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
chatHistory.push({
|
||||||
|
|
||||||
|
role:"assistant",
|
||||||
|
content:data.message.content
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveChat();
|
||||||
|
|
||||||
|
}
|
||||||
|
catch(err){
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
thinking.classList.remove("thinking-state");
|
||||||
|
|
||||||
|
thinking.textContent =
|
||||||
|
"Backend connection failed.";
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
New Chat
|
||||||
|
========================================================== */
|
||||||
|
newChatBtn.addEventListener("click", () => {
|
||||||
|
currentChatId = null;
|
||||||
|
chatHistory = [];
|
||||||
|
chatMessages.innerHTML = "";
|
||||||
|
appendMessage("system","Conversation started.");
|
||||||
|
loadHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelSelect.addEventListener("change", () => {
|
||||||
|
if (modelSelect.value && modelSelect.value !== "Loading models..." && modelSelect.value !== "No Models" && modelSelect.value !== "Connection failed") {
|
||||||
|
saveConfig(modelSelect.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Keyboard
|
||||||
|
========================================================== */
|
||||||
|
|
||||||
|
sendBtn.addEventListener("click",sendMessage);
|
||||||
|
userInput.addEventListener("keydown", function(e){
|
||||||
|
if(e.key==="Enter" && !e.shiftKey){
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Startup
|
||||||
|
========================================================== */
|
||||||
|
|
||||||
|
loadModels();
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/default.php';
|
||||||
|
|
||||||
|
/*----------------------------------------------------------
|
||||||
|
- Create storage file if necessary
|
||||||
|
----------------------------------------------------------*/
|
||||||
|
if (!file_exists(CHAT_HISTORY_FILE)) {
|
||||||
|
if (@file_put_contents(CHAT_HISTORY_FILE, '') === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Cannot create history file.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*----------------------------------------------------------
|
||||||
|
- Helper
|
||||||
|
----------------------------------------------------------*/
|
||||||
|
function readChats() {
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
$fp = fopen(CHAT_HISTORY_FILE, 'c+');
|
||||||
|
if (!$fp) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($fp, LOCK_SH);
|
||||||
|
while (($line = fgets($fp)) !== false) {
|
||||||
|
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$obj = json_decode($line, true);
|
||||||
|
if (is_array($obj)) {
|
||||||
|
$result[] = $obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChats($chats) {
|
||||||
|
$fp = fopen(CHAT_HISTORY_FILE, 'w');
|
||||||
|
|
||||||
|
if (!$fp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($fp, LOCK_EX);
|
||||||
|
foreach ($chats as $chat) {
|
||||||
|
fwrite($fp, json_encode($chat, JSON_UNESCAPED_UNICODE) . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
fflush($fp);
|
||||||
|
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTitle($messages) {
|
||||||
|
foreach ($messages as $msg) {
|
||||||
|
if (($msg['role'] ?? '') === 'user') {
|
||||||
|
$title = trim($msg['content']);
|
||||||
|
if (mb_strlen($title) > 60) {
|
||||||
|
$title = mb_substr($title, 0, 60) . "...";
|
||||||
|
}
|
||||||
|
return $title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Untitled Chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
/*----------------------------------------------------------
|
||||||
|
- Action
|
||||||
|
----------------------------------------------------------*/
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
switch ($action) {
|
||||||
|
/*------------------------------------------------------
|
||||||
|
- LIST
|
||||||
|
------------------------------------------------------*/
|
||||||
|
case 'list':
|
||||||
|
$chats = readChats();
|
||||||
|
$list = [];
|
||||||
|
foreach ($chats as $chat) {
|
||||||
|
$list[] = [
|
||||||
|
'id' => $chat['id'],
|
||||||
|
'title' => $chat['title'],
|
||||||
|
'created' => $chat['created'],
|
||||||
|
'model' => $chat['model'] ?? ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($list, function($a, $b) {
|
||||||
|
return strcmp($b['created'], $a['created']);
|
||||||
|
});
|
||||||
|
|
||||||
|
echo json_encode($list);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
/*------------------------------------------------------
|
||||||
|
- LOAD
|
||||||
|
------------------------------------------------------*/
|
||||||
|
case 'load':
|
||||||
|
$id = $_GET['id'] ?? '';
|
||||||
|
if ($id == '') {
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Missing id.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chats = readChats();
|
||||||
|
foreach ($chats as $chat) {
|
||||||
|
if ($chat['id'] === $id) {
|
||||||
|
echo json_encode($chat);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Chat not found.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
/*------------------------------------------------------
|
||||||
|
- SAVE
|
||||||
|
------------------------------------------------------*/
|
||||||
|
case 'save':
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'POST required.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($input)) {
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Invalid JSON.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = $input['messages'] ?? [];
|
||||||
|
if (!is_array($messages) || count($messages) == 0) {
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'No messages.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
if ($id == '') {
|
||||||
|
$id = uniqid('', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = createTitle($messages);
|
||||||
|
$model = $input['model'] ?? '';
|
||||||
|
$chat = [
|
||||||
|
'id' => $id,
|
||||||
|
'title' => $title,
|
||||||
|
'created' => date('Y-m-d H:i:s'),
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => $messages
|
||||||
|
];
|
||||||
|
|
||||||
|
$chats = readChats();
|
||||||
|
$found = false;
|
||||||
|
foreach ($chats as &$existing) {
|
||||||
|
if ($existing['id'] === $id) {
|
||||||
|
$existing = $chat;
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
$chats[] = $chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeChats($chats)) {
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Unable to save.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'id' => $id,
|
||||||
|
'title' => $title
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
/*------------------------------------------------------
|
||||||
|
- DELETE
|
||||||
|
------------------------------------------------------*/
|
||||||
|
case 'delete':
|
||||||
|
$id = $_GET['id'] ?? '';
|
||||||
|
|
||||||
|
if ($id == '') {
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Missing id.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chats = readChats();
|
||||||
|
$newChats = [];
|
||||||
|
|
||||||
|
foreach ($chats as $chat) {
|
||||||
|
if ($chat['id'] !== $id) {
|
||||||
|
$newChats[] = $chat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeChats($newChats);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
/*------------------------------------------------------
|
||||||
|
- DEFAULT
|
||||||
|
------------------------------------------------------*/
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Unknown action.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
Loading…
Reference in new issue