lamplight / src /server /Main.server.luau
maddiedreese's picture
Publish SmolLM2 Luau port and Roblox experience
a44492d verified
Raw History Blame Contribute Delete
14.7 kB
local Players=game:GetService('Players')
local RunService=game:GetService('RunService')
local RS=game:GetService('ReplicatedStorage')
local SS=game:GetService('ServerStorage')
local TextService=game:GetService('TextService')
local TextChatService=game:GetService('TextChatService')
local Safety=require(script.Parent.ChatSafety).new(TextService,TextChatService)
local Config=require(RS.Shared.Config)
local Tokenizer=require(script.Parent.Tokenizer)
local Engine=require(script.Parent.Inference)
local remote=Instance.new('RemoteEvent');remote.Name='Club';remote.Parent=RS
local terminals=workspace:WaitForChild('Lab'):WaitForChild('Terminals')
local rooms={};local ready=false;local loading=0;local tokenizer
local jobs={};local waiting={};local rate={}
local inputChannel=Instance.new('TextChannel');inputChannel.Name='LamplightInput';inputChannel.Parent=TextChatService
local displayChannel=Instance.new('TextChannel');displayChannel.Name='LamplightDisplay';displayChannel.Parent=TextChatService
local function fire(player,kind,data) if player and player.Parent then remote:FireClient(player,kind,data) end end
local function status(room,text)
room.status=text;room.model:SetAttribute('Status',text);room.model:SetAttribute('Busy',room.busy)
if room.owner then fire(room.owner,'status',{status=text,terminal=room.id}) end
end
local function sendView(room,recipient)
local revision=room.viewRevision;local owner=room.owner;local history=table.clone(room.displayHistory)
task.spawn(function()
local allowed=owner and Safety:canView(owner.UserId,recipient.UserId)
if room.viewRevision~=revision or room.owner~=owner then return end
fire(recipient,'view',{terminal=room.id,revision=revision,history=if allowed then history else {}})
end)
end
local function broadcast(room)
room.viewRevision=(room.viewRevision or 0)+1
room.model:SetAttribute('Transcript',nil)
room.model:SetAttribute('RequestVersion',room.version)
room.model:SetAttribute('Busy',room.busy)
room.model:SetAttribute('Owner',if room.owner then room.owner.DisplayName else '')
room.model:SetAttribute('OwnerId',if room.owner then room.owner.UserId else 0)
room.model:SetAttribute('Status',room.status)
local pp=room.model.Interact.ProximityPrompt
pp.ActionText=if room.owner then 'In use' else 'Sit at terminal'
for _,recipient in Players:GetPlayers() do sendView(room,recipient) end
end
local function near(player,room,distance)
local root=player.Character and player.Character:FindFirstChild('HumanoidRootPart')
return root and (root.Position-room.model.Interact.Position).Magnitude<=distance
end
local function cancel(room)
room.version+=1;room.busy=false
end
local function release(room)
if room.releasing then return end
room.releasing=true
local owner=room.owner
local character=owner and owner.Character
local humanoid=character and character:FindFirstChildOfClass('Humanoid')
local wasSeated=humanoid and humanoid.SeatPart==room.seat
local exitCFrame=if wasSeated then room.seatHome*CFrame.new(0,2,5) else nil
cancel(room);room.owner=nil;room.history={};room.displayHistory={};room.publicPrompt=nil;room.publicAnswer=nil
-- Remove the seat-to-avatar weld before moving the avatar, preserving the chair assembly.
local weld=room.seat:FindFirstChild('SeatWeld');if weld then weld:Destroy() end
room.seat.Disabled=true
if wasSeated then
humanoid.Sit=false
humanoid:ChangeState(Enum.HumanoidStateType.GettingUp)
task.spawn(function()
-- Let the seat detach before moving the avatar; never teleport the chair's assembly.
RunService.Heartbeat:Wait();RunService.Heartbeat:Wait()
local root=character:FindFirstChild('HumanoidRootPart')
if root and not humanoid.SeatPart and not room.seat:FindFirstChild('SeatWeld') then
root.CFrame=exitCFrame
root.AssemblyLinearVelocity=Vector3.zero;root.AssemblyAngularVelocity=Vector3.zero
end
end)
end
status(room,if ready then 'READY' else 'WARMING UP');broadcast(room);fire(owner,'released',{terminal=room.id})
task.delay(1,function() room.releasing=false;room.seat.Disabled=false end)
end
local function claim(player,room)
if room.releasing then return end
if not near(player,room,Config.ClaimDistance) then return end
local humanoid=player.Character and player.Character:FindFirstChildOfClass('Humanoid')
if not humanoid or humanoid.Health<=0 then return end
if room.seat.Occupant and room.seat.Occupant~=humanoid then return end
if humanoid.SeatPart and humanoid.SeatPart~=room.seat then return end
if room.owner and room.owner~=player then
fire(player,'notice',{text='This terminal is in use by '..room.owner.DisplayName..'. You can read its public monitor.'});return
end
for _,other in rooms do if other~=room and other.owner==player then release(other) end end
room.owner=player;room.lastActive=os.clock();broadcast(room)
if humanoid.SeatPart~=room.seat then room.seat:Sit(humanoid) end
task.delay(.5,function()
if room.owner==player and room.seat.Occupant~=humanoid then release(room) end
end)
fire(player,'open',{terminal=room.id,status=room.status,ready=ready,busy=room.busy})
end
for i=1,4 do
local model=terminals:WaitForChild('Terminal'..i)
local room={id=i,model=model,seat=model:WaitForChild('Chair seat'),status='WARMING UP',history={},displayHistory={},version=0,busy=false,lastActive=0}
room.seatHome=room.seat.CFrame
rooms[i]=room
model.Interact.ProximityPrompt.Triggered:Connect(function(player) claim(player,room) end)
room.seat:GetPropertyChangedSignal('Occupant'):Connect(function()
local humanoid=room.seat.Occupant
if humanoid then
local player=Players:GetPlayerFromCharacter(humanoid.Parent)
if player and room.owner~=player then claim(player,room) end
elseif room.owner and not room.releasing then release(room) end
end)
broadcast(room)
end
-- Only a completed TextChatService submission can create a model request.
inputChannel.ShouldDeliverCallback=function(message,recipient)
local source=message.TextSource
if not source or recipient.UserId~=source.UserId then return false end
local player=Players:GetPlayerByUserId(source.UserId)
local id,version=message.Metadata:match('^Lamplight:(%d):(%d+):[%w%-]+$')
id=tonumber(id);version=tonumber(version)
local room=id and rooms[id]
if not room or room.owner~=player or room.version~=version or room.busy or #message.Text>Config.MaxPromptCharacters*4 or not near(player,room,Config.ReleaseDistance) then return false end
Safety:record(player.UserId,message.MessageId,id,room.version,message.Text,os.clock())
return true
end
local function addChatUser(player)
task.spawn(function()
local ok,source=pcall(function() return inputChannel:AddUserAsync(player.UserId) end)
if not ok or not source then fire(player,'notice',{text='Roblox chat is unavailable for this account.'}) end
end)
end
Players.PlayerAdded:Connect(addChatUser)
for _,player in Players:GetPlayers() do addChatUser(player) end
local function enqueue(player,room,text)
if not ready then fire(player,'notice',{text='The model is warming up. Please wait a moment.'});return end
if room.busy then return end
if not player.Character or room.seat.Occupant~=player.Character:FindFirstChildOfClass('Humanoid') then return end
if type(text)~='string' or #text>Config.MaxPromptCharacters*4 or not utf8.len(text) or utf8.len(text)>Config.MaxPromptCharacters or not text:match('%S') then return end
room.busy=true;room.lastActive=os.clock();room.version+=1;local version=room.version
status(room,'CHECKING MESSAGE')
task.spawn(function()
local allowed=Safety:canView(player.UserId,player.UserId)
local safe,reason
if allowed then safe,reason=Safety:filter(text,player.UserId) end
if room.version~=version or room.owner~=player then return end
if not safe then room.busy=false;status(room,'MESSAGE WITHHELD');broadcast(room);fire(player,'notice',{terminal=room.id,text=if reason=='professional' then Safety.professionalNotice elseif reason=='blocked' then 'This message was blocked by Roblox filtering. Please try a different message.' else 'Roblox chat or filtering is unavailable. Your message was not sent.'});return end
local messages={{role='system',content=Config.SystemPrompt}}
for _,m in room.history do table.insert(messages,m) end
table.insert(messages,{role='user',content=safe})
local ids=tokenizer:chat(messages)
while #ids>Config.MaxContext-Config.MaxOutput and #messages>2 do table.remove(messages,2);if #messages>2 then table.remove(messages,2) end;ids=tokenizer:chat(messages) end
if #ids>Config.MaxContext-Config.MaxOutput then room.busy=false;status(room,'MESSAGE TOO LONG');broadcast(room);fire(player,'notice',{text='That message uses too many tokens. Please shorten it.'});return end
room.publicPrompt=safe;room.publicAnswer='';table.insert(room.displayHistory,{role='user',content=safe})
fire(player,'accepted',{text=safe});status(room,'WAITING FOR A TURN');broadcast(room)
table.insert(waiting,{room=room,player=player,version=version,messages=messages,ids=ids,index=1,output={},rng=Random.new(),started=os.clock(),generated=0})
end)
end
remote.OnServerEvent:Connect(function(player,action,data)
if type(action)~='string' or type(data)~='table' then return end
if action=='hello' then fire(player,'welcome',{ready=ready,loading=loading});for _,room in rooms do sendView(room,player) end;return end
local id=data.terminal;if type(id)~='number' or id%1~=0 then return end
local room=rooms[id];if not room then return end
if action=='claim' then claim(player,room);return end
if room.owner~=player then return end
if action=='release' then release(room);room.displayHistory={};return end
if not near(player,room,Config.ReleaseDistance) then release(room);return end
room.lastActive=os.clock()
if action=='stop' then cancel(room);status(room,'STOPPED');broadcast(room);fire(player,'done',{cancelled=true});return end
if action=='new' then cancel(room);room.history={};room.displayHistory={};room.publicPrompt=nil;room.publicAnswer=nil;status(room,'READY');broadcast(room);fire(player,'cleared',{});return end
if action=='ask' then
local text=Safety:take(player.UserId,data.messageId,id,room.version,os.clock())
if not text then fire(player,'notice',{terminal=id,text='Your message was not accepted by Roblox chat. Please send it again.'});return end
local now=os.clock();if now-(rate[player.UserId] or 0)<2 then fire(player,'notice',{text='Please wait a moment before sending another message.'});return end;rate[player.UserId]=now;enqueue(player,room,text)
end
end)
Players.PlayerRemoving:Connect(function(player)
for _,room in rooms do if room.owner==player then release(room);room.displayHistory={} end end
rate[player.UserId]=nil;Safety.receipts[player.UserId]=nil
end)
task.spawn(function()
while true do
task.wait(5)
for _,room in rooms do
if room.owner and (not near(room.owner,room,Config.ReleaseDistance) or (not room.busy and os.clock()-room.lastActive>Config.IdleSeconds)) then release(room);room.displayHistory={} elseif room.owner then for _,recipient in Players:GetPlayers() do sendView(room,recipient) end end
end
end
end)
local function valid(job) return job.room.version==job.version and job.room.owner==job.player and job.player.Parent~=nil end
local function finish(job)
local room=job.room
status(room,'PREPARING PUBLIC RESPONSE')
task.spawn(function()
local raw=tokenizer:decode(job.output)
-- A byte-level tokenizer may stop in the middle of a UTF-8 code point.
while #raw>0 and not utf8.len(raw) do raw=raw:sub(1,-2) end
local safe,reason=Safety:filter(raw,job.player.UserId,true)
if not valid(job) then return end
if not safe then safe=if reason=='professional' then Safety.professionalNotice elseif reason=='blocked' then 'This response was blocked by Roblox filtering. Please try a different message.' else 'The response could not be checked by Roblox filtering and was not displayed. Please try again.' end
room.publicAnswer=safe;room.history=job.messages;table.remove(room.history,1);table.insert(room.history,{role='assistant',content=safe})
table.insert(room.displayHistory,{role='assistant',content=safe});while #room.displayHistory>12 do table.remove(room.displayHistory,1) end
room.busy=false;room.lastActive=os.clock();status(room,'READY');broadcast(room)
fire(job.player,'done',{tokens=#job.output,seconds=os.clock()-job.started})
print(string.format('[Lamplight] Terminal %d: %d tokens in %.2fs',room.id,#job.output,os.clock()-job.started))
end)
end
task.spawn(function()
local ok,err=pcall(function()
tokenizer=Tokenizer.new(SS:WaitForChild('TokenizerData'))
Engine.load(SS:WaitForChild('ModelData'),function(p)
loading=p;RS:SetAttribute('ModelLoading',p)
end)
end)
if not ok then warn('[Lamplight] Model load failed: '..tostring(err));for _,r in rooms do status(r,'MODEL UNAVAILABLE');broadcast(r) end;return end
ready=true;RS:SetAttribute('ModelReady',true)
for _,r in rooms do status(r,'READY');broadcast(r) end
remote:FireAllClients('ready',{})
print('[Lamplight] SmolLM2 ready. Pure Luau. '..Engine.manifest.weightBytes..' weight bytes.')
while true do
while #jobs<Config.ActiveSessions and #waiting>0 do
local j=table.remove(waiting,1)
if valid(j) then j.session=Engine.newSession(Config.MaxContext);table.insert(jobs,j) end
end
if #jobs==0 then task.wait(.1) else
for index=#jobs,1,-1 do
local j=jobs[index]
if not valid(j) then table.remove(jobs,index) else
local okStep,errorStep=pcall(function()
local prefill=j.index<=#j.ids
local id=if prefill then j.ids[j.index] else j.output[#j.output]
local projected=not prefill or j.index==#j.ids
local logits=Engine.forward(j.session,id,projected,function() return not valid(j) end)
j.index+=1
if projected then
local nextId=Engine.sample(logits,Config.Temperature,j.rng,j.output)
if nextId==2 or j.session.position>=Config.MaxContext-1 then j.finished=true else table.insert(j.output,nextId) end
if #j.output>=Config.MaxOutput then j.finished=true end
status(j.room,'WRITING · '..#j.output..' TOKENS')
else status(j.room,'READING · '..(j.index-1)..' / '..#j.ids) end
local display=j.room.model.Screen.TerminalDisplay;display.State.Text=j.room.status
if os.clock()-j.started>240 then error('Response time limit reached') end
end)
if not okStep then
table.remove(jobs,index)
if valid(j) then j.room.busy=false;status(j.room,'TRY AGAIN');fire(j.player,'done',{text='The model took too long or encountered an error. Try a shorter message.'});warn('[Lamplight] '..tostring(errorStep)) end
elseif j.finished then table.remove(jobs,index);finish(j) end
end
end
end
end
end)