Changeset 0.10.1 (#57)
This commit is contained in:
44
CHANGELOG.md
44
CHANGELOG.md
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.10.1] — 2026-02-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Admin System Prompt** — Global system prompt configured in Admin › Settings.
|
||||||
|
Injected as the first system message in every conversation, before user/preset
|
||||||
|
system prompts. Users cannot override or disable it. Stored in
|
||||||
|
`global_settings['system_prompt']`.
|
||||||
|
- **Personas tab** — User presets moved from the Models tab to their own
|
||||||
|
"Personas" tab in Settings. Tab is hidden when admin disables user presets
|
||||||
|
(`allow_user_personas` policy).
|
||||||
|
- **Team Management modal** — Team admin functionality extracted from Settings
|
||||||
|
into its own tabbed modal (Members, Providers, Presets, Usage, Activity).
|
||||||
|
Accessed via "Team Management" flyout menu item, or "Manage →" on team cards
|
||||||
|
in Settings. Multi-team admins see a picker first; single-team admins go
|
||||||
|
straight to the tabbed view. Back arrow in header returns to picker.
|
||||||
|
- **Preview pane: clear button** — Trash icon in the preview header resets the
|
||||||
|
iframe and shows the empty hint. Also auto-clears when deleting a chat that
|
||||||
|
had active preview content.
|
||||||
|
- **Preview/Notes pane: fullscreen** — Expand button in the header toggles
|
||||||
|
fullscreen mode (panel fills entire viewport width).
|
||||||
|
- **Preview/Notes pane: resizable** — Drag the left edge of the side panel
|
||||||
|
to resize (280px–70vw). Width resets on close.
|
||||||
|
- **Code block: download** — "Download" button on every code block. Infers
|
||||||
|
file extension from language tag (e.g. `python` → `.py`, `go` → `.go`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Personal Usage scoped to BYOK** — `GET /usage` and the user Usage tab now
|
||||||
|
only show consumption against personal (BYOK) providers. Global provider
|
||||||
|
usage is the org's cost, not the user's. New `QueryByUserPersonal` store
|
||||||
|
method filters on `scope = 'personal' AND owner_id = user_id`. Admin usage
|
||||||
|
views remain unaffected. Usage tab hidden when admin disables user providers
|
||||||
|
(`allow_user_byok` policy).
|
||||||
|
- **OpenAI streaming usage race** — Deferred the Done event until the usage
|
||||||
|
chunk arrives. Previously, finish_reason fired Done before the usage chunk
|
||||||
|
(which has `choices: []`), so streaming token counts were always 0 for all
|
||||||
|
OpenAI-compatible providers.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Usage logging zero-token guard** — Removed early exit in `logUsage` that
|
||||||
|
suppressed rows when tokens were 0. Combined with the streaming race above,
|
||||||
|
this meant no streaming usage was ever recorded.
|
||||||
|
- **Live chat completion test** — `TestLive_VeniceChatCompletion` sent wrong
|
||||||
|
field names (`messages`/`config_id` instead of `content`/`provider_config_id`).
|
||||||
|
|
||||||
## [0.10.0] — 2026-02-24
|
## [0.10.0] — 2026-02-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -267,11 +267,19 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
// Banner config, branding, etc. — safe subset for non-admin users
|
// Banner config, branding, etc. — safe subset for non-admin users
|
||||||
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
|
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
|
||||||
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
|
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
|
||||||
|
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
|
||||||
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
||||||
|
|
||||||
|
// Only tell the user whether admin prompt exists — don't expose content
|
||||||
|
hasAdminPrompt := false
|
||||||
|
if content, ok := systemPrompt["content"].(string); ok && content != "" {
|
||||||
|
hasAdminPrompt = true
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"banner": banner,
|
"banner": banner,
|
||||||
"branding": branding,
|
"branding": branding,
|
||||||
|
"has_admin_prompt": hasAdminPrompt,
|
||||||
"policies": gin.H{
|
"policies": gin.H{
|
||||||
"allow_registration": policies["allow_registration"],
|
"allow_registration": policies["allow_registration"],
|
||||||
"allow_user_byok": policies["allow_user_byok"],
|
"allow_user_byok": policies["allow_user_byok"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -480,14 +481,25 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
// active path through the message tree. Only the current branch is sent —
|
// active path through the message tree. Only the current branch is sent —
|
||||||
// sibling branches are never included in context.
|
// sibling branches are never included in context.
|
||||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
||||||
// Load system prompt from channel
|
messages := make([]providers.Message, 0)
|
||||||
|
|
||||||
|
// ── Admin system prompt (always injected first, no opt out) ──
|
||||||
|
adminPrompt, err := h.stores.GlobalConfig.Get(context.Background(), "system_prompt")
|
||||||
|
if err == nil {
|
||||||
|
if content, ok := adminPrompt["content"].(string); ok && content != "" {
|
||||||
|
messages = append(messages, providers.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User/preset system prompt (appended after admin prompt) ──
|
||||||
var systemPrompt *string
|
var systemPrompt *string
|
||||||
_ = database.DB.QueryRow(
|
_ = database.DB.QueryRow(
|
||||||
`SELECT system_prompt FROM channels WHERE id = $1`, channelID,
|
`SELECT system_prompt FROM channels WHERE id = $1`, channelID,
|
||||||
).Scan(&systemPrompt)
|
).Scan(&systemPrompt)
|
||||||
|
|
||||||
messages := make([]providers.Message, 0)
|
|
||||||
|
|
||||||
// Preset system prompt takes priority; channel system prompt is fallback
|
// Preset system prompt takes priority; channel system prompt is fallback
|
||||||
if presetSystemPrompt != "" {
|
if presetSystemPrompt != "" {
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
|
|||||||
@@ -1861,21 +1861,35 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
|
|||||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||||
|
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||||
userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
|
userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
|
||||||
|
|
||||||
// Create global provider
|
// Create global provider (admin-managed)
|
||||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||||
"name": "Global2", "provider": "openai",
|
"name": "Global2", "provider": "openai",
|
||||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
|
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
|
||||||
})
|
})
|
||||||
var cfg map[string]interface{}
|
var globalCfg map[string]interface{}
|
||||||
decode(w, &cfg)
|
decode(w, &globalCfg)
|
||||||
cfgID := cfg["id"].(string)
|
globalCfgID := globalCfg["id"].(string)
|
||||||
|
|
||||||
seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
|
// Create personal BYOK provider (user-managed)
|
||||||
seedUsage(t, userID, cfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
|
w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
|
||||||
|
"name": "MyKey", "provider": "openai",
|
||||||
|
"endpoint": "http://localhost:1/v1", "api_key": "sk-user-carol",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create personal config: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var personalCfg map[string]interface{}
|
||||||
|
decode(w, &personalCfg)
|
||||||
|
personalCfgID := personalCfg["id"].(string)
|
||||||
|
|
||||||
// Personal view should include all scopes
|
// Seed usage against both providers
|
||||||
|
seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
|
||||||
|
seedUsage(t, userID, personalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
|
||||||
|
|
||||||
|
// Personal view should only show BYOK usage (not global)
|
||||||
w = h.request("GET", "/api/v1/usage", userToken, nil)
|
w = h.request("GET", "/api/v1/usage", userToken, nil)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
|
t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
|
||||||
@@ -1884,8 +1898,26 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
|
|||||||
decode(w, &resp)
|
decode(w, &resp)
|
||||||
totals := resp["totals"].(map[string]interface{})
|
totals := resp["totals"].(map[string]interface{})
|
||||||
|
|
||||||
if int(totals["requests"].(float64)) != 2 {
|
if int(totals["requests"].(float64)) != 1 {
|
||||||
t.Errorf("personal should see 2 requests (includes BYOK), got %v", totals["requests"])
|
t.Errorf("personal should see 1 request (BYOK only, not global), got %v", totals["requests"])
|
||||||
|
}
|
||||||
|
if int(totals["input_tokens"].(float64)) != 500 {
|
||||||
|
t.Errorf("personal should see 500 input tokens (BYOK row), got %v", totals["input_tokens"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin view should show global usage but exclude BYOK
|
||||||
|
w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
decode(w, &resp)
|
||||||
|
totals = resp["totals"].(map[string]interface{})
|
||||||
|
|
||||||
|
if int(totals["requests"].(float64)) != 1 {
|
||||||
|
t.Errorf("admin should see 1 request (global only, not BYOK), got %v", totals["requests"])
|
||||||
|
}
|
||||||
|
if int(totals["input_tokens"].(float64)) != 1000 {
|
||||||
|
t.Errorf("admin should see 1000 input tokens (global row), got %v", totals["input_tokens"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (h *UsageHandler) PersonalUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
|
results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ type GlobalConfigStore interface {
|
|||||||
type UsageStore interface {
|
type UsageStore interface {
|
||||||
Log(ctx context.Context, entry *models.UsageEntry) error
|
Log(ctx context.Context, entry *models.UsageEntry) error
|
||||||
QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||||
|
QueryByUserPersonal(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||||
QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||||
QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||||
QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error)
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.
|
|||||||
return s.query(ctx, where, args, opts)
|
return s.query(ctx, where, args, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only.
|
||||||
|
func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||||
|
where := []string{
|
||||||
|
"user_id = $1",
|
||||||
|
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
|
||||||
|
}
|
||||||
|
args := []interface{}{userID}
|
||||||
|
return s.query(ctx, where, args, opts)
|
||||||
|
}
|
||||||
|
|
||||||
// QueryByTeam returns aggregated usage for all members of a team (admin view).
|
// QueryByTeam returns aggregated usage for all members of a team (admin view).
|
||||||
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||||
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"}
|
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"}
|
||||||
@@ -104,11 +114,15 @@ func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, o
|
|||||||
return &t, err
|
return &t, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPersonalTotals returns aggregate totals for a specific user (all scopes).
|
// GetPersonalTotals returns aggregate totals for a user's own provider usage.
|
||||||
|
// Only includes usage against personal (BYOK) providers — global provider
|
||||||
|
// costs are the org's responsibility, not the user's.
|
||||||
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
||||||
baseWhere := []string{"user_id = $1"}
|
baseWhere := []string{
|
||||||
|
"user_id = $1",
|
||||||
|
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
|
||||||
|
}
|
||||||
baseArgs := []interface{}{userID}
|
baseArgs := []interface{}{userID}
|
||||||
// Don't exclude BYOK for personal view
|
|
||||||
opts.ExcludeBYOK = false
|
opts.ExcludeBYOK = false
|
||||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
||||||
|
|
||||||
|
|||||||
@@ -579,17 +579,38 @@ a:hover { text-decoration: underline; }
|
|||||||
.side-panel {
|
.side-panel {
|
||||||
width: 0; min-width: 0; overflow: hidden;
|
width: 0; min-width: 0; overflow: hidden;
|
||||||
background: var(--bg); border-left: 1px solid var(--border);
|
background: var(--bg); border-left: 1px solid var(--border);
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column; position: relative;
|
||||||
transition: width 0.25s ease, min-width 0.25s ease;
|
transition: width 0.25s ease, min-width 0.25s ease;
|
||||||
}
|
}
|
||||||
.side-panel.open {
|
.side-panel.open {
|
||||||
width: 480px; min-width: 480px;
|
width: 480px; min-width: 480px;
|
||||||
}
|
}
|
||||||
|
.side-panel.fullscreen {
|
||||||
|
position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
|
width: 100% !important; min-width: 100% !important;
|
||||||
|
z-index: 100;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.side-panel-resize {
|
||||||
|
position: absolute; left: -3px; top: 0; bottom: 0; width: 6px;
|
||||||
|
cursor: col-resize; z-index: 10;
|
||||||
|
}
|
||||||
|
.side-panel-resize:hover { background: var(--accent); opacity: 0.3; }
|
||||||
|
.side-panel.fullscreen .side-panel-resize { display: none; }
|
||||||
.side-panel-header {
|
.side-panel-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg-raised); flex-shrink: 0;
|
background: var(--bg-raised); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.side-panel-actions {
|
||||||
|
display: flex; align-items: center; gap: 2px;
|
||||||
|
}
|
||||||
|
.side-panel-action-btn {
|
||||||
|
background: none; border: none; color: var(--text-3);
|
||||||
|
cursor: pointer; padding: 4px 6px; border-radius: var(--radius);
|
||||||
|
transition: all var(--transition); display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.side-panel-tabs {
|
.side-panel-tabs {
|
||||||
display: flex; gap: 2px; background: var(--bg-surface);
|
display: flex; gap: 2px; background: var(--bg-surface);
|
||||||
border-radius: var(--radius); padding: 2px;
|
border-radius: var(--radius); padding: 2px;
|
||||||
@@ -1279,6 +1300,12 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
|
.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
|
||||||
.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; }
|
.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; }
|
||||||
.team-card-info { display: flex; align-items: center; gap: 8px; }
|
.team-card-info { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.team-admin-back {
|
||||||
|
cursor: pointer; margin-right: 4px; opacity: 0.5;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
.team-admin-back:hover { opacity: 1; }
|
||||||
|
.team-tab-content { padding: 0; }
|
||||||
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
.admin-user-row.user-inactive { opacity: 0.7; }
|
.admin-user-row.user-inactive { opacity: 0.7; }
|
||||||
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
||||||
|
|||||||
223
src/index.html
223
src/index.html
@@ -95,6 +95,10 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
<span>Admin Panel</span>
|
<span>Admin Panel</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="flyout-item" id="menuTeamAdmin" style="display:none">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
<span>Team Management</span>
|
||||||
|
</button>
|
||||||
<button class="flyout-item" id="menuDebug">
|
<button class="flyout-item" id="menuDebug">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><path d="M4 14h4"/><path d="M16 14h4"/><path d="M12 18v-6"/><circle cx="12" cy="14" r="6"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><path d="M4 14h4"/><path d="M16 14h4"/><path d="M12 18v-6"/><circle cx="12" cy="14" r="6"/></svg>
|
||||||
<span>Debug Log</span>
|
<span>Debug Log</span>
|
||||||
@@ -161,12 +165,21 @@
|
|||||||
|
|
||||||
<!-- Side Panel (preview + notes) -->
|
<!-- Side Panel (preview + notes) -->
|
||||||
<aside class="side-panel" id="sidePanel">
|
<aside class="side-panel" id="sidePanel">
|
||||||
|
<div class="side-panel-resize" id="sidePanelResize"></div>
|
||||||
<div class="side-panel-header">
|
<div class="side-panel-header">
|
||||||
<div class="side-panel-tabs" id="sidePanelTabs">
|
<div class="side-panel-tabs" id="sidePanelTabs">
|
||||||
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
|
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
|
||||||
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
|
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel">✕</button>
|
<div class="side-panel-actions">
|
||||||
|
<button class="side-panel-action-btn" id="sidePanelClearBtn" onclick="clearPreview()" title="Clear preview" style="display:none">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel">✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="side-panel-body">
|
<div class="side-panel-body">
|
||||||
<!-- Preview tab -->
|
<!-- Preview tab -->
|
||||||
@@ -321,8 +334,8 @@
|
|||||||
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')" id="settingsProvidersTabBtn">Providers</button>
|
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')" id="settingsProvidersTabBtn">Providers</button>
|
||||||
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
||||||
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')">Usage</button>
|
<button class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')" id="settingsPersonasTabBtn">Personas</button>
|
||||||
<button class="settings-tab" data-stab="teams" onclick="UI.switchSettingsTab('teams')" id="settingsTeamsTabBtn" style="display:none">Teams</button>
|
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
@@ -414,12 +427,15 @@
|
|||||||
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through presets.</p>
|
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through presets.</p>
|
||||||
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
|
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- Personas Tab (hidden if admin disables user presets) -->
|
||||||
|
<div class="settings-tab-content" id="settingsPersonasTab" style="display:none">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3 style="font-size:14px;margin-bottom:4px">My Presets</h3>
|
<h3 style="font-size:14px;margin-bottom:4px">My Personas</h3>
|
||||||
<p class="section-hint" style="margin-bottom:8px">Personal model presets with custom system prompts and settings.</p>
|
<p class="section-hint" style="margin-bottom:8px">Personal model presets with custom system prompts and settings.</p>
|
||||||
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
|
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
|
||||||
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Preset</button>
|
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<!-- Usage Tab -->
|
<!-- Usage Tab -->
|
||||||
@@ -442,102 +458,114 @@
|
|||||||
<div id="myUsageResults"></div>
|
<div id="myUsageResults"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<!-- Teams Tab (visible to team admins) -->
|
</div>
|
||||||
<div class="settings-tab-content" id="settingsTeamsTab" style="display:none">
|
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||||
<div id="settingsTeamPicker"></div>
|
</div>
|
||||||
<div id="settingsTeamManage" style="display:none">
|
</div>
|
||||||
<button class="notes-back-btn" id="settingsTeamBackBtn">← Back to teams</button>
|
|
||||||
<h3 id="settingsTeamManageName" style="margin:8px 0 4px;font-size:15px"></h3>
|
|
||||||
<!-- Members -->
|
|
||||||
<section class="settings-section">
|
|
||||||
<h4 style="font-size:13px;margin-bottom:6px">Members</h4>
|
|
||||||
<div id="settingsTeamMembers"></div>
|
|
||||||
<div id="settingsTeamAddMember" style="display:none;margin-top:8px" class="admin-inline-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group"><label>User</label><select id="settingsTeamMemberUser"></select></div>
|
|
||||||
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button><button class="btn-small" id="settingsTeamCancelMember">Cancel</button></div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
|
|
||||||
</section>
|
|
||||||
<!-- Team Providers -->
|
|
||||||
<section class="settings-section">
|
|
||||||
<h4 style="font-size:13px;margin-bottom:4px">Providers</h4>
|
|
||||||
<div id="settingsTeamProviders"></div>
|
|
||||||
<div id="settingsTeamEditProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
|
||||||
<div class="form-grid-2col">
|
|
||||||
<div class="form-group"><label>Name</label><input id="teamProviderEditName"></div>
|
|
||||||
<div class="form-group"><label>Endpoint</label><input id="teamProviderEditEndpoint"></div>
|
|
||||||
<div class="form-group"><label>API Key</label><input id="teamProviderEditKey" type="password" placeholder="(unchanged)"></div>
|
|
||||||
<div class="form-group"><label>Default Model</label><input id="teamProviderEditDefault" placeholder="optional"></div>
|
|
||||||
</div>
|
|
||||||
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderEditPrivate"> Private provider (data stays on-prem)</label></div>
|
|
||||||
<div class="form-row">
|
|
||||||
<button class="btn-small btn-primary" id="settingsTeamEditProviderSubmit">Update</button>
|
|
||||||
<button class="btn-small" id="settingsTeamCancelEditProvider">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="settingsTeamAddProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
|
||||||
<div class="form-grid-2col">
|
|
||||||
<div class="form-group"><label>Name</label><input id="teamProviderName" placeholder="e.g. Team OpenAI"></div>
|
|
||||||
<div class="form-group"><label>Provider</label><select id="teamProviderType"></select></div>
|
|
||||||
<div class="form-group"><label>Endpoint</label><input id="teamProviderEndpoint" placeholder="https://api.openai.com/v1"></div>
|
|
||||||
<div class="form-group"><label>API Key</label><input id="teamProviderKey" type="password" placeholder="sk-..."></div>
|
|
||||||
</div>
|
|
||||||
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderPrivate"> Private provider (data stays on-prem)</label></div>
|
|
||||||
<div class="form-row">
|
|
||||||
<button class="btn-small btn-primary" id="settingsTeamAddProviderSubmit">Add Provider</button>
|
|
||||||
<button class="btn-small" id="settingsTeamCancelProvider">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Team Presets -->
|
<!-- ── Team Management Modal ─────────────────── -->
|
||||||
<section class="settings-section">
|
<div class="modal-overlay" id="teamAdminModal">
|
||||||
<h4 style="font-size:13px;margin-bottom:6px">Team Presets</h4>
|
<div class="modal modal-wide">
|
||||||
<div id="settingsTeamPresets"></div>
|
<div class="modal-header">
|
||||||
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
<h2 id="teamAdminTitle">Team Management</h2>
|
||||||
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
<button class="modal-close" id="teamAdminCloseBtn">✕</button>
|
||||||
</section>
|
</div>
|
||||||
<!-- Team Usage (team admins only — usage against team-owned providers) -->
|
<!-- Team picker (shown when user admins multiple teams) -->
|
||||||
<section class="settings-section">
|
<div id="teamAdminPicker" style="display:none">
|
||||||
<h4 style="font-size:13px;margin-bottom:6px">Usage</h4>
|
<div class="modal-body" id="teamAdminPickerList"></div>
|
||||||
<div class="form-row" style="gap:6px;margin-bottom:8px">
|
</div>
|
||||||
<select id="teamUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
|
<!-- Tabbed management (shown after selecting a team) -->
|
||||||
<option value="7d">7 days</option>
|
<div id="teamAdminContent" style="display:none">
|
||||||
<option value="30d" selected>30 days</option>
|
<div class="modal-tabs admin-tabs" id="teamAdminTabs">
|
||||||
<option value="90d">90 days</option>
|
<button class="admin-tab active" data-ttab="members" onclick="UI.switchTeamTab('members')">Members</button>
|
||||||
</select>
|
<button class="admin-tab" data-ttab="providers" onclick="UI.switchTeamTab('providers')">Providers</button>
|
||||||
<select id="teamUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
|
<button class="admin-tab" data-ttab="presets" onclick="UI.switchTeamTab('presets')">Presets</button>
|
||||||
<option value="model">By Model</option>
|
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
|
||||||
<option value="user">By User</option>
|
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
|
||||||
<option value="day">By Day</option>
|
</div>
|
||||||
</select>
|
<div class="modal-body">
|
||||||
|
<!-- Members tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabMembers">
|
||||||
|
<div id="settingsTeamMembers"></div>
|
||||||
|
<div id="settingsTeamAddMember" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>User</label><select id="settingsTeamMemberUser"></select></div>
|
||||||
|
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="settingsTeamUsageTotals"></div>
|
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button><button class="btn-small" id="settingsTeamCancelMember">Cancel</button></div>
|
||||||
<div id="settingsTeamUsageResults"></div>
|
</div>
|
||||||
</section>
|
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
|
||||||
<!-- Team Activity (team admins only — audit scoped to team) -->
|
</div>
|
||||||
<section class="settings-section">
|
<!-- Providers tab -->
|
||||||
<h4 style="font-size:13px;margin-bottom:6px">Activity Log</h4>
|
<div class="team-tab-content" id="teamTabProviders" style="display:none">
|
||||||
<div class="audit-filters" style="margin-bottom:8px">
|
<div id="settingsTeamProviders"></div>
|
||||||
<select id="teamAuditFilterAction" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamAuditLog()">
|
<div id="settingsTeamEditProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||||
<option value="">All actions</option>
|
<div class="form-grid-2col">
|
||||||
</select>
|
<div class="form-group"><label>Name</label><input id="teamProviderEditName"></div>
|
||||||
|
<div class="form-group"><label>Endpoint</label><input id="teamProviderEditEndpoint"></div>
|
||||||
|
<div class="form-group"><label>API Key</label><input id="teamProviderEditKey" type="password" placeholder="(unchanged)"></div>
|
||||||
|
<div class="form-group"><label>Default Model</label><input id="teamProviderEditDefault" placeholder="optional"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="settingsTeamAudit" style="max-height:300px;overflow-y:auto"></div>
|
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderEditPrivate"> Private provider (data stays on-prem)</label></div>
|
||||||
<div id="teamAuditPagination" class="audit-pagination" style="display:none">
|
<div class="form-row">
|
||||||
<button class="btn-small" id="teamAuditPrevBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage - 1)">← Prev</button>
|
<button class="btn-small btn-primary" id="settingsTeamEditProviderSubmit">Update</button>
|
||||||
<span id="teamAuditPageInfo" style="font-size:11px;color:var(--text-3)"></span>
|
<button class="btn-small" id="settingsTeamCancelEditProvider">Cancel</button>
|
||||||
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
<div id="settingsTeamAddProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||||
|
<div class="form-grid-2col">
|
||||||
|
<div class="form-group"><label>Name</label><input id="teamProviderName" placeholder="e.g. Team OpenAI"></div>
|
||||||
|
<div class="form-group"><label>Provider</label><select id="teamProviderType"></select></div>
|
||||||
|
<div class="form-group"><label>Endpoint</label><input id="teamProviderEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||||
|
<div class="form-group"><label>API Key</label><input id="teamProviderKey" type="password" placeholder="sk-..."></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderPrivate"> Private provider (data stays on-prem)</label></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<button class="btn-small btn-primary" id="settingsTeamAddProviderSubmit">Add Provider</button>
|
||||||
|
<button class="btn-small" id="settingsTeamCancelProvider">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
|
||||||
|
</div>
|
||||||
|
<!-- Presets tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabPresets" style="display:none">
|
||||||
|
<div id="settingsTeamPresets"></div>
|
||||||
|
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
|
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
||||||
|
</div>
|
||||||
|
<!-- Usage tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabUsage" style="display:none">
|
||||||
|
<div class="form-row" style="gap:6px;margin-bottom:8px">
|
||||||
|
<select id="teamUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
|
||||||
|
<option value="7d">7 days</option>
|
||||||
|
<option value="30d" selected>30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
</select>
|
||||||
|
<select id="teamUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
|
||||||
|
<option value="model">By Model</option>
|
||||||
|
<option value="user">By User</option>
|
||||||
|
<option value="day">By Day</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="settingsTeamUsageTotals"></div>
|
||||||
|
<div id="settingsTeamUsageResults"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Activity tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabActivity" style="display:none">
|
||||||
|
<div class="audit-filters" style="margin-bottom:8px">
|
||||||
|
<select id="teamAuditFilterAction" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamAuditLog()">
|
||||||
|
<option value="">All actions</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="settingsTeamAudit" style="max-height:400px;overflow-y:auto"></div>
|
||||||
|
<div id="teamAuditPagination" class="audit-pagination" style="display:none">
|
||||||
|
<button class="btn-small" id="teamAuditPrevBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage - 1)">← Prev</button>
|
||||||
|
<span id="teamAuditPageInfo" style="font-size:11px;color:var(--text-3)"></span>
|
||||||
|
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -647,6 +675,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>System Prompt</h3>
|
||||||
|
<p class="section-hint" style="margin-bottom:6px">Injected into every conversation before user/preset prompts. Users cannot override or disable this.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea id="adminSystemPrompt" rows="4" placeholder="e.g. You are a helpful assistant for Acme Corp. Always respond professionally."></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Registration</h3>
|
<h3>Registration</h3>
|
||||||
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow new user registration</label>
|
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow new user registration</label>
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ async function deleteChat(chatId) {
|
|||||||
try {
|
try {
|
||||||
await API.deleteChannel(chatId);
|
await API.deleteChannel(chatId);
|
||||||
App.chats = App.chats.filter(c => c.id !== chatId);
|
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||||
if (App.currentChatId === chatId) newChat();
|
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||||
}
|
}
|
||||||
@@ -907,6 +907,8 @@ function initListeners() {
|
|||||||
// Flyout items
|
// Flyout items
|
||||||
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
||||||
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||||||
|
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
|
||||||
|
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
|
||||||
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||||
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||||||
|
|
||||||
@@ -1173,9 +1175,6 @@ function initListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Settings — Team management (team admin self-service)
|
// Settings — Team management (team admin self-service)
|
||||||
document.getElementById('settingsTeamBackBtn')?.addEventListener('click', () => {
|
|
||||||
UI.loadTeamsTab();
|
|
||||||
});
|
|
||||||
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
|
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
|
||||||
document.getElementById('settingsTeamAddMember').style.display = '';
|
document.getElementById('settingsTeamAddMember').style.display = '';
|
||||||
// Load available users (requires sys-admin; team admins use their own knowledge)
|
// Load available users (requires sys-admin; team admins use their own knowledge)
|
||||||
@@ -1698,6 +1697,10 @@ async function editProvider(id) {
|
|||||||
|
|
||||||
async function handleSaveAdminSettings() {
|
async function handleSaveAdminSettings() {
|
||||||
try {
|
try {
|
||||||
|
// Admin system prompt → global_settings (JSON value)
|
||||||
|
const sysPromptContent = document.getElementById('adminSystemPrompt').value.trim();
|
||||||
|
await API.adminUpdateSetting('system_prompt', { value: { content: sysPromptContent } });
|
||||||
|
|
||||||
// Policies — send as string "true"/"false" so backend routes to platform_policies
|
// Policies — send as string "true"/"false" so backend routes to platform_policies
|
||||||
const reg = document.getElementById('adminRegToggle').checked;
|
const reg = document.getElementById('adminRegToggle').checked;
|
||||||
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
|
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
|
||||||
|
|||||||
221
src/js/ui.js
221
src/js/ui.js
@@ -827,6 +827,7 @@ const UI = {
|
|||||||
UI.loadProfileIntoSettings();
|
UI.loadProfileIntoSettings();
|
||||||
UI.loadMyTeams();
|
UI.loadMyTeams();
|
||||||
UI.checkUserProvidersAllowed();
|
UI.checkUserProvidersAllowed();
|
||||||
|
UI.checkUserPresetsAllowed();
|
||||||
UI.switchSettingsTab('general');
|
UI.switchSettingsTab('general');
|
||||||
openModal('settingsModal');
|
openModal('settingsModal');
|
||||||
},
|
},
|
||||||
@@ -842,10 +843,10 @@ const UI = {
|
|||||||
UI.loadProviderList();
|
UI.loadProviderList();
|
||||||
UI.checkUserProvidersAllowed();
|
UI.checkUserProvidersAllowed();
|
||||||
}
|
}
|
||||||
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
|
if (tab === 'models') UI.loadUserModels();
|
||||||
|
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
|
||||||
if (tab === 'usage') UI.loadMyUsage();
|
if (tab === 'usage') UI.loadMyUsage();
|
||||||
if (tab === 'appearance') UI.loadAppearanceSettings();
|
if (tab === 'appearance') UI.loadAppearanceSettings();
|
||||||
if (tab === 'teams') UI.loadTeamsTab();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Appearance Settings ─────────────────
|
// ── Appearance Settings ─────────────────
|
||||||
@@ -899,19 +900,23 @@ const UI = {
|
|||||||
const notice = document.getElementById('userProvidersDisabled');
|
const notice = document.getElementById('userProvidersDisabled');
|
||||||
const addBtn = document.getElementById('providerShowAddBtn');
|
const addBtn = document.getElementById('providerShowAddBtn');
|
||||||
const tabBtn = document.getElementById('settingsProvidersTabBtn');
|
const tabBtn = document.getElementById('settingsProvidersTabBtn');
|
||||||
|
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
|
||||||
if (!notice) return;
|
if (!notice) return;
|
||||||
const allowed = App.policies?.allow_user_byok === 'true';
|
const allowed = App.policies?.allow_user_byok === 'true';
|
||||||
notice.style.display = allowed ? 'none' : '';
|
notice.style.display = allowed ? 'none' : '';
|
||||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||||
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||||
|
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
|
||||||
},
|
},
|
||||||
|
|
||||||
checkUserPresetsAllowed() {
|
checkUserPresetsAllowed() {
|
||||||
const addBtn = document.getElementById('userAddPresetBtn');
|
const addBtn = document.getElementById('userAddPresetBtn');
|
||||||
const addForm = document.getElementById('userAddPresetForm');
|
const addForm = document.getElementById('userAddPresetForm');
|
||||||
|
const tabBtn = document.getElementById('settingsPersonasTabBtn');
|
||||||
const allowed = App.policies?.allow_user_personas === 'true';
|
const allowed = App.policies?.allow_user_personas === 'true';
|
||||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||||
if (addForm && !allowed) addForm.style.display = 'none';
|
if (addForm && !allowed) addForm.style.display = 'none';
|
||||||
|
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadProfileIntoSettings() {
|
async loadProfileIntoSettings() {
|
||||||
@@ -926,16 +931,16 @@ const UI = {
|
|||||||
async loadMyTeams() {
|
async loadMyTeams() {
|
||||||
const section = document.getElementById('settingsTeamsSection');
|
const section = document.getElementById('settingsTeamsSection');
|
||||||
const el = document.getElementById('settingsTeamsList');
|
const el = document.getElementById('settingsTeamsList');
|
||||||
const tabBtn = document.getElementById('settingsTeamsTabBtn');
|
const menuBtn = document.getElementById('menuTeamAdmin');
|
||||||
if (!section || !el) return;
|
if (!section || !el) return;
|
||||||
try {
|
try {
|
||||||
const resp = await API.listMyTeams();
|
const resp = await API.listMyTeams();
|
||||||
const teams = resp.data || [];
|
const teams = resp.data || [];
|
||||||
UI._myTeams = teams;
|
UI._myTeams = teams;
|
||||||
|
|
||||||
// Show/hide the Teams tab for team admins
|
// Show/hide the Team Management flyout item for team admins
|
||||||
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
|
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
|
||||||
if (tabBtn) tabBtn.style.display = isTeamAdmin ? '' : 'none';
|
if (menuBtn) menuBtn.style.display = isTeamAdmin ? '' : 'none';
|
||||||
|
|
||||||
if (teams.length === 0) {
|
if (teams.length === 0) {
|
||||||
section.style.display = 'none';
|
section.style.display = 'none';
|
||||||
@@ -943,67 +948,62 @@ const UI = {
|
|||||||
}
|
}
|
||||||
section.style.display = '';
|
section.style.display = '';
|
||||||
el.innerHTML = teams.map(t => `
|
el.innerHTML = teams.map(t => `
|
||||||
<div class="team-card">
|
<div class="team-card"${t.my_role === 'admin' ? ` style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')"` : ''}>
|
||||||
<div class="team-card-info">
|
<div class="team-card-info">
|
||||||
<strong>${esc(t.name)}</strong>
|
<strong>${esc(t.name)}</strong>
|
||||||
<span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span>
|
<span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span>
|
||||||
|
${t.my_role === 'admin' ? '<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
||||||
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} catch (e) { section.style.display = 'none'; if (tabBtn) tabBtn.style.display = 'none'; }
|
} catch (e) { section.style.display = 'none'; if (menuBtn) menuBtn.style.display = 'none'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
_myTeams: [],
|
_myTeams: [],
|
||||||
_managingTeamId: null,
|
_managingTeamId: null,
|
||||||
|
|
||||||
loadTeamsTab() {
|
loadTeamsTab() {
|
||||||
const picker = document.getElementById('settingsTeamPicker');
|
// No longer used — picker is in team admin modal
|
||||||
const manage = document.getElementById('settingsTeamManage');
|
|
||||||
if (!picker) return;
|
|
||||||
manage.style.display = 'none';
|
|
||||||
picker.style.display = '';
|
|
||||||
|
|
||||||
const adminTeams = UI._myTeams.filter(t => t.my_role === 'admin');
|
|
||||||
if (adminTeams.length === 0) {
|
|
||||||
picker.innerHTML = '<div class="empty-hint">You are not an admin of any teams.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
picker.innerHTML = adminTeams.map(t => `
|
|
||||||
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
|
|
||||||
<div class="team-card-info">
|
|
||||||
<strong>${esc(t.name)}</strong>
|
|
||||||
<span class="badge-admin">admin</span>
|
|
||||||
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
|
|
||||||
</div>
|
|
||||||
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
|
||||||
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async openTeamManage(teamId, teamName) {
|
async openTeamManage(teamId, teamName) {
|
||||||
UI._managingTeamId = teamId;
|
UI._managingTeamId = teamId;
|
||||||
UI._teamAuditPage = 1;
|
UI._teamAuditPage = 1;
|
||||||
document.getElementById('settingsTeamPicker').style.display = 'none';
|
|
||||||
const manage = document.getElementById('settingsTeamManage');
|
// If the modal isn't open yet (called from settings "Manage →"), open it
|
||||||
manage.style.display = '';
|
if (!document.getElementById('teamAdminModal').classList.contains('active')) {
|
||||||
document.getElementById('settingsTeamManageName').textContent = teamName;
|
openModal('teamAdminModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show tabbed content, hide picker
|
||||||
|
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
|
||||||
|
const titleEl = document.getElementById('teamAdminTitle');
|
||||||
|
if (adminTeams.length > 1) {
|
||||||
|
titleEl.innerHTML = `<span class="team-admin-back" onclick="UI.showTeamPicker()" title="Back to teams">←</span> ${esc(teamName)}`;
|
||||||
|
} else {
|
||||||
|
titleEl.textContent = teamName;
|
||||||
|
}
|
||||||
|
document.getElementById('teamAdminPicker').style.display = 'none';
|
||||||
|
document.getElementById('teamAdminContent').style.display = '';
|
||||||
|
|
||||||
|
// Reset forms
|
||||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||||
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
||||||
// Reset team audit filter
|
|
||||||
const af = document.getElementById('teamAuditFilterAction');
|
const af = document.getElementById('teamAuditFilterAction');
|
||||||
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
|
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
|
||||||
await Promise.all([
|
|
||||||
UI.loadTeamManageMembers(teamId),
|
// Show Members tab first, load its data
|
||||||
UI.loadTeamManageProviders(teamId),
|
UI.switchTeamTab('members');
|
||||||
UI.loadTeamManagePresets(teamId),
|
},
|
||||||
UI.loadTeamUsage(),
|
|
||||||
UI.loadTeamAuditLog(1),
|
showTeamPicker() {
|
||||||
]);
|
document.getElementById('teamAdminTitle').textContent = 'Team Management';
|
||||||
|
document.getElementById('teamAdminContent').style.display = 'none';
|
||||||
|
document.getElementById('teamAdminPicker').style.display = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadTeamManageMembers(teamId) {
|
async loadTeamManageMembers(teamId) {
|
||||||
@@ -1346,6 +1346,51 @@ const UI = {
|
|||||||
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
|
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
|
||||||
closeAdmin() { closeModal('adminModal'); },
|
closeAdmin() { closeModal('adminModal'); },
|
||||||
|
|
||||||
|
openTeamAdmin() {
|
||||||
|
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
|
||||||
|
if (adminTeams.length === 0) {
|
||||||
|
UI.toast('You are not an admin of any teams', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModal('teamAdminModal');
|
||||||
|
if (adminTeams.length === 1) {
|
||||||
|
// Skip picker, go directly to the team
|
||||||
|
UI.openTeamManage(adminTeams[0].id, adminTeams[0].name);
|
||||||
|
} else {
|
||||||
|
// Show team picker
|
||||||
|
document.getElementById('teamAdminTitle').textContent = 'Team Management';
|
||||||
|
document.getElementById('teamAdminPicker').style.display = '';
|
||||||
|
document.getElementById('teamAdminContent').style.display = 'none';
|
||||||
|
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
|
||||||
|
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
|
||||||
|
<div class="team-card-info">
|
||||||
|
<strong>${esc(t.name)}</strong>
|
||||||
|
<span class="badge-admin">admin</span>
|
||||||
|
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
|
||||||
|
</div>
|
||||||
|
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
||||||
|
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeTeamAdmin() { closeModal('teamAdminModal'); },
|
||||||
|
|
||||||
|
switchTeamTab(tab) {
|
||||||
|
document.querySelectorAll('#teamAdminTabs .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.ttab === tab));
|
||||||
|
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
|
||||||
|
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
|
||||||
|
if (panel) panel.style.display = '';
|
||||||
|
|
||||||
|
// Lazy-load tab data
|
||||||
|
const teamId = UI._managingTeamId;
|
||||||
|
if (tab === 'members') UI.loadTeamManageMembers(teamId);
|
||||||
|
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
|
||||||
|
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
|
||||||
|
if (tab === 'usage') UI.loadTeamUsage();
|
||||||
|
if (tab === 'activity') UI.loadTeamAuditLog(1);
|
||||||
|
},
|
||||||
|
|
||||||
async switchAdminTab(tab) {
|
async switchAdminTab(tab) {
|
||||||
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
|
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
|
||||||
@@ -1870,6 +1915,10 @@ const UI = {
|
|||||||
// Registration (policy: allow_registration)
|
// Registration (policy: allow_registration)
|
||||||
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
|
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
|
||||||
|
|
||||||
|
// Admin system prompt (global_settings)
|
||||||
|
const sysPrompt = getSetting('system_prompt', {}) || {};
|
||||||
|
document.getElementById('adminSystemPrompt').value = sysPrompt.content || '';
|
||||||
|
|
||||||
// Registration default state (policy: default_user_active → 'true' means auto-active)
|
// Registration default state (policy: default_user_active → 'true' means auto-active)
|
||||||
const defaultActive = policies.default_user_active === 'true';
|
const defaultActive = policies.default_user_active === 'true';
|
||||||
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
|
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
|
||||||
@@ -2136,6 +2185,7 @@ function _formatMarked(text) {
|
|||||||
|
|
||||||
let toolbar = collapseBtn;
|
let toolbar = collapseBtn;
|
||||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||||
|
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${codeId}','${lang}')">Download</button>`;
|
||||||
|
|
||||||
// HTML preview button (only for HTML-like content)
|
// HTML preview button (only for HTML-like content)
|
||||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||||
@@ -2164,6 +2214,7 @@ function _formatBasic(text) {
|
|||||||
|
|
||||||
let toolbar = collapseBtn;
|
let toolbar = collapseBtn;
|
||||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||||
|
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${id}','${lang}')">Download</button>`;
|
||||||
|
|
||||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||||
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`;
|
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`;
|
||||||
@@ -2219,9 +2270,93 @@ function toggleHTMLPreview(codeId) {
|
|||||||
frame.style.display = '';
|
frame.style.display = '';
|
||||||
if (empty) empty.style.display = 'none';
|
if (empty) empty.style.display = 'none';
|
||||||
|
|
||||||
|
// Show clear button
|
||||||
|
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||||
|
if (clearBtn) clearBtn.style.display = '';
|
||||||
|
|
||||||
openSidePanel('preview');
|
openSidePanel('preview');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
const empty = document.getElementById('previewEmpty');
|
||||||
|
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||||
|
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
|
||||||
|
if (empty) empty.style.display = '';
|
||||||
|
if (clearBtn) clearBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidePanelFullscreen() {
|
||||||
|
const panel = document.getElementById('sidePanel');
|
||||||
|
panel.classList.toggle('fullscreen');
|
||||||
|
const btn = document.getElementById('sidePanelFullscreenBtn');
|
||||||
|
if (btn) {
|
||||||
|
const isFS = panel.classList.contains('fullscreen');
|
||||||
|
btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen';
|
||||||
|
btn.innerHTML = isFS
|
||||||
|
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
||||||
|
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCode(codeId, lang) {
|
||||||
|
const el = document.getElementById(codeId);
|
||||||
|
if (!el) return;
|
||||||
|
const extMap = {
|
||||||
|
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
|
||||||
|
golang: 'go', go: 'go', rust: 'rs', java: 'java', cpp: 'cpp',
|
||||||
|
c: 'c', csharp: 'cs', css: 'css', html: 'html', htm: 'html',
|
||||||
|
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', sql: 'sql',
|
||||||
|
bash: 'sh', shell: 'sh', sh: 'sh', markdown: 'md', md: 'md',
|
||||||
|
toml: 'toml', dockerfile: 'Dockerfile', makefile: 'Makefile',
|
||||||
|
perl: 'pl', lua: 'lua', swift: 'swift', kotlin: 'kt', php: 'php',
|
||||||
|
};
|
||||||
|
const ext = extMap[lang?.toLowerCase()] || lang || 'txt';
|
||||||
|
const filename = `code.${ext}`;
|
||||||
|
const blob = new Blob([el.textContent], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename;
|
||||||
|
document.body.appendChild(a); a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
UI.toast('Downloaded ' + filename, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Side Panel Resize ─────────────────────
|
||||||
|
(function initSidePanelResize() {
|
||||||
|
let startX, startW;
|
||||||
|
const panel = document.getElementById('sidePanel');
|
||||||
|
const handle = document.getElementById('sidePanelResize');
|
||||||
|
if (!handle || !panel) return;
|
||||||
|
|
||||||
|
handle.addEventListener('mousedown', (e) => {
|
||||||
|
if (panel.classList.contains('fullscreen')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
startX = e.clientX;
|
||||||
|
startW = panel.getBoundingClientRect().width;
|
||||||
|
panel.style.transition = 'none'; // disable animation during drag
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const delta = startX - e.clientX; // dragging left = wider
|
||||||
|
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta));
|
||||||
|
panel.style.width = newW + 'px';
|
||||||
|
panel.style.minWidth = newW + 'px';
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
panel.style.transition = ''; // re-enable animation
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
function openSidePanel(tab) {
|
function openSidePanel(tab) {
|
||||||
const panel = document.getElementById('sidePanel');
|
const panel = document.getElementById('sidePanel');
|
||||||
panel.classList.add('open');
|
panel.classList.add('open');
|
||||||
@@ -2229,7 +2364,9 @@ function openSidePanel(tab) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeSidePanel() {
|
function closeSidePanel() {
|
||||||
document.getElementById('sidePanel').classList.remove('open');
|
const panel = document.getElementById('sidePanel');
|
||||||
|
panel.classList.remove('open', 'fullscreen');
|
||||||
|
panel.style.width = ''; panel.style.minWidth = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchSidePanelTab(tab) {
|
function switchSidePanelTab(tab) {
|
||||||
|
|||||||
Reference in New Issue
Block a user