Changeset 0.22.4 (#146)

This commit is contained in:
2026-03-02 22:16:08 +00:00
parent 9940fb5831
commit 3953dcf364
25 changed files with 2254 additions and 145 deletions

View File

@@ -259,6 +259,8 @@ const API = {
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
listWorkspaces() { return this._get('/api/v1/workspaces'); },
createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
updateWorkspace(id, patch) { return this._patch(`/api/v1/workspaces/${id}`, patch); },
listGitCredentials() { return this._get('/api/v1/git-credentials'); },
// ── Messages ─────────────────────────────
@@ -727,6 +729,33 @@ const API = {
adminListCapabilityOverrides() { return this._get('/api/v1/admin/capability-overrides'); },
adminGetProviderTypes() { return this._get('/api/v1/admin/provider-types'); },
// ── Project Files (v0.22.4) ─────────────
async projectUploadFile(projectId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
body: form,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
await this._refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
},
projectListFiles(projectId) { return this._get(`/api/v1/projects/${projectId}/files`); },
// ── Export (v0.22.4) ────────────────────
exportDocument(content, format, filename) {
return this._post('/api/v1/export', { content, format, filename });
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();

View File

@@ -333,6 +333,8 @@ const EditorMode = {
<div class="editor-export-menu" id="editorExportMenu">
<button class="editor-export-item" data-format="md">Download file</button>
<button class="editor-export-item" data-format="html">Export as HTML</button>
<button class="editor-export-item" data-format="pdf">Export as PDF</button>
<button class="editor-export-item" data-format="docx">Export as DOCX</button>
<button class="editor-export-item" data-format="clipboard">Copy to clipboard</button>
</div>
</div>
@@ -1020,6 +1022,37 @@ pre{background:#f5f5f5;padding:16px;border-radius:6px;overflow-x:auto}code{font-
a.click(); URL.revokeObjectURL(url);
break;
}
case 'pdf':
case 'docx': {
try {
if (typeof UI !== 'undefined') UI.toast(`Exporting as ${format.toUpperCase()}`, 'info');
const resp = await fetch(
(window.BASE_PATH || '') + '/api/v1/export',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API.accessToken}`,
},
body: JSON.stringify({ content, format, filename: fileName }),
}
);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName.replace(/\.[^.]+$/, '.' + format);
a.click();
URL.revokeObjectURL(url);
} catch (e) {
if (typeof UI !== 'undefined') UI.toast(`Export failed: ${e.message}`, 'error');
}
break;
}
case 'clipboard': {
try {
await navigator.clipboard.writeText(content);

View File

@@ -409,6 +409,26 @@ function _registerProjectPanel() {
</div>
<div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div>
</div>
<div class="project-panel-section" id="projectGitSection" style="display:none">
<label class="project-panel-label">Git Settings</label>
<div style="display:flex;flex-direction:column;gap:6px">
<input type="text" id="projectGitRemote" class="project-panel-input" placeholder="Git remote URL (https://...)" spellcheck="false">
<input type="text" id="projectGitBranch" class="project-panel-input" placeholder="Branch (default: main)" spellcheck="false">
<select id="projectGitCredential" class="project-panel-select">
<option value="">No credential</option>
</select>
<button class="btn-small" id="projectGitSave">Save Git Config</button>
</div>
<div class="project-panel-hint">Configure git remote to sync workspace files with a repository.</div>
</div>
<div class="project-panel-section" id="projectFilesSection">
<label class="project-panel-label">Project Files</label>
<div id="projectFileList" class="project-panel-list"></div>
<div style="margin-top:6px">
<input type="file" id="projectFileInput" style="display:none" multiple>
<button class="btn-small" id="projectFileUploadBtn">Upload File</button>
</div>
</div>
<div class="project-panel-section project-panel-footer">
<label class="project-panel-checkbox">
<input type="checkbox" id="projectArchiveToggle">
@@ -434,6 +454,15 @@ function _registerProjectPanel() {
el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace);
el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace);
// Wire git settings (v0.22.4)
el.querySelector('#projectGitSave').addEventListener('click', _saveProjectGitConfig);
// Wire project files (v0.22.4)
el.querySelector('#projectFileUploadBtn').addEventListener('click', () => {
document.getElementById('projectFileInput').click();
});
el.querySelector('#projectFileInput').addEventListener('change', _handleProjectFileUpload);
PanelRegistry.register('project', {
element: el,
label: 'Project',
@@ -481,6 +510,12 @@ async function _loadProjectPanel(projectId) {
// Load workspace picker (v0.21.5)
await _renderProjectWorkspace(projectId);
// Load git settings (v0.22.4) — depends on workspace being loaded
await _renderProjectGitSettings(projectId);
// Load project files (v0.22.4)
await _renderProjectFiles(projectId);
}
async function _saveProjectPrompt() {
@@ -700,6 +735,8 @@ async function _saveProjectWorkspace() {
UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success');
// Trigger editor surface check
if (typeof EditorMode !== 'undefined') EditorMode.check();
// Refresh git settings (v0.22.4)
await _renderProjectGitSettings(_projectPanelId);
} catch (e) {
UI.toast('Failed to update workspace', 'error');
}
@@ -731,6 +768,106 @@ async function _createProjectWorkspace() {
// ── Channel workspace picker (v0.21.5) ───────
// ── Workspace Git Config (v0.22.4) ───────────
async function _renderProjectGitSettings(projectId) {
const gitSection = document.getElementById('projectGitSection');
const wsId = document.getElementById('projectWsSelect')?.value;
if (!wsId) {
gitSection.style.display = 'none';
return;
}
gitSection.style.display = '';
try {
const ws = await API.getWorkspace(wsId);
document.getElementById('projectGitRemote').value = ws.git_remote_url || '';
document.getElementById('projectGitBranch').value = ws.git_branch || '';
// Load git credentials
const credSel = document.getElementById('projectGitCredential');
credSel.innerHTML = '<option value="">No credential</option>';
try {
const creds = await API.listGitCredentials();
const list = Array.isArray(creds) ? creds : (creds?.data || []);
for (const c of list) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name || c.id.slice(0, 8);
if (c.id === ws.git_credential_id) opt.selected = true;
credSel.appendChild(opt);
}
} catch (_) {}
} catch (_) {
gitSection.style.display = 'none';
}
}
async function _saveProjectGitConfig() {
const wsId = document.getElementById('projectWsSelect')?.value;
if (!wsId) return;
const remote = document.getElementById('projectGitRemote').value.trim();
const branch = document.getElementById('projectGitBranch').value.trim() || null;
const credId = document.getElementById('projectGitCredential').value || null;
try {
await API.updateWorkspace(wsId, {
git_remote_url: remote || null,
git_branch: branch,
git_credential_id: credId,
});
UI.toast('Git config saved', 'success');
} catch (e) {
UI.toast('Failed to save git config: ' + (e.message || e), 'error');
}
}
// ── Project Files (v0.22.4) ──────────────────
async function _renderProjectFiles(projectId) {
const list = document.getElementById('projectFileList');
if (!list) return;
try {
const resp = await API.projectListFiles(projectId);
const files = resp?.files || [];
if (!files.length) {
list.innerHTML = '<div class="project-panel-empty">No files uploaded</div>';
return;
}
list.innerHTML = files.map(f => {
const size = f.size_bytes > 1024 * 1024
? (f.size_bytes / 1024 / 1024).toFixed(1) + ' MB'
: (f.size_bytes / 1024).toFixed(1) + ' KB';
return `<div class="project-panel-item" style="display:flex;justify-content:space-between;align-items:center">
<span title="${esc(f.filename)}">${esc(f.filename)}</span>
<span class="text-muted" style="font-size:11px">${size}</span>
</div>`;
}).join('');
} catch (e) {
list.innerHTML = '<div class="project-panel-empty">Failed to load files</div>';
}
}
async function _handleProjectFileUpload() {
const input = document.getElementById('projectFileInput');
if (!input.files?.length || !_projectPanelId) return;
for (const file of input.files) {
try {
await API.projectUploadFile(_projectPanelId, file);
UI.toast(`Uploaded ${file.name}`, 'success');
} catch (e) {
UI.toast(`Upload failed: ${e.message || e}`, 'error');
}
}
input.value = '';
await _renderProjectFiles(_projectPanelId);
}
async function showWorkspacePicker(chatId) {
let workspaces = [];
try {

View File

@@ -929,6 +929,10 @@ Object.assign(UI, {
<span class="admin-storage-label">Timeouts</span>
<span class="admin-storage-value">${p.timeout_count ?? 0}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Rate Limits</span>
<span class="admin-storage-value" style="color:${(p.rate_limit_count || 0) > 0 ? 'var(--warning)' : 'inherit'}">${p.rate_limit_count ?? 0}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Max Latency</span>
<span class="admin-storage-value">${p.max_latency_ms ?? 0}ms</span>