feat: Avatar Position-Offset (translate X/Y)

Backend:
- AgentRecord + SessionRecord: AvatarOffsetX, AvatarOffsetY Properties
- DB-Migration: avatar_offset_x/y REAL DEFAULT 0 auf agents + sessions
- Agent/Session Endpoints: Create + Update lesen/schreiben Offset-Felder
- GetSessionsWithStats: Offset-Felder ans Frontend

Frontend:
- Avatar-Editor: X/Y Offset-Slider (-50% bis +50%) im Modal
  - buildTransformStyle() Helper für transform: scale() translate()
  - getOffsetX()/getOffsetY() Public API
  - Reset bei Clear/AI-Generate/Upload
- ui.js: buildAgentCard + buildSessionCard rendern transform mit Offset
- Agent-Page + Session-Page: initialOffsetX/Y + Save
- CSS: .av-offset-row Styles (gruppiert mit .av-scale-row)
This commit is contained in:
Meleki committed 2026-03-06 21:14:51 +01:00
1 parent 95b3683910
commit 4775b8ded7
10 files changed
+143 -30

No files matched your search

+5 -1
View File
@@ -65,7 +65,9 @@ public static class AgentEndpoint
UseMemory = !body.TryGetProperty("useMemory", out var um) || um.GetBoolean(),
UseWebSearch = body.TryGetProperty("webSearchVersion", out var wsv) && !string.IsNullOrEmpty(wsv.GetString()),
WebSearchVersion = body.TryGetProperty("webSearchVersion", out var wsv2) ? wsv2.GetString() ?? "" : "",
AvatarScale = body.TryGetProperty("avatarScale", out var asc) ? asc.GetDouble() : 1.0
AvatarScale = body.TryGetProperty("avatarScale", out var asc) ? asc.GetDouble() : 1.0,
AvatarOffsetX = body.TryGetProperty("avatarOffsetX", out var aox) ? aox.GetDouble() : 0,
AvatarOffsetY = body.TryGetProperty("avatarOffsetY", out var aoy) ? aoy.GetDouble() : 0
};
agent = await db.CreateAgent(agent);
@@ -102,6 +104,8 @@ public static class AgentEndpoint
agent.UseWebSearch = !string.IsNullOrEmpty(agent.WebSearchVersion);
}
if (body.TryGetProperty("avatarScale", out var asc)) agent.AvatarScale = asc.GetDouble();
if (body.TryGetProperty("avatarOffsetX", out var aox)) agent.AvatarOffsetX = aox.GetDouble();
if (body.TryGetProperty("avatarOffsetY", out var aoy)) agent.AvatarOffsetY = aoy.GetDouble();
await db.UpdateAgent(agent);
memoryService.InitializeAgentFiles(agentId, agent.Name);
+3 -1
View File
@@ -77,10 +77,12 @@ public static class SessionEndpoint
var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null;
var customCommands = body.TryGetProperty("customCommands", out var cc) && cc.ValueKind != JsonValueKind.Null ? cc.GetRawText() : null;
var avatarScale = body.TryGetProperty("avatarScale", out var asc) ? asc.GetDouble() : 1.0;
var avatarOffsetX = body.TryGetProperty("avatarOffsetX", out var aox) ? aox.GetDouble() : 0;
var avatarOffsetY = body.TryGetProperty("avatarOffsetY", out var aoy) ? aoy.GetDouble() : 0;
var reviewAgentId = body.TryGetProperty("reviewAgentId", out var ra) ? ra.GetString() : null;
var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3;
var session = await db.CreateSession(userId, name, description, image, agentId, workspace, context, customCommands, avatarScale, reviewAgentId, maxReviewIterations);
var session = await db.CreateSession(userId, name, description, image, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations);
await ctx.Response.WriteAsJsonAsync(session);
}
+2
View File
@@ -18,6 +18,8 @@ public class AgentRecord
public bool UseWebSearch { get; set; }
public string WebSearchVersion { get; set; } = "";
public double AvatarScale { get; set; } = 1.0;
public double AvatarOffsetX { get; set; }
public double AvatarOffsetY { get; set; }
public string UserId { get; set; }
public bool IsDeleted { get; set; }
public string CreatedAt { get; set; }
+2
View File
@@ -18,6 +18,8 @@ public class SessionRecord
public string TelegramChatId { get; set; }
public string TelegramAllowedChatIds { get; set; }
public double AvatarScale { get; set; } = 1.0;
public double AvatarOffsetX { get; set; }
public double AvatarOffsetY { get; set; }
public string ReviewAgentId { get; set; }
public int MaxReviewIterations { get; set; } = 3;
public string CreatedAt { get; set; }
+16 -6
View File
@@ -173,6 +173,10 @@ public class DatabaseService
TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT");
TryAddColumn(conn, "sessions", "review_agent_id", "TEXT");
TryAddColumn(conn, "sessions", "max_review_iterations", "INTEGER NOT NULL DEFAULT 3");
TryAddColumn(conn, "sessions", "avatar_offset_x", "REAL NOT NULL DEFAULT 0");
TryAddColumn(conn, "sessions", "avatar_offset_y", "REAL NOT NULL DEFAULT 0");
TryAddColumn(conn, "agents", "avatar_offset_x", "REAL NOT NULL DEFAULT 0");
TryAddColumn(conn, "agents", "avatar_offset_y", "REAL NOT NULL DEFAULT 0");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_skills (
@@ -325,8 +329,8 @@ public class DatabaseService
agent.CreatedAt = now;
agent.UpdatedAt = now;
await conn.ExecuteAsync(@"
INSERT INTO agents (agent_id, user_id, name, avatar, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_web_search, web_search_version, avatar_scale, is_deleted, created_at, updated_at)
VALUES (@AgentId, @UserId, @Name, @Avatar, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseWebSearch, @WebSearchVersion, @AvatarScale, 0, @CreatedAt, @UpdatedAt)
INSERT INTO agents (agent_id, user_id, name, avatar, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_web_search, web_search_version, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at)
VALUES (@AgentId, @UserId, @Name, @Avatar, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseWebSearch, @WebSearchVersion, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt)
", agent);
return agent;
}
@@ -354,7 +358,7 @@ public class DatabaseService
model = @Model, tool_choice = @ToolChoice,
custom_commands = @CustomCommands, auto_commit = @AutoCommit,
use_soul = @UseSoul, use_identity = @UseIdentity, use_user_info = @UseUserInfo, use_memory = @UseMemory, use_web_search = @UseWebSearch, web_search_version = @WebSearchVersion,
avatar_scale = @AvatarScale, updated_at = @UpdatedAt
avatar_scale = @AvatarScale, avatar_offset_x = @AvatarOffsetX, avatar_offset_y = @AvatarOffsetY, updated_at = @UpdatedAt
WHERE agent_id = @AgentId AND user_id = @UserId
", agent);
}
@@ -369,7 +373,7 @@ public class DatabaseService
// ── Sessions ──
public async Task<SessionRecord> CreateSession(string userId, string name = null, string description = null, string image = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, string reviewAgentId = null, int maxReviewIterations = 3)
public async Task<SessionRecord> CreateSession(string userId, string name = null, string description = null, string image = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3)
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
@@ -385,14 +389,16 @@ public class DatabaseService
Context = context,
CustomCommands = customCommands,
AvatarScale = avatarScale,
AvatarOffsetX = avatarOffsetX,
AvatarOffsetY = avatarOffsetY,
ReviewAgentId = reviewAgentId,
MaxReviewIterations = maxReviewIterations,
CreatedAt = now,
UpdatedAt = now
};
await conn.ExecuteAsync(@"
INSERT INTO sessions (session_id, user_id, name, description, image, agent_id, workspace, context, custom_commands, avatar_scale, review_agent_id, max_review_iterations, created_at, updated_at)
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @ReviewAgentId, @MaxReviewIterations, @CreatedAt, @UpdatedAt)
INSERT INTO sessions (session_id, user_id, name, description, image, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, created_at, updated_at)
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @CreatedAt, @UpdatedAt)
", session);
return session;
}
@@ -436,6 +442,8 @@ public class DatabaseService
s.Context,
s.CustomCommands,
s.AvatarScale,
s.AvatarOffsetX,
s.AvatarOffsetY,
s.ReviewAgentId,
s.MaxReviewIterations,
s.IsDeleted,
@@ -488,6 +496,8 @@ public class DatabaseService
if (body.TryGetProperty("context", out var ctx)) { sets.Add("context = @context"); p.Add("context", ctx.GetString()); }
if (body.TryGetProperty("customCommands", out var cc)) { sets.Add("custom_commands = @customCommands"); p.Add("customCommands", cc.ValueKind == System.Text.Json.JsonValueKind.Null ? null : cc.GetRawText()); }
if (body.TryGetProperty("avatarScale", out var asc)) { sets.Add("avatar_scale = @avatarScale"); p.Add("avatarScale", asc.GetDouble()); }
if (body.TryGetProperty("avatarOffsetX", out var aox)) { sets.Add("avatar_offset_x = @avatarOffsetX"); p.Add("avatarOffsetX", aox.GetDouble()); }
if (body.TryGetProperty("avatarOffsetY", out var aoy)) { sets.Add("avatar_offset_y = @avatarOffsetY"); p.Add("avatarOffsetY", aoy.GetDouble()); }
if (body.TryGetProperty("reviewAgentId", out var ra)) { sets.Add("review_agent_id = @reviewAgentId"); p.Add("reviewAgentId", ra.ValueKind == System.Text.Json.JsonValueKind.Null ? null : ra.GetString()); }
if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); }
+9 -4
View File
@@ -107,23 +107,28 @@
gap: var(--badge-spacing);
}
.av-scale-row {
.av-scale-row,
.av-offset-row {
display: flex;
align-items: center;
gap: var(--badge-spacing);
}
.av-scale-row label {
.av-scale-row label,
.av-offset-row label {
margin: 0;
white-space: nowrap;
min-width: 2.5em;
}
.av-scale-row input[type="range"] {
.av-scale-row input[type="range"],
.av-offset-row input[type="range"] {
flex: 1;
accent-color: var(--primary);
}
.av-scale-row .av-scale-value {
.av-scale-row .av-scale-value,
.av-offset-row span[class*="-value"] {
font-size: 0.85em;
min-width: 2.5em;
text-align: right;
+85 -11
View File
@@ -6,13 +6,17 @@
// previewEl: document.getElementById('myPreview'),
// initialImage: 'data:image/...' || null,
// initialScale: 1.5,
// onChange(imageData, scale) { ... }
// initialOffsetX: 0,
// initialOffsetY: 0,
// onChange(imageData, scale, offsetX, offsetY) { ... }
// });
// // Cleanup:
// editor.destroy();
// // Get current values:
// editor.getImage(); // data-url or null
// editor.getScale(); // number
// editor.getOffsetX(); // number (-50 to 50)
// editor.getOffsetY(); // number (-50 to 50)
var AvatarEditor = (function () {
@@ -20,6 +24,8 @@ var AvatarEditor = (function () {
var previewEl = opts.previewEl;
var imageData = opts.initialImage || null;
var scale = opts.initialScale || 1;
var offsetX = opts.initialOffsetX || 0;
var offsetY = opts.initialOffsetY || 0;
var onChange = opts.onChange || function () {};
var overlay = null;
var pasteHandler = null;
@@ -28,10 +34,17 @@ var AvatarEditor = (function () {
// Krea-Status prüfen
apiKreaStatus().then(function (s) { kreaAvailable = s.configured; }).catch(function () {});
function buildTransformStyle() {
var parts = [];
if (scale !== 1) parts.push('scale(' + scale + ')');
if (offsetX !== 0 || offsetY !== 0) parts.push('translate(' + offsetX + '%, ' + offsetY + '%)');
return parts.length ? ' style="transform: ' + parts.join(' ') + '"' : '';
}
// Preview rendern
function renderPreview() {
if (imageData) {
previewEl.innerHTML = '<img src="' + imageData + '" alt="" style="scale: ' + scale + '">';
previewEl.innerHTML = '<img src="' + imageData + '" alt=""' + buildTransformStyle() + '>';
previewEl.classList.add('has-image');
} else {
previewEl.innerHTML = '?';
@@ -65,7 +78,7 @@ var AvatarEditor = (function () {
reader.onload = function () {
imageData = reader.result;
renderPreview();
onChange(imageData, scale);
onChange(imageData, scale, offsetX, offsetY);
if (overlay) refreshModal();
};
reader.readAsDataURL(file);
@@ -74,8 +87,10 @@ var AvatarEditor = (function () {
function setImage(data) {
imageData = data;
scale = 1;
offsetX = 0;
offsetY = 0;
renderPreview();
onChange(imageData, scale);
onChange(imageData, scale, offsetX, offsetY);
}
function openModal() {
@@ -91,7 +106,7 @@ var AvatarEditor = (function () {
'</div>' +
'<div class="av-body">' +
'<div class="av-preview-large">' +
(imageData ? '<img src="' + imageData + '" alt="" style="scale: ' + scale + '">' : '?') +
(imageData ? '<img src="' + imageData + '" alt=""' + buildTransformStyle() + '>' : '?') +
'</div>' +
'<div class="av-controls">' +
'<div class="av-scale-row"' + (imageData ? '' : ' hidden') + '>' +
@@ -99,6 +114,16 @@ var AvatarEditor = (function () {
'<input type="range" class="av-scale" min="0.5" max="3" step="0.1" value="' + scale + '">' +
'<span class="av-scale-value">' + scale + '×</span>' +
'</div>' +
'<div class="av-offset-row"' + (imageData ? '' : ' hidden') + '>' +
'<label>X</label>' +
'<input type="range" class="av-offset-x" min="-50" max="50" step="1" value="' + offsetX + '">' +
'<span class="av-offset-x-value">' + offsetX + '%</span>' +
'</div>' +
'<div class="av-offset-row"' + (imageData ? '' : ' hidden') + '>' +
'<label>Y</label>' +
'<input type="range" class="av-offset-y" min="-50" max="50" step="1" value="' + offsetY + '">' +
'<span class="av-offset-y-value">' + offsetY + '%</span>' +
'</div>' +
'<div class="av-actions">' +
'<button class="av-upload-btn"><i class="fa fa-upload"></i> Hochladen</button>' +
'<input type="file" class="av-file-input" accept="image/*" hidden>' +
@@ -123,6 +148,11 @@ var AvatarEditor = (function () {
var scaleRow = overlay.querySelector('.av-scale-row');
var scaleInput = overlay.querySelector('.av-scale');
var scaleValueEl = overlay.querySelector('.av-scale-value');
var offsetRows = overlay.querySelectorAll('.av-offset-row');
var offsetXInput = overlay.querySelector('.av-offset-x');
var offsetXValueEl = overlay.querySelector('.av-offset-x-value');
var offsetYInput = overlay.querySelector('.av-offset-y');
var offsetYValueEl = overlay.querySelector('.av-offset-y-value');
var uploadBtn = overlay.querySelector('.av-upload-btn');
var fileInput = overlay.querySelector('.av-file-input');
var clearBtn = overlay.querySelector('.av-clear-btn');
@@ -135,14 +165,41 @@ var AvatarEditor = (function () {
if (e.target === overlay) closeModal();
});
function updatePreviewLargeTransform() {
var img = previewLarge.querySelector('img');
if (img) {
var parts = [];
if (scale !== 1) parts.push('scale(' + scale + ')');
if (offsetX !== 0 || offsetY !== 0) parts.push('translate(' + offsetX + '%, ' + offsetY + '%)');
img.style.transform = parts.length ? parts.join(' ') : '';
}
}
// Scale
scaleInput.addEventListener('input', function () {
scale = parseFloat(scaleInput.value);
scaleValueEl.textContent = scale + '×';
var img = previewLarge.querySelector('img');
if (img) img.style.scale = scale;
updatePreviewLargeTransform();
renderPreview();
onChange(imageData, scale);
onChange(imageData, scale, offsetX, offsetY);
});
// Offset X
offsetXInput.addEventListener('input', function () {
offsetX = parseFloat(offsetXInput.value);
offsetXValueEl.textContent = offsetX + '%';
updatePreviewLargeTransform();
renderPreview();
onChange(imageData, scale, offsetX, offsetY);
});
// Offset Y
offsetYInput.addEventListener('input', function () {
offsetY = parseFloat(offsetYInput.value);
offsetYValueEl.textContent = offsetY + '%';
updatePreviewLargeTransform();
renderPreview();
onChange(imageData, scale, offsetX, offsetY);
});
// Upload
@@ -155,10 +212,16 @@ var AvatarEditor = (function () {
clearBtn.addEventListener('click', function () {
imageData = null;
scale = 1;
offsetX = 0;
offsetY = 0;
scaleInput.value = 1;
scaleValueEl.textContent = '1×';
offsetXInput.value = 0;
offsetXValueEl.textContent = '0%';
offsetYInput.value = 0;
offsetYValueEl.textContent = '0%';
renderPreview();
onChange(imageData, scale);
onChange(imageData, scale, offsetX, offsetY);
refreshModal();
});
@@ -182,10 +245,16 @@ var AvatarEditor = (function () {
reader.onload = function () {
imageData = reader.result;
scale = 1;
offsetX = 0;
offsetY = 0;
scaleInput.value = 1;
scaleValueEl.textContent = '1×';
offsetXInput.value = 0;
offsetXValueEl.textContent = '0%';
offsetYInput.value = 0;
offsetYValueEl.textContent = '0%';
renderPreview();
onChange(imageData, scale);
onChange(imageData, scale, offsetX, offsetY);
refreshModal();
};
reader.readAsDataURL(blob);
@@ -222,14 +291,17 @@ var AvatarEditor = (function () {
if (!overlay) return;
var previewLarge = overlay.querySelector('.av-preview-large');
var scaleRow = overlay.querySelector('.av-scale-row');
var offsetRows = overlay.querySelectorAll('.av-offset-row');
var clearBtn = overlay.querySelector('.av-clear-btn');
if (imageData) {
previewLarge.innerHTML = '<img src="' + imageData + '" alt="" style="scale: ' + scale + '">';
previewLarge.innerHTML = '<img src="' + imageData + '" alt=""' + buildTransformStyle() + '>';
scaleRow.hidden = false;
offsetRows.forEach(function (r) { r.hidden = false; });
clearBtn.hidden = false;
} else {
previewLarge.innerHTML = '?';
scaleRow.hidden = true;
offsetRows.forEach(function (r) { r.hidden = true; });
clearBtn.hidden = true;
}
}
@@ -245,6 +317,8 @@ var AvatarEditor = (function () {
return {
getImage: function () { return imageData; },
getScale: function () { return scale; },
getOffsetX: function () { return offsetX; },
getOffsetY: function () { return offsetY; },
destroy: function () {
closeModal();
if (pasteHandler) {
+5 -1
View File
@@ -38,7 +38,9 @@ registerPage('agent', {
_agentAvatarEditor = AvatarEditor.init({
previewEl: avatarPreview,
initialImage: (agent && agent.avatar) || null,
initialScale: (agent && agent.avatarScale) || 1
initialScale: (agent && agent.avatarScale) || 1,
initialOffsetX: (agent && agent.avatarOffsetX) || 0,
initialOffsetY: (agent && agent.avatarOffsetY) || 0
});
if (agent) {
@@ -100,6 +102,8 @@ registerPage('agent', {
webSearchVersion: webSearchVersionEl.value,
avatar: _agentAvatarEditor.getImage(),
avatarScale: _agentAvatarEditor.getScale(),
avatarOffsetX: _agentAvatarEditor.getOffsetX(),
avatarOffsetY: _agentAvatarEditor.getOffsetY(),
customCommands: getCommandsFromUI(commandsList)
};
+6 -2
View File
@@ -37,7 +37,9 @@ registerPage('session', {
_sessionAvatarEditor = AvatarEditor.init({
previewEl: imagePreview,
initialImage: (session && session.image) || null,
initialScale: (session && session.avatarScale) || 1
initialScale: (session && session.avatarScale) || 1,
initialOffsetX: (session && session.avatarOffsetX) || 0,
initialOffsetY: (session && session.avatarOffsetY) || 0
});
if (session) {
@@ -190,7 +192,9 @@ registerPage('session', {
context: _sessionContextMde.value().trim() || null,
workspace: workspaceEl.value.trim() || null,
customCommands: commands,
avatarScale: _sessionAvatarEditor.getScale()
avatarScale: _sessionAvatarEditor.getScale(),
avatarOffsetX: _sessionAvatarEditor.getOffsetX(),
avatarOffsetY: _sessionAvatarEditor.getOffsetY()
};
if (isNew) {
+10 -4
View File
@@ -7,8 +7,11 @@ function buildSessionCard(session, agent) {
const card = document.createElement('div');
card.className = 'card';
const scaleStyle = session.avatarScale && session.avatarScale !== 1 ? ` style="scale: ${session.avatarScale}"` : '';
const imageHtml = session.image ? `<img src="${session.image}" alt=""${scaleStyle}>` : '';
const transformParts = [];
if (session.avatarScale && session.avatarScale !== 1) transformParts.push(`scale(${session.avatarScale})`);
if (session.avatarOffsetX || session.avatarOffsetY) transformParts.push(`translate(${session.avatarOffsetX || 0}%, ${session.avatarOffsetY || 0}%)`);
const transformStyle = transformParts.length ? ` style="transform: ${transformParts.join(' ')}"` : '';
const imageHtml = session.image ? `<img src="${session.image}" alt=""${transformStyle}>` : '';
// Badges
const badges = [];
@@ -47,8 +50,11 @@ function buildAgentCard(agent, skills) {
const card = document.createElement('div');
card.className = 'card';
const scaleStyle = agent.avatarScale && agent.avatarScale !== 1 ? ` style="transform: scale(${agent.avatarScale})"` : '';
const imageHtml = agent.avatar ? `<img src="${agent.avatar}" alt=""${scaleStyle}>` : '';
const agentTransformParts = [];
if (agent.avatarScale && agent.avatarScale !== 1) agentTransformParts.push(`scale(${agent.avatarScale})`);
if (agent.avatarOffsetX || agent.avatarOffsetY) agentTransformParts.push(`translate(${agent.avatarOffsetX || 0}%, ${agent.avatarOffsetY || 0}%)`);
const agentTransformStyle = agentTransformParts.length ? ` style="transform: ${agentTransformParts.join(' ')}"` : '';
const imageHtml = agent.avatar ? `<img src="${agent.avatar}" alt=""${agentTransformStyle}>` : '';
// Badges
const badges = [];