mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-11 16:36:32 +02:00
## Summary Adds per-user `profile_description` so coding agents have cheap, durable context about who is asking. v1 per the brief Xeon locked in on [MUL-2406](mention://issue/63a7247c-4f6a-42cf-90d1-7c746e77158a): - **DB** — `user.profile_description TEXT NOT NULL DEFAULT ''` (migration 096). 2000-rune cap enforced server-side. No nullable / privacy state to manage. - **API** — `PATCH /api/me` accepts the field; `UserResponse` always emits it. Client wraps `updateMe` in a lenient `UserSchema` + `EMPTY_USER` fallback per CLAUDE.md API Response Compatibility. - **UI** — Settings → Account gains an "About you" textarea with live `n/2000` counter, `maxLength` guard, and a localized too-long error (EN + zh-Hans). - **CLI** — `multica user profile get` / `multica user profile update` with `--description / --description-stdin / --description-file / --clear`, mirroring the existing `issue comment add` input-mode menu. - **Daemon injection** — claim handler resolves the runtime owner and stamps `requesting_user_name` + `requesting_user_profile_description` on the task. `buildMetaSkillContent` emits `## Requesting User` between `## Agent Identity` and `## Available Commands`, blockquoted and framed as background context. The block is omitted entirely when the description is empty (no token cost when unused). Brief is written **once per task** via `CLAUDE.md` / `AGENTS.md`, not the per-turn prompt — same path the agent already reads for identity, so no extra per-turn cost. ## Test plan - [x] `go build ./...`, `go vet ./...`, `go test ./internal/cli/ ./internal/daemon/ ./internal/daemon/execenv/ ./cmd/multica/` - [x] New brief tests: `TestBuildMetaSkillContentEmitsRequestingUser`, `TestBuildMetaSkillContentOmitsRequestingUserWhenEmpty` - [x] `pnpm typecheck`, `pnpm lint`, `pnpm test` (74 files, 644 tests pass) - [ ] Handler DB tests (`TestUpdateMe*`) require a migrated test DB — not runnable in this sandbox - [ ] Manual: open Settings → Account, set a description, confirm the next daemon-run agent's `CLAUDE.md` shows `## Requesting User`
183 lines
6.9 KiB
TypeScript
183 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Camera, Loader2, Save } from "lucide-react";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import { Label } from "@multica/ui/components/ui/label";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
|
import { Textarea } from "@multica/ui/components/ui/textarea";
|
|
import { toast } from "sonner";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { api } from "@multica/core/api";
|
|
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
|
import { useT } from "../../i18n";
|
|
|
|
// Mirror server/internal/handler/auth.go:MaxProfileDescriptionLen. Counted in
|
|
// JS String.length (UTF-16 code units) here while the server counts runes,
|
|
// so a profile full of supplementary-plane emoji will trip the client cap
|
|
// before the server's — which is the safer direction of drift.
|
|
const MAX_PROFILE_DESCRIPTION_LEN = 2000;
|
|
|
|
export function AccountTab() {
|
|
const { t } = useT("settings");
|
|
const user = useAuthStore((s) => s.user);
|
|
const setUser = useAuthStore((s) => s.setUser);
|
|
|
|
const [profileName, setProfileName] = useState(user?.name ?? "");
|
|
const [profileDescription, setProfileDescription] = useState(
|
|
user?.profile_description ?? "",
|
|
);
|
|
const [profileSaving, setProfileSaving] = useState(false);
|
|
const { upload, uploading } = useFileUpload(api);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
setProfileName(user?.name ?? "");
|
|
setProfileDescription(user?.profile_description ?? "");
|
|
}, [user]);
|
|
|
|
const descriptionTooLong = profileDescription.length > MAX_PROFILE_DESCRIPTION_LEN;
|
|
|
|
const initials = (user?.name ?? "")
|
|
.split(" ")
|
|
.map((w) => w[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
|
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
// Reset input so the same file can be re-selected
|
|
e.target.value = "";
|
|
try {
|
|
const result = await upload(file);
|
|
if (!result) return;
|
|
const updated = await api.updateMe({ avatar_url: result.link });
|
|
setUser(updated);
|
|
toast.success(t(($) => $.account.toast_avatar_updated));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t(($) => $.account.toast_avatar_failed));
|
|
}
|
|
};
|
|
|
|
const handleProfileSave = async () => {
|
|
if (descriptionTooLong) return;
|
|
setProfileSaving(true);
|
|
try {
|
|
const updated = await api.updateMe({
|
|
name: profileName,
|
|
profile_description: profileDescription,
|
|
});
|
|
setUser(updated);
|
|
toast.success(t(($) => $.account.toast_profile_updated));
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t(($) => $.account.toast_profile_failed));
|
|
} finally {
|
|
setProfileSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<section className="space-y-4">
|
|
<h2 className="text-sm font-semibold">{t(($) => $.account.section_profile)}</h2>
|
|
|
|
<Card>
|
|
<CardContent className="space-y-4">
|
|
{/* Avatar upload */}
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
type="button"
|
|
className="group relative h-16 w-16 shrink-0 rounded-full bg-muted overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
{user?.avatar_url ? (
|
|
<img
|
|
src={user.avatar_url}
|
|
alt={user.name}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<span className="flex h-full w-full items-center justify-center text-lg font-semibold text-muted-foreground">
|
|
{initials}
|
|
</span>
|
|
)}
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
|
{uploading ? (
|
|
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
|
) : (
|
|
<Camera className="h-5 w-5 text-white" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleAvatarUpload}
|
|
/>
|
|
<div className="text-xs text-muted-foreground">
|
|
{t(($) => $.account.click_avatar_hint)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">{t(($) => $.account.name_label)}</Label>
|
|
<Input
|
|
type="search"
|
|
value={profileName}
|
|
onChange={(e) => setProfileName(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">
|
|
{t(($) => $.account.profile_description_label)}
|
|
</Label>
|
|
<Textarea
|
|
value={profileDescription}
|
|
onChange={(e) => setProfileDescription(e.target.value)}
|
|
placeholder={t(($) => $.account.profile_description_placeholder)}
|
|
rows={5}
|
|
maxLength={MAX_PROFILE_DESCRIPTION_LEN}
|
|
className="mt-1 resize-y"
|
|
/>
|
|
<div className="mt-1 flex items-start justify-between gap-3 text-xs text-muted-foreground">
|
|
<span>{t(($) => $.account.profile_description_hint)}</span>
|
|
<span
|
|
className={descriptionTooLong ? "text-destructive shrink-0" : "shrink-0"}
|
|
aria-live="polite"
|
|
>
|
|
{profileDescription.length}/{MAX_PROFILE_DESCRIPTION_LEN}
|
|
</span>
|
|
</div>
|
|
{descriptionTooLong ? (
|
|
<p className="mt-1 text-xs text-destructive">
|
|
{t(($) => $.account.profile_description_too_long, {
|
|
max: MAX_PROFILE_DESCRIPTION_LEN,
|
|
count: profileDescription.length,
|
|
})}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleProfileSave}
|
|
disabled={profileSaving || !profileName.trim() || descriptionTooLong}
|
|
>
|
|
<Save className="h-3 w-3" />
|
|
{profileSaving ? t(($) => $.account.saving) : t(($) => $.account.save)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|