| <!DOCTYPE html>
|
| <html lang="zh-CN">
|
| <head>
|
| <meta charset="UTF-8">
|
| <title>WT - Copilot</title>
|
| <style>
|
| body {
|
| margin: 0;
|
| display: flex;
|
| height: 100vh;
|
| font-family: "PingFang SC", "Microsoft YaHei", serif;
|
| background-color: #f5f5f5;
|
| }
|
|
|
|
|
| #sidebar {
|
| width: 280px;
|
| background: #fff;
|
| border-right: 1px solid #ddd;
|
| padding: 20px;
|
| display: flex;
|
| flex-direction: column;
|
| gap: 15px;
|
| box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
|
| z-index: 10;
|
| }
|
|
|
| .setting-item {
|
| display: flex;
|
| flex-direction: column;
|
| gap: 5px;
|
| }
|
|
|
| .setting-item label {
|
| font-size: 12px;
|
| color: #666;
|
| font-weight: bold;
|
| }
|
|
|
| input {
|
| padding: 8px;
|
| border: 1px solid #ccc;
|
| border-radius: 4px;
|
| }
|
|
|
|
|
| #main-container {
|
| flex: 1;
|
| padding: 40px;
|
| display: flex;
|
| justify-content: center;
|
| overflow-y: auto;
|
| }
|
|
|
| #editor {
|
| width: 100%;
|
| max-width: 800px;
|
| min-height: 85vh;
|
| background: white;
|
| padding: 40px;
|
| box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
| outline: none;
|
| font-size: 18px;
|
| line-height: 1.8;
|
| color: #333;
|
| white-space: pre-wrap;
|
| word-wrap: break-word;
|
|
|
| display: block;
|
| }
|
|
|
|
|
| .ghost-text {
|
| color: #aaa;
|
| user-select: none;
|
| pointer-events: none;
|
| font-style: italic;
|
| }
|
|
|
| .status-box {
|
| font-size: 12px;
|
| margin-top: auto;
|
| color: #888;
|
| background: #f9f9f9;
|
| padding: 10px;
|
| border-radius: 5px;
|
| }
|
|
|
| .kbd {
|
| background: #eee;
|
| padding: 2px 4px;
|
| border-radius: 3px;
|
| border: 1px solid #ccc;
|
| color: #333;
|
| }
|
| </style>
|
| </head>
|
| <body>
|
|
|
| <div id="sidebar">
|
| <h3>WTC - WebUI</h3>
|
| <div class="setting-item">
|
| <label>API Endpoint</label>
|
| <input type="text" id="api-url" value="http://localhost:8080/v1/chat/completions">
|
| </div>
|
| <div class="setting-item">
|
| <label>Temperature</label>
|
| <input type="number" id="temp" value="0.7" step="0.1" min="0" max="2">
|
| </div>
|
| <div class="setting-item">
|
| <label>Max Tokens</label>
|
| <input type="number" id="max-tokens" value="150">
|
| </div>
|
|
|
| <div class="status-box">
|
| <p>✨ <span class="kbd">Tab</span> 触发续写</p>
|
| <p>🤝 <span class="kbd">Tab</span> 接受建议</p>
|
| <p>Powered by WT-Copilot™ | Trina AI Lab</p>
|
| <hr>
|
| <p id="info-display">状态:就绪</p>
|
| </div>
|
| </div>
|
|
|
| <div id="main-container">
|
| <div id="editor" contenteditable="true" spellcheck="false">在这里输入故事开头...</div>
|
| </div>
|
|
|
| <script>
|
| const editor = document.getElementById('editor');
|
| const infoDisplay = document.getElementById('info-display');
|
| let isFetching = false;
|
| editor.addEventListener('paste', (e) => {
|
| e.preventDefault();
|
| const text = (e.originalEvent || e).clipboardData.getData('text/plain');
|
| document.execCommand('insertText', false, text);
|
| });
|
|
|
| function periodicSanitize() {
|
| if (isFetching) return;
|
|
|
| const ghost = editor.querySelector('.ghost-text');
|
| if (!ghost) {
|
| const hasComplexHTML = Array.from(editor.childNodes).some(node =>
|
| node.nodeType === 1 && node.tagName !== 'SPAN' && node.tagName !== 'BR'
|
| );
|
| if (hasComplexHTML) {
|
| const selection = window.getSelection();
|
| const offset = selection.focusOffset;
|
| editor.innerText = editor.innerText;
|
| }
|
| }
|
| }
|
|
|
| setInterval(periodicSanitize, 5000);
|
|
|
| function getContext() {
|
| const tempDiv = document.createElement('div');
|
| tempDiv.innerHTML = editor.innerHTML;
|
| const ghost = tempDiv.querySelector('.ghost-text');
|
| if (ghost) ghost.remove();
|
|
|
| const fullText = tempDiv.innerText;
|
| return fullText.slice(-1000);
|
| }
|
|
|
| function removeGhost() {
|
| const ghost = editor.querySelector('.ghost-text');
|
| if (ghost) ghost.remove();
|
| }
|
|
|
| function acceptGhost() {
|
| const ghost = editor.querySelector('.ghost-text');
|
| if (ghost) {
|
| const text = ghost.innerText;
|
| removeGhost();
|
| document.execCommand('insertText', false, text);
|
| }
|
| }
|
|
|
| async function fetchCompletion() {
|
| if (isFetching) return;
|
|
|
| const context = getContext();
|
| const apiUrl = document.getElementById('api-url').value;
|
| const temp = parseFloat(document.getElementById('temp').value);
|
| const maxTokens = parseInt(document.getElementById('max-tokens').value);
|
|
|
| isFetching = true;
|
| infoDisplay.innerText = "状态:AI 正在思考...";
|
|
|
| try {
|
| const response = await fetch(apiUrl, {
|
| method: 'POST',
|
| headers: {'Content-Type': 'application/json'},
|
| body: JSON.stringify({
|
| "messages": [{"role": "user", "content": `续写:${context}\n/no_think`}],
|
| "temperature": temp,
|
| "max_tokens": maxTokens
|
| })
|
| });
|
| const data = await response.json();
|
| let rawContent = data.choices[0].message.content;
|
| const cleanContent = rawContent.replace(/<think>[\s\S]*?<\/think>/g, '').trimStart();
|
|
|
| if (cleanContent) {
|
| showGhost(cleanContent);
|
| infoDisplay.innerText = "状态:已生成 (Tab 接受)";
|
| } else {
|
| infoDisplay.innerText = "状态:未生成有效补全";
|
| }
|
| } catch (error) {
|
| infoDisplay.innerText = "状态:API 连接失败";
|
| } finally {
|
| isFetching = false;
|
| }
|
| }
|
|
|
| function showGhost(text) {
|
| removeGhost();
|
| const selection = window.getSelection();
|
| if (selection.rangeCount > 0) {
|
| const range = selection.getRangeAt(0);
|
| const ghostSpan = document.createElement('span');
|
| ghostSpan.className = 'ghost-text';
|
| ghostSpan.innerText = text;
|
|
|
| range.insertNode(ghostSpan);
|
| range.setStartBefore(ghostSpan);
|
| range.collapse(true);
|
| }
|
| }
|
|
|
| editor.addEventListener('keydown', (e) => {
|
| const hasGhost = !!editor.querySelector('.ghost-text');
|
|
|
| if (e.key === 'Tab') {
|
| e.preventDefault();
|
| if (hasGhost) {
|
| acceptGhost();
|
| } else {
|
| fetchCompletion();
|
| }
|
| return;
|
| }
|
| if (hasGhost && !['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) {
|
| removeGhost();
|
| infoDisplay.innerText = "状态:就绪";
|
| }
|
| });
|
| editor.addEventListener('focus', function () {
|
| if (this.innerText === '在这里输入故事开头...') {
|
| this.innerText = '';
|
| }
|
| }, {once: true});
|
| </script>
|
|
|
| </body>
|
| </html> |