Нет описания
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. // OpenCode 前端应用
  2. class OpenCodeApp {
  3. constructor() {
  4. this.baseURL = window.location.origin;
  5. this.currentSessionId = null;
  6. this.sessions = [];
  7. this.streamMode = true;
  8. this.logEventSource = null;
  9. this.userId = this.generateUserId();
  10. this.initElements();
  11. this.bindEvents();
  12. this.checkHealth();
  13. this.loadSessions();
  14. this.startLogStream();
  15. }
  16. generateUserId() {
  17. // 生成或获取存储的用户ID
  18. let userId = localStorage.getItem('opencode_user_id');
  19. if (!userId) {
  20. userId = 'user_' + Math.random().toString(36).substr(2, 9);
  21. localStorage.setItem('opencode_user_id', userId);
  22. }
  23. return userId;
  24. }
  25. initElements() {
  26. this.elements = {
  27. createSessionBtn: document.getElementById('createSessionBtn'),
  28. refreshSessionsBtn: document.getElementById('refreshSessionsBtn'),
  29. sessionList: document.getElementById('sessionList'),
  30. currentSessionTitle: document.getElementById('currentSessionTitle'),
  31. chatMessages: document.getElementById('chatMessages'),
  32. messageInput: document.getElementById('messageInput'),
  33. sendMessageBtn: document.getElementById('sendMessageBtn'),
  34. clearChatBtn: document.getElementById('clearChatBtn'),
  35. exportChatBtn: document.getElementById('exportChatBtn'),
  36. streamToggleBtn: document.getElementById('streamToggleBtn'),
  37. toggleLogsBtn: document.getElementById('toggleLogsBtn'),
  38. logsPanel: document.getElementById('logsPanel'),
  39. logEntries: document.getElementById('logEntries'),
  40. statusText: document.getElementById('statusText'),
  41. portInfo: document.getElementById('portInfo')
  42. };
  43. }
  44. bindEvents() {
  45. this.elements.createSessionBtn.addEventListener('click', () => this.createSession());
  46. this.elements.refreshSessionsBtn.addEventListener('click', () => this.loadSessions());
  47. this.elements.sendMessageBtn.addEventListener('click', () => this.sendMessage());
  48. this.elements.clearChatBtn.addEventListener('click', () => this.clearChat());
  49. this.elements.exportChatBtn.addEventListener('click', () => this.exportChat());
  50. this.elements.streamToggleBtn.addEventListener('click', () => this.toggleStreamMode());
  51. this.elements.toggleLogsBtn.addEventListener('click', () => this.toggleLogs());
  52. this.elements.messageInput.addEventListener('keydown', (e) => {
  53. if (e.key === 'Enter' && !e.shiftKey) {
  54. e.preventDefault();
  55. this.sendMessage();
  56. }
  57. this.elements.sendMessageBtn.disabled = !this.elements.messageInput.value.trim();
  58. });
  59. this.elements.messageInput.addEventListener('input', () => {
  60. this.elements.sendMessageBtn.disabled = !this.elements.messageInput.value.trim();
  61. });
  62. }
  63. async checkHealth() {
  64. try {
  65. const response = await fetch('/api/health');
  66. const result = await response.json();
  67. if (result.success) {
  68. this.elements.statusText.textContent = '连接正常';
  69. this.elements.portInfo.textContent = `端口: ${result.data.opencode_port}`;
  70. console.log('服务状态:', result.data);
  71. } else {
  72. this.elements.statusText.textContent = '服务异常';
  73. }
  74. } catch (error) {
  75. this.elements.statusText.textContent = '连接失败';
  76. console.error('健康检查失败:', error);
  77. }
  78. }
  79. async createSession() {
  80. const title = prompt('请输入会话标题:', '新会话 ' + new Date().toLocaleTimeString());
  81. if (!title) return;
  82. try {
  83. const response = await fetch('/api/session/create', {
  84. method: 'POST',
  85. headers: { 'Content-Type': 'application/json' },
  86. body: JSON.stringify({ title })
  87. });
  88. const result = await response.json();
  89. if (result.success) {
  90. this.showNotification('会话创建成功');
  91. this.loadSessions();
  92. this.selectSession(result.data.id, result.data.title);
  93. } else {
  94. this.showNotification('会话创建失败: ' + result.message, 'error');
  95. }
  96. } catch (error) {
  97. this.showNotification('请求失败: ' + error.message, 'error');
  98. }
  99. }
  100. async loadSessions() {
  101. try {
  102. const response = await fetch('/api/session/list');
  103. const result = await response.json();
  104. if (result.success) {
  105. this.sessions = result.data;
  106. this.renderSessionList();
  107. }
  108. } catch (error) {
  109. console.error('加载会话列表失败:', error);
  110. }
  111. }
  112. renderSessionList() {
  113. const list = this.elements.sessionList;
  114. list.innerHTML = '';
  115. if (this.sessions.length === 0) {
  116. list.innerHTML = '<div class="session-item" style="text-align: center; color: #64748b;">暂无会话</div>';
  117. return;
  118. }
  119. this.sessions.forEach(session => {
  120. const item = document.createElement('li');
  121. item.className = 'session-item';
  122. if (this.currentSessionId === session.id) {
  123. item.classList.add('active');
  124. }
  125. item.innerHTML = `
  126. <div class="session-title">${this.escapeHtml(session.title)}</div>
  127. <div class="session-id">${session.id}</div>
  128. `;
  129. item.addEventListener('click', () => {
  130. this.selectSession(session.id, session.title);
  131. });
  132. list.appendChild(item);
  133. });
  134. }
  135. selectSession(sessionId, sessionTitle) {
  136. this.currentSessionId = sessionId;
  137. this.elements.currentSessionTitle.textContent = sessionTitle;
  138. this.renderSessionList();
  139. this.clearChat();
  140. this.addMessage('system', `已切换到会话: ${sessionTitle}`);
  141. this.elements.messageInput.disabled = false;
  142. this.elements.sendMessageBtn.disabled = !this.elements.messageInput.value.trim();
  143. }
  144. async sendMessage() {
  145. const message = this.elements.messageInput.value.trim();
  146. if (!message || !this.currentSessionId) return;
  147. // 添加用户消息
  148. this.addMessage('user', message);
  149. this.elements.messageInput.value = '';
  150. this.elements.sendMessageBtn.disabled = true;
  151. if (this.streamMode) {
  152. await this.sendMessageStream(message);
  153. } else {
  154. await this.sendMessageSync(message);
  155. }
  156. }
  157. async sendMessageSync(message) {
  158. try {
  159. const response = await fetch('/api/prompt/sync', {
  160. method: 'POST',
  161. headers: { 'Content-Type': 'application/json' },
  162. body: JSON.stringify({
  163. sessionID: this.currentSessionId,
  164. parts: [{ type: 'text', text: message }],
  165. agent: 'opencode'
  166. })
  167. });
  168. const result = await response.json();
  169. if (result.success) {
  170. const assistantMessage = result.data.info;
  171. this.addMessage('assistant', assistantMessage.content || '收到响应');
  172. } else {
  173. this.addMessage('system', '错误: ' + result.message);
  174. }
  175. } catch (error) {
  176. this.addMessage('system', '请求失败: ' + error.message);
  177. }
  178. }
  179. async sendMessageStream(message) {
  180. try {
  181. const response = await fetch('/api/prompt/stream', {
  182. method: 'POST',
  183. headers: { 'Content-Type': 'application/json' },
  184. body: JSON.stringify({
  185. sessionID: this.currentSessionId,
  186. parts: [{ type: 'text', text: message }],
  187. agent: 'opencode'
  188. })
  189. });
  190. if (!response.ok) {
  191. throw new Error(`HTTP ${response.status}`);
  192. }
  193. const reader = response.body.getReader();
  194. const decoder = new TextDecoder();
  195. let buffer = '';
  196. // 添加助理消息容器
  197. const messageId = 'msg_' + Date.now();
  198. const messageElement = this.addMessage('assistant', '', messageId);
  199. const contentElement = messageElement.querySelector('.message-content');
  200. while (true) {
  201. const { done, value } = await reader.read();
  202. if (done) break;
  203. buffer += decoder.decode(value, { stream: true });
  204. const lines = buffer.split('\n');
  205. buffer = lines.pop() || '';
  206. for (const line of lines) {
  207. if (line.startsWith('data: ')) {
  208. const data = line.slice(6);
  209. if (data === '[DONE]') break;
  210. try {
  211. const parsed = JSON.parse(data);
  212. // 这里根据实际的流式响应格式调整
  213. const text = parsed.text || parsed.content || parsed.data || '';
  214. contentElement.innerHTML += this.escapeHtml(text);
  215. this.scrollToBottom();
  216. } catch (e) {
  217. // 可能是纯文本
  218. contentElement.innerHTML += this.escapeHtml(data);
  219. this.scrollToBottom();
  220. }
  221. }
  222. }
  223. }
  224. if (buffer.startsWith('data: ')) {
  225. const data = buffer.slice(6);
  226. if (data !== '[DONE]') {
  227. contentElement.innerHTML += this.escapeHtml(data);
  228. }
  229. }
  230. } catch (error) {
  231. this.addMessage('system', '流式请求失败: ' + error.message);
  232. }
  233. }
  234. addMessage(role, content, id = null) {
  235. const messagesDiv = this.elements.chatMessages;
  236. const messageId = id || 'msg_' + Date.now();
  237. const messageDiv = document.createElement('div');
  238. messageDiv.className = `message ${role}`;
  239. messageDiv.id = messageId;
  240. const time = new Date().toLocaleTimeString();
  241. const roleName = role === 'user' ? '您' : role === 'assistant' ? 'OpenCode' : '系统';
  242. messageDiv.innerHTML = `
  243. <div class="message-header">
  244. <span class="message-role">${roleName}</span>
  245. <span class="message-time">${time}</span>
  246. </div>
  247. <div class="message-content">${this.escapeHtml(content)}</div>
  248. `;
  249. messagesDiv.appendChild(messageDiv);
  250. this.scrollToBottom();
  251. return messageDiv;
  252. }
  253. clearChat() {
  254. this.elements.chatMessages.innerHTML = '';
  255. }
  256. exportChat() {
  257. const messages = this.elements.chatMessages.querySelectorAll('.message');
  258. let exportText = `OpenCode 对话记录\n会话: ${this.elements.currentSessionTitle.textContent}\n时间: ${new Date().toLocaleString()}\n\n`;
  259. messages.forEach(msg => {
  260. const role = msg.classList.contains('user') ? '用户' :
  261. msg.classList.contains('assistant') ? 'OpenCode' : '系统';
  262. const time = msg.querySelector('.message-time').textContent;
  263. const content = msg.querySelector('.message-content').textContent;
  264. exportText += `[${time}] ${role}: ${content}\n\n`;
  265. });
  266. const blob = new Blob([exportText], { type: 'text/plain' });
  267. const url = URL.createObjectURL(blob);
  268. const a = document.createElement('a');
  269. a.href = url;
  270. a.download = `opencode-chat-${new Date().toISOString().slice(0,10)}.txt`;
  271. a.click();
  272. URL.revokeObjectURL(url);
  273. this.showNotification('对话已导出');
  274. }
  275. toggleStreamMode() {
  276. this.streamMode = !this.streamMode;
  277. this.elements.streamToggleBtn.textContent = `流式模式: ${this.streamMode ? '开启' : '关闭'}`;
  278. this.showNotification(`流式模式 ${this.streamMode ? '已开启' : '已关闭'}`);
  279. }
  280. toggleLogs() {
  281. this.elements.logsPanel.classList.toggle('hidden');
  282. if (!this.elements.logsPanel.classList.contains('hidden')) {
  283. this.scrollLogsToBottom();
  284. }
  285. }
  286. startLogStream() {
  287. if (this.logEventSource) {
  288. this.logEventSource.close();
  289. }
  290. this.logEventSource = new EventSource('/api/logs/stream');
  291. this.logEventSource.onmessage = (event) => {
  292. this.addLogEntry(event.data);
  293. };
  294. this.logEventSource.onerror = (error) => {
  295. console.error('日志流错误:', error);
  296. setTimeout(() => this.startLogStream(), 5000);
  297. };
  298. }
  299. addLogEntry(log) {
  300. const entry = document.createElement('div');
  301. entry.className = 'log-entry';
  302. const time = new Date().toLocaleTimeString();
  303. let source = 'system';
  304. let message = log;
  305. // 解析日志格式 [source] message
  306. const match = log.match(/^\[(\w+)\]\s+(.+)$/);
  307. if (match) {
  308. source = match[1];
  309. message = match[2];
  310. }
  311. entry.innerHTML = `
  312. <span class="log-time">${time}</span>
  313. <span class="log-source">${source}</span>
  314. <span class="log-message">${this.escapeHtml(message)}</span>
  315. `;
  316. this.elements.logEntries.appendChild(entry);
  317. // 限制日志条目数量
  318. const maxEntries = 100;
  319. const entries = this.elements.logEntries.querySelectorAll('.log-entry');
  320. if (entries.length > maxEntries) {
  321. entries[0].remove();
  322. }
  323. this.scrollLogsToBottom();
  324. }
  325. scrollToBottom() {
  326. this.elements.chatMessages.scrollTop = this.elements.chatMessages.scrollHeight;
  327. }
  328. scrollLogsToBottom() {
  329. this.elements.logsPanel.scrollTop = this.elements.logsPanel.scrollHeight;
  330. }
  331. showNotification(message, type = 'success') {
  332. // 简单的通知实现
  333. console.log(`通知 [${type}]: ${message}`);
  334. alert(message); // 实际应用中应该使用更优雅的通知方式
  335. }
  336. escapeHtml(text) {
  337. const div = document.createElement('div');
  338. div.textContent = text;
  339. return div.innerHTML;
  340. }
  341. }
  342. // 初始化应用
  343. window.addEventListener('DOMContentLoaded', () => {
  344. window.opencodeApp = new OpenCodeApp();
  345. });