Bug fix: created characters token (#1813)

- Create flow stored `inference_job_token` as the `character_token`, so
mentioning
a new character sent a bogus token - backend drops it and name-matches
the
  raw `@name` against stale characters.
- Don't add pending characters to the mention store; they aren't
generatable yet.
- Keep the pending poll running after the modal closes and sync real
tokens into
  the store once the character is active.
- Remove unused `addCharacter` store action.
- Same fix in all three copies: webapp, website, shared `promptbox` lib.
This commit is contained in:
BFlat
2026-07-28 22:03:30 +07:00
committed by GitHub
parent 6ee5518c55
commit 2d7bf65a23
6 changed files with 57 additions and 46 deletions
@@ -89,11 +89,13 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
// Poll the server while any creation is pending (even with the modal
// closed), so the pending card gets cleaned up and — once the character is
// active — the mention store learns its real character token. The create
// response only carries an inference job token, so @-mentions must wait for
// the server list to include the character before it can be referenced.
useEffect(() => {
if (!isOpen || pendingCharacters.length === 0) return;
if (pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
@@ -112,14 +114,25 @@ export const CharactersModal = ({
return true;
}),
);
if (resolved) setRefreshKey((k) => k + 1);
if (resolved) {
const store = useCharactersStore.getState();
store.setCharacters(
res.data.map((c) => ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
store.setLoaded(true);
setRefreshKey((k) => k + 1);
}
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isOpen, pendingCharacters.length]);
}, [pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
@@ -610,7 +623,6 @@ const NewCharacterView = ({
onBack: () => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<UploadedImage[]>([]);
@@ -753,11 +765,6 @@ const NewCharacterView = ({
if (res.success && res.data) {
toast.success(`Character "${name.trim()}" is being created`);
addCharacterToStore({
character_token: res.data.inference_job_token,
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
onCreated({
name: name.trim(),
previewUrl: uploadedImages[0]!.url,
@@ -10,7 +10,6 @@ interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
addCharacter: (character: StoredCharacter) => void;
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
removeCharacter: (token: string) => void;
setLoaded: (loaded: boolean) => void;
@@ -20,8 +19,6 @@ export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
addCharacter: (character) =>
set((state) => ({ characters: [...state.characters, character] })),
updateCharacter: (token, updates) =>
set((state) => ({
characters: state.characters.map((c) =>
@@ -89,11 +89,13 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
// Poll the server while any creation is pending (even with the modal
// closed), so the pending card gets cleaned up and — once the character is
// active — the mention store learns its real character token. The create
// response only carries an inference job token, so @-mentions must wait for
// the server list to include the character before it can be referenced.
useEffect(() => {
if (!isOpen || pendingCharacters.length === 0) return;
if (pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
@@ -112,14 +114,25 @@ export const CharactersModal = ({
return true;
}),
);
if (resolved) setRefreshKey((k) => k + 1);
if (resolved) {
const store = useCharactersStore.getState();
store.setCharacters(
res.data.map((c) => ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
store.setLoaded(true);
setRefreshKey((k) => k + 1);
}
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isOpen, pendingCharacters.length]);
}, [pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
@@ -610,7 +623,6 @@ const NewCharacterView = ({
onBack: () => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<UploadedImage[]>([]);
@@ -753,11 +765,6 @@ const NewCharacterView = ({
if (res.success && res.data) {
toast.success(`Character "${name.trim()}" is being created`);
addCharacterToStore({
character_token: res.data.inference_job_token,
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
onCreated({
name: name.trim(),
previewUrl: uploadedImages[0]!.url,
@@ -10,7 +10,6 @@ interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
addCharacter: (character: StoredCharacter) => void;
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
removeCharacter: (token: string) => void;
setLoaded: (loaded: boolean) => void;
@@ -20,8 +19,6 @@ export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
addCharacter: (character) =>
set((state) => ({ characters: [...state.characters, character] })),
updateCharacter: (token, updates) =>
set((state) => ({
characters: state.characters.map((c) =>
@@ -99,11 +99,13 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
// Poll the server while any creation is pending (even with the modal
// closed), so the pending card gets cleaned up and — once the character is
// active — the mention store learns its real character token. The create
// response only carries an inference job token, so @-mentions must wait for
// the server list to include the character before it can be referenced.
useEffect(() => {
if (!isOpen || pendingCharacters.length === 0) return;
if (pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
@@ -122,14 +124,25 @@ export const CharactersModal = ({
return true;
}),
);
if (resolved) setRefreshKey((k) => k + 1);
if (resolved) {
const store = useCharactersStore.getState();
store.setCharacters(
res.data.map((c) => ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
store.setLoaded(true);
setRefreshKey((k) => k + 1);
}
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isOpen, pendingCharacters.length]);
}, [pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
@@ -604,7 +617,6 @@ const NewCharacterView = ({
onBack: () => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<UploadedImage[]>([]);
@@ -754,12 +766,6 @@ const NewCharacterView = ({
if (res.success && res.data) {
toast.success(`Character "${name.trim()}" is being created`);
// Add to global store so it appears in @-mentions immediately
addCharacterToStore({
character_token: res.data.inference_job_token,
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
// Pass pending info up so the list shows an optimistic card
onCreated({
name: name.trim(),
@@ -346,7 +346,6 @@ interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
addCharacter: (character: StoredCharacter) => void;
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
removeCharacter: (token: string) => void;
setLoaded: (loaded: boolean) => void;
@@ -356,8 +355,6 @@ export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
addCharacter: (character) =>
set((state) => ({ characters: [...state.characters, character] })),
updateCharacter: (token, updates) =>
set((state) => ({
characters: state.characters.map((c) =>