mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-03 03:24:34 -04:00
chore: merge master into distributed transport PR
Bring the distributed branch onto current master before the CI fix. Assisted-by: Codex:gpt-6
This commit is contained in:
commit
4bc0e92a3d
102 files changed
+7143
-205
No files matched your search
@@ -213,9 +213,10 @@ func transcriptResultFromProto(r *proto.TranscriptResult) *schema.TranscriptionR
|
||||
var words []schema.TranscriptionWord
|
||||
for _, w := range s.Words {
|
||||
var word = schema.TranscriptionWord{
|
||||
Start: time.Duration(w.Start),
|
||||
End: time.Duration(w.End),
|
||||
Text: w.Text,
|
||||
Start: time.Duration(w.Start),
|
||||
End: time.Duration(w.End),
|
||||
Text: w.Text,
|
||||
Speaker: w.Speaker,
|
||||
}
|
||||
words = append(words, word)
|
||||
tr.Words = append(tr.Words, word)
|
||||
|
||||
@@ -26,11 +26,33 @@ import (
|
||||
// backchannel ("uh-huh") ended — callers must NOT treat Eob as a turn
|
||||
// boundary.
|
||||
type LiveTranscriptionEvent struct {
|
||||
Delta string
|
||||
Eou bool
|
||||
Eob bool
|
||||
Words []schema.TranscriptionWord
|
||||
Final *schema.TranscriptionResult
|
||||
Delta string
|
||||
Eou bool
|
||||
Eob bool
|
||||
Words []schema.TranscriptionWord
|
||||
Speakers []LiveSpeakerSegment
|
||||
Sounds []LiveSoundEvent
|
||||
Final *schema.TranscriptionResult
|
||||
}
|
||||
|
||||
// LiveSpeakerSegment is one closed speaker segment from a companion
|
||||
// diarization/scene stream running alongside live transcription. Start/End
|
||||
// are stream-relative seconds (mapped from the backend's nanoseconds).
|
||||
type LiveSpeakerSegment struct {
|
||||
Speaker string
|
||||
Start float64
|
||||
End float64
|
||||
}
|
||||
|
||||
// LiveSoundEvent is one closed sound event from a companion sound/scene
|
||||
// stream running alongside live transcription. Start/End are stream-relative
|
||||
// seconds (mapped from the backend's nanoseconds).
|
||||
type LiveSoundEvent struct {
|
||||
Label string
|
||||
Index int
|
||||
Peak float32
|
||||
Start float64
|
||||
End float64
|
||||
}
|
||||
|
||||
// LiveTranscriptionSession is a handle on an open live transcription stream.
|
||||
@@ -298,9 +320,26 @@ func liveEventFromProto(r *proto.TranscriptLiveResponse) LiveTranscriptionEvent
|
||||
}
|
||||
for _, w := range r.GetWords() {
|
||||
ev.Words = append(ev.Words, schema.TranscriptionWord{
|
||||
Start: time.Duration(w.Start),
|
||||
End: time.Duration(w.End),
|
||||
Text: w.Text,
|
||||
Start: time.Duration(w.Start),
|
||||
End: time.Duration(w.End),
|
||||
Text: w.Text,
|
||||
Speaker: w.Speaker,
|
||||
})
|
||||
}
|
||||
for _, s := range r.GetSpeakers() {
|
||||
ev.Speakers = append(ev.Speakers, LiveSpeakerSegment{
|
||||
Speaker: s.GetSpeaker(),
|
||||
Start: time.Duration(s.GetStart()).Seconds(),
|
||||
End: time.Duration(s.GetEnd()).Seconds(),
|
||||
})
|
||||
}
|
||||
for _, s := range r.GetSounds() {
|
||||
ev.Sounds = append(ev.Sounds, LiveSoundEvent{
|
||||
Label: s.GetLabel(),
|
||||
Index: int(s.GetIndex()),
|
||||
Peak: s.GetPeak(),
|
||||
Start: time.Duration(s.GetStart()).Seconds(),
|
||||
End: time.Duration(s.GetEnd()).Seconds(),
|
||||
})
|
||||
}
|
||||
if r.GetFinalResult() != nil {
|
||||
|
||||
@@ -54,11 +54,53 @@ var _ = Describe("liveEventFromProto", func() {
|
||||
Expect(ev.Final).To(BeNil())
|
||||
})
|
||||
|
||||
It("carries word speakers and final segment speakers from a diarizing backend", func() {
|
||||
ev := liveEventFromProto(&proto.TranscriptLiveResponse{
|
||||
Words: []*proto.TranscriptWord{{Text: "hi", Speaker: "1"}},
|
||||
})
|
||||
Expect(ev.Words[0].Speaker).To(Equal("1"))
|
||||
ev = liveEventFromProto(&proto.TranscriptLiveResponse{
|
||||
FinalResult: &proto.TranscriptResult{
|
||||
Text: "hi there",
|
||||
Segments: []*proto.TranscriptSegment{{Text: "hi", Speaker: "0"}, {Text: "there", Speaker: "1"}},
|
||||
},
|
||||
})
|
||||
Expect(ev.Final.Segments[1].Speaker).To(Equal("1"))
|
||||
})
|
||||
|
||||
It("maps the eob backchannel flag separately from eou", func() {
|
||||
ev := liveEventFromProto(&proto.TranscriptLiveResponse{Delta: "uh-huh", Eob: true})
|
||||
Expect(ev.Eob).To(BeTrue())
|
||||
Expect(ev.Eou).To(BeFalse())
|
||||
})
|
||||
|
||||
It("maps speaker segments and sound events (ns -> seconds)", func() {
|
||||
ev := liveEventFromProto(&proto.TranscriptLiveResponse{
|
||||
Speakers: []*proto.LiveSpeakerSegment{
|
||||
{Speaker: "1", Start: int64(1500 * time.Millisecond), End: int64(3200 * time.Millisecond)},
|
||||
},
|
||||
Sounds: []*proto.LiveSoundEvent{
|
||||
{Label: "Dog bark", Index: 5, Peak: 0.8, Start: int64(500 * time.Millisecond), End: int64(900 * time.Millisecond)},
|
||||
},
|
||||
})
|
||||
Expect(ev.Speakers).To(HaveLen(1))
|
||||
Expect(ev.Speakers[0].Speaker).To(Equal("1"))
|
||||
Expect(ev.Speakers[0].Start).To(BeNumerically("~", 1.5, 1e-9))
|
||||
Expect(ev.Speakers[0].End).To(BeNumerically("~", 3.2, 1e-9))
|
||||
|
||||
Expect(ev.Sounds).To(HaveLen(1))
|
||||
Expect(ev.Sounds[0].Label).To(Equal("Dog bark"))
|
||||
Expect(ev.Sounds[0].Index).To(Equal(5))
|
||||
Expect(ev.Sounds[0].Peak).To(BeNumerically("~", 0.8, 1e-6))
|
||||
Expect(ev.Sounds[0].Start).To(BeNumerically("~", 0.5, 1e-9))
|
||||
Expect(ev.Sounds[0].End).To(BeNumerically("~", 0.9, 1e-9))
|
||||
})
|
||||
|
||||
It("leaves speakers and sounds nil when the proto carries none", func() {
|
||||
ev := liveEventFromProto(&proto.TranscriptLiveResponse{Delta: "hi"})
|
||||
Expect(ev.Speakers).To(BeNil())
|
||||
Expect(ev.Sounds).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
// liveTraceState is what makes streaming-only pipelines visible on the
|
||||
|
||||
@@ -314,7 +314,7 @@ func agentOptions(dir, model string, opts Options) app.Options {
|
||||
TraceDir: opts.TraceDir,
|
||||
}
|
||||
if opts.Yolo {
|
||||
overrides.ApprovalMode = "auto"
|
||||
overrides.ApprovalMode = nibtypes.ApprovalAuto
|
||||
}
|
||||
|
||||
return app.Options{
|
||||
|
||||
@@ -509,7 +509,7 @@ var _ = Describe("prepare", func() {
|
||||
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(BeEmpty())
|
||||
|
||||
opts.Yolo = true
|
||||
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal("auto"))
|
||||
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal(nibtypes.ApprovalAuto))
|
||||
})
|
||||
|
||||
// The specs above pin what is handed over. These pin what nib does with
|
||||
@@ -570,7 +570,7 @@ var _ = Describe("prepare", func() {
|
||||
opts.Yolo = true
|
||||
|
||||
cfg := resolve(agentOptions(dir, "a-model", opts))
|
||||
Expect(cfg.ApprovalMode).To(Equal("auto"))
|
||||
Expect(cfg.ApprovalMode).To(Equal(nibtypes.ApprovalAuto))
|
||||
})
|
||||
|
||||
// The other half of the same rule, and the reason an unset flag is
|
||||
@@ -582,7 +582,7 @@ var _ = Describe("prepare", func() {
|
||||
|
||||
cfg := resolve(agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
|
||||
Expect(cfg.APIKey).To(Equal("saved-key"))
|
||||
Expect(cfg.ApprovalMode).To(Equal("prompt"))
|
||||
Expect(cfg.ApprovalMode).To(Equal(nibtypes.ApprovalPrompt))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,18 +93,20 @@ func (t *TranscriptCMD) Run(ctx *cliContext.Context) error {
|
||||
}
|
||||
for _, word := range(tr.Words) {
|
||||
trs.Words = append(trs.Words, schema.TranscriptionWordSeconds{
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Speaker: word.Speaker,
|
||||
})
|
||||
}
|
||||
for _, seg := range(tr.Segments) {
|
||||
segWords := []schema.TranscriptionWordSeconds{}
|
||||
for _, word := range(seg.Words) {
|
||||
segWords = append(segWords, schema.TranscriptionWordSeconds{
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Speaker: word.Speaker,
|
||||
})
|
||||
}
|
||||
trs.Segments = append(trs.Segments, schema.TranscriptionSegmentSeconds{
|
||||
|
||||
@@ -35,6 +35,7 @@ const (
|
||||
UsecaseSpeakerRecognition = "speaker_recognition"
|
||||
UsecaseTokenClassify = "token_classify"
|
||||
UsecaseScore = "score"
|
||||
UsecaseDecisions = "decisions"
|
||||
)
|
||||
|
||||
// GRPCMethod identifies a Backend service RPC from backend.proto.
|
||||
@@ -216,6 +217,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
|
||||
GRPCMethod: MethodScore,
|
||||
Description: "Joint log-probability scoring of candidate continuations via the Score RPC. Declared explicitly via known_usecases and usable alongside generation usecases.",
|
||||
},
|
||||
UsecaseDecisions: {
|
||||
Flag: FLAG_DECISIONS,
|
||||
GRPCMethod: MethodScore,
|
||||
Description: "Decision models (served by POST /v1/systemone): typed choice, noul and score questions over a state text, answered by a non-generative decision model through the Score RPC (question_type systemone). Declared explicitly via known_usecases.",
|
||||
},
|
||||
}
|
||||
|
||||
// BackendCapability describes which gRPC methods and usecases a backend supports.
|
||||
@@ -349,10 +355,10 @@ var BackendCapabilities = map[string]BackendCapability{
|
||||
// model returns an error rather than silent garbage.
|
||||
"vllm-cpp": {
|
||||
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodGenerateVideo, MethodTokenClassify, MethodScore},
|
||||
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseVideo, UsecaseTokenClassify, UsecaseScore},
|
||||
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseVision, UsecaseVideo, UsecaseTokenClassify, UsecaseScore, UsecaseDecisions},
|
||||
DefaultUsecases: []string{UsecaseChat},
|
||||
AcceptsImages: true,
|
||||
Description: "vllm.cpp — the LocalAI team's C++20 port of vLLM; text generation, MiniMax-H3 video+audio generation, GLiNER2.5 zero-shot NER, cua-s1-forms scoring, and kev/laya decision pipelines",
|
||||
Description: "vllm.cpp — the LocalAI team's C++20 port of vLLM; text generation, MiniMax-H3 video+audio generation, GLiNER2.5 zero-shot NER, cua-s1-forms scoring, and decision models (kev, laya, CLM, GLiNER2.5-Decide, xor, nimble)",
|
||||
},
|
||||
"vllm-omni": {
|
||||
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodGenerateImage, MethodGenerateVideo, MethodTTS},
|
||||
@@ -477,11 +483,15 @@ var BackendCapabilities = map[string]BackendCapability{
|
||||
DefaultUsecases: []string{UsecaseTranscript},
|
||||
Description: "NVIDIA NeMo speech recognition",
|
||||
},
|
||||
// parakeet-cpp loads three model kinds, picked from the GGUF: an ASR model
|
||||
// transcribes (and labels speakers when a diarization_model companion is
|
||||
// attached), a Nemotron-3-Diarization model answers Diarize, and a CED model
|
||||
// answers SoundDetection. PossibleUsecases is their union.
|
||||
"parakeet-cpp": {
|
||||
GRPCMethods: []GRPCMethod{MethodAudioTranscription},
|
||||
PossibleUsecases: []string{UsecaseTranscript},
|
||||
GRPCMethods: []GRPCMethod{MethodAudioTranscription, MethodDiarize, MethodSoundDetection},
|
||||
PossibleUsecases: []string{UsecaseTranscript, UsecaseDiarization, UsecaseSoundClassification},
|
||||
DefaultUsecases: []string{UsecaseTranscript},
|
||||
Description: "NVIDIA NeMo Parakeet ASR (parakeet.cpp)",
|
||||
Description: "NVIDIA NeMo Parakeet ASR, Nemotron-3-Diarization speaker diarization and CED sound-event detection (parakeet.cpp)",
|
||||
},
|
||||
// nemo-speech-cpp is one gRPC server in front of four NeMo-Speech.cpp model
|
||||
// families, picked at load time from the GGUF general.architecture key, so
|
||||
|
||||
+2
-2
@@ -16,14 +16,14 @@ import (
|
||||
|
||||
// reservedNonChatModel reports whether the operator reserved this model for an
|
||||
// internal primitive — the router score classifier or the PII NER
|
||||
// token_classify tier. Such a model has no chat template and must not be
|
||||
// token_classify tier, or a decision head. Such a model has no chat template and must not be
|
||||
// given the generative-chat defaults the GGUF importer otherwise applies
|
||||
// (FLAG_CHAT, jinja templating): surfacing it in chat pickers defeats the
|
||||
// reservation. Operators who do want a combined model declare both usecases
|
||||
// explicitly — the combination is valid.
|
||||
func reservedNonChatModel(cfg *ModelConfig) bool {
|
||||
return cfg.KnownUsecases != nil &&
|
||||
(*cfg.KnownUsecases&(FLAG_SCORE|FLAG_TOKEN_CLASSIFY)) != 0
|
||||
(*cfg.KnownUsecases&(FLAG_SCORE|FLAG_TOKEN_CLASSIFY|FLAG_DECISIONS)) != 0
|
||||
}
|
||||
|
||||
// genAudioEncoderKey is the mmproj metadata flag llama.cpp's mtmd writes for a
|
||||
|
||||
@@ -509,6 +509,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
|
||||
Min: f64(0),
|
||||
Order: 66,
|
||||
},
|
||||
"pipeline.diarization": {
|
||||
Section: "pipeline",
|
||||
Label: "Speaker Diarization",
|
||||
Description: "Label speakers on each committed utterance and emit every labelled segment as a conversation.item.input_audio_transcription.segment event. Needs a transcription model that diarizes (e.g. parakeet-cpp with a diarization_model companion). Speaker labels are per turn.",
|
||||
Component: "toggle",
|
||||
Order: 67,
|
||||
},
|
||||
"pipeline.reasoning_effort": {
|
||||
Section: "pipeline",
|
||||
Label: "Reasoning Effort",
|
||||
|
||||
@@ -833,6 +833,14 @@ type Pipeline struct {
|
||||
SoundDetectionWindowMs int `yaml:"sound_detection_window_ms,omitempty" json:"sound_detection_window_ms,omitempty"`
|
||||
SoundDetectionHopMs int `yaml:"sound_detection_hop_ms,omitempty" json:"sound_detection_hop_ms,omitempty"`
|
||||
|
||||
// Diarization asks the transcription model for speaker labels on each
|
||||
// VAD-committed utterance and emits every labelled segment as a
|
||||
// conversation.item.input_audio_transcription.segment event. It needs a
|
||||
// transcription model that diarizes (e.g. parakeet-cpp with a
|
||||
// diarization_model companion); off by default because some backends fail
|
||||
// a diarize request they cannot serve. Speaker labels are per turn.
|
||||
Diarization bool `yaml:"diarization,omitempty" json:"diarization,omitempty"`
|
||||
|
||||
// ReasoningEffort sets the reasoning effort (none|minimal|low|medium|high) for
|
||||
// the pipeline's LLM without editing the LLM model config. Overrides the LLM's
|
||||
// own reasoning_effort. Unset leaves the LLM model config in charge.
|
||||
@@ -2048,6 +2056,13 @@ const (
|
||||
FLAG_3D ModelConfigUsecase = 0b100000000000000000000000
|
||||
FLAG_3D_ANIMATION ModelConfigUsecase = 1 << 24
|
||||
|
||||
// Marks a model as a decision model: it answers typed choice / noul /
|
||||
// score questions over a state (served by POST /v1/systemone).
|
||||
// Explicit only, like FLAG_SCORE: a decision model never generates
|
||||
// text, so guessing chat or embeddings for it would surface it in
|
||||
// pickers it cannot serve.
|
||||
FLAG_DECISIONS ModelConfigUsecase = 1 << 25
|
||||
|
||||
// Common Subsets
|
||||
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
|
||||
)
|
||||
@@ -2110,6 +2125,7 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
|
||||
"FLAG_TOKEN_CLASSIFY": FLAG_TOKEN_CLASSIFY,
|
||||
"FLAG_3D": FLAG_3D,
|
||||
"FLAG_3D_ANIMATION": FLAG_3D_ANIMATION,
|
||||
"FLAG_DECISIONS": FLAG_DECISIONS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2138,9 +2154,9 @@ func GetUsecasesFromYAML(input []string) *ModelConfigUsecase {
|
||||
//
|
||||
// Declared known_usecases are normally additive — the guessing heuristic
|
||||
// still adds whatever it can infer from backend/templates. The exceptions
|
||||
// are FLAG_SCORE and FLAG_TOKEN_CLASSIFY: when the operator declared
|
||||
// either, they reserved the model for an internal direct-decode primitive
|
||||
// (the router classifier, or the PII NER tier). Letting GuessUsecases
|
||||
// are FLAG_SCORE, FLAG_TOKEN_CLASSIFY and FLAG_DECISIONS: when the operator
|
||||
// declared any of them, they reserved the model for a direct-decode primitive
|
||||
// (the router classifier, the PII NER tier, or a decision head). Letting GuessUsecases
|
||||
// paint chat/completion/embeddings on top would surface it in pickers it
|
||||
// was deliberately kept out of. So a declared score or token_classify
|
||||
// list is authoritative; declare the generation usecases explicitly
|
||||
@@ -2150,7 +2166,7 @@ func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
|
||||
if (u & *c.KnownUsecases) == u {
|
||||
return true
|
||||
}
|
||||
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY)) != 0 {
|
||||
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY | FLAG_DECISIONS)) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2373,6 +2389,14 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if (u & FLAG_DECISIONS) == FLAG_DECISIONS {
|
||||
// No heuristic: decisions intent is a deliberate operator choice
|
||||
// (the model is a non-generative decision head), so
|
||||
// HasUsecases(FLAG_DECISIONS) is true only when KnownUsecases
|
||||
// declares it explicitly.
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -955,3 +955,36 @@ var _ = Describe("ModelConfig alias", func() {
|
||||
Expect(err).To(MatchError(ContainSubstring("alias")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("decisions usecase", func() {
|
||||
// A decision model never generates text, so a declared decisions list
|
||||
// must stay authoritative and the heuristic must never guess the flag.
|
||||
It("is authoritative when declared and never guessed", func() {
|
||||
declared := GetUsecasesFromYAML([]string{"decisions"})
|
||||
Expect(declared).NotTo(BeNil())
|
||||
Expect(*declared).NotTo(Equal(FLAG_ANY))
|
||||
|
||||
cfg := ModelConfig{
|
||||
Name: "laya",
|
||||
Backend: "vllm-cpp",
|
||||
KnownUsecases: declared,
|
||||
TemplateConfig: TemplateConfig{
|
||||
Chat: "inherited from chatml",
|
||||
ChatMessage: "inherited from chatml",
|
||||
Completion: "inherited from chatml",
|
||||
},
|
||||
}
|
||||
Expect(cfg.HasUsecases(*declared)).To(BeTrue())
|
||||
Expect(cfg.HasUsecases(FLAG_CHAT)).To(BeFalse())
|
||||
Expect(cfg.HasUsecases(FLAG_COMPLETION)).To(BeFalse())
|
||||
Expect(cfg.HasUsecases(FLAG_EMBEDDINGS)).To(BeFalse())
|
||||
|
||||
undeclared := ModelConfig{Name: "laya", Backend: "vllm-cpp"}
|
||||
Expect(undeclared.HasUsecases(*declared)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is a reserved usecase for the GGUF importer chat-default guard", func() {
|
||||
declared := GetUsecasesFromYAML([]string{"decisions"})
|
||||
Expect(reservedNonChatModel(&ModelConfig{Backend: "vllm-cpp", KnownUsecases: declared})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@@ -108,6 +108,11 @@ func (i *ParakeetCppImporter) Import(details Details) (gallery.ModelConfig, erro
|
||||
|
||||
uri := downloader.URI(details.URI)
|
||||
directGGUF := isParakeetGGUF(filepath.Base(details.URI))
|
||||
// A speaker diarization GGUF is served by the same backend but answers
|
||||
// /v1/audio/diarization, not transcription.
|
||||
if directGGUF && isParakeetDiarGGUF(filepath.Base(details.URI)) {
|
||||
modelConfig.KnownUsecaseStrings = []string{"diarization"}
|
||||
}
|
||||
switch {
|
||||
case uri.LooksLikeURL() && directGGUF:
|
||||
// Direct file URL (e.g. .../resolve/main/tdt_ctc-110m-f16.gguf). The
|
||||
@@ -128,12 +133,23 @@ func (i *ParakeetCppImporter) Import(details Details) (gallery.ModelConfig, erro
|
||||
// HF repo: collect every parakeet GGUF, pick the preferred quant, and
|
||||
// nest under parakeet-cpp/models/<name>/ so a multi-quant repo doesn't
|
||||
// collide on disk.
|
||||
var ggufFiles []hfapi.ModelFile
|
||||
// Prefer ASR weights: a repo that also ships the diarization model
|
||||
// (mudler/parakeet-cpp-gguf) imports as a transcription model, and the
|
||||
// diarization GGUF is imported by its direct URL. A repo with only
|
||||
// diarization weights imports as a diarization model.
|
||||
var ggufFiles, diarFiles []hfapi.ModelFile
|
||||
for _, f := range details.HuggingFace.Files {
|
||||
if isParakeetGGUF(filepath.Base(f.Path)) {
|
||||
switch base := filepath.Base(f.Path); {
|
||||
case isParakeetDiarGGUF(base):
|
||||
diarFiles = append(diarFiles, f)
|
||||
case isParakeetGGUF(base):
|
||||
ggufFiles = append(ggufFiles, f)
|
||||
}
|
||||
}
|
||||
if len(ggufFiles) == 0 && len(diarFiles) > 0 {
|
||||
ggufFiles = diarFiles
|
||||
modelConfig.KnownUsecaseStrings = []string{"diarization"}
|
||||
}
|
||||
if chosen, ok := pickPreferredGGMLFile(ggufFiles, quants); ok {
|
||||
target := filepath.Join("parakeet-cpp", "models", name, filepath.Base(chosen.Path))
|
||||
cfg.Files = append(cfg.Files, gallery.File{
|
||||
@@ -176,5 +192,14 @@ func isParakeetGGUF(name string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return isParakeetDiarGGUF(name)
|
||||
}
|
||||
|
||||
// isParakeetDiarGGUF reports whether name is the parakeet.cpp speaker
|
||||
// diarization GGUF (nemotron-3-diarization-<quant>.gguf). Matched by its
|
||||
// published name only, so diarization weights for other backends are not
|
||||
// claimed.
|
||||
func isParakeetDiarGGUF(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return strings.HasSuffix(lower, ".gguf") && strings.Contains(lower, "nemotron-3-diarization")
|
||||
}
|
||||
@@ -50,6 +50,11 @@ var _ = Describe("ParakeetCppImporter", func() {
|
||||
Expect(imp.Match(d)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("matches a direct URL to the diarization GGUF", func() {
|
||||
d := parakeetDetails("https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/nemotron-3-diarization-q8_0.gguf", `{}`)
|
||||
Expect(imp.Match(d)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does NOT claim a generic llama-style GGUF", func() {
|
||||
d := parakeetDetails("huggingface://someorg/some-llm-gguf", `{}`,
|
||||
hfapi.ModelFile{Path: "llama-3-8b-instruct-q4_k_m.gguf"},
|
||||
@@ -66,6 +71,43 @@ var _ = Describe("ParakeetCppImporter", func() {
|
||||
})
|
||||
|
||||
Context("import (Import)", func() {
|
||||
It("imports the diarization GGUF as a diarization model", func() {
|
||||
d := parakeetDetails("https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/nemotron-3-diarization-q8_0.gguf",
|
||||
`{"name":"nemotron-diarization"}`)
|
||||
cfg, err := imp.Import(d)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(cfg.ConfigFile).To(ContainSubstring("backend: parakeet-cpp"))
|
||||
Expect(cfg.ConfigFile).To(ContainSubstring("diarization"))
|
||||
Expect(cfg.ConfigFile).ToNot(ContainSubstring("transcript"))
|
||||
Expect(cfg.Files).To(HaveLen(1))
|
||||
Expect(cfg.Files[0].Filename).To(HaveSuffix("nemotron-3-diarization-q8_0.gguf"))
|
||||
})
|
||||
|
||||
It("keeps picking ASR weights from a repo that also ships the diarization model", func() {
|
||||
d := parakeetDetails("huggingface://mudler/parakeet-cpp-gguf", `{"name":"parakeet-110m"}`,
|
||||
hfapi.ModelFile{Path: "nemotron-3-diarization-f16.gguf", URL: "https://hf/diar-f16", SHA256: "ddd"},
|
||||
hfapi.ModelFile{Path: "tdt_ctc-110m-f16.gguf", URL: "https://hf/f16", SHA256: "aaa"},
|
||||
hfapi.ModelFile{Path: "nemotron-3-diarization-q8_0.gguf", URL: "https://hf/diar-q8", SHA256: "eee"},
|
||||
)
|
||||
cfg, err := imp.Import(d)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(cfg.Files).To(HaveLen(1))
|
||||
Expect(cfg.Files[0].URI).To(Equal("https://hf/f16"))
|
||||
Expect(cfg.ConfigFile).To(ContainSubstring("transcript"))
|
||||
})
|
||||
|
||||
It("imports a diarization-only repo as a diarization model", func() {
|
||||
d := parakeetDetails("huggingface://someone/diar-gguf", `{"name":"diar"}`,
|
||||
hfapi.ModelFile{Path: "nemotron-3-diarization-f16.gguf", URL: "https://hf/diar-f16", SHA256: "ddd"},
|
||||
hfapi.ModelFile{Path: "nemotron-3-diarization-q8_0.gguf", URL: "https://hf/diar-q8", SHA256: "eee"},
|
||||
)
|
||||
cfg, err := imp.Import(d)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(cfg.Files).To(HaveLen(1))
|
||||
Expect(cfg.Files[0].URI).To(Equal("https://hf/diar-q8"), "default quant ladder picks q8_0 before f16")
|
||||
Expect(cfg.ConfigFile).To(ContainSubstring("diarization"))
|
||||
})
|
||||
|
||||
It("picks the default quant (q4_k) from a multi-quant HF repo", func() {
|
||||
d := parakeetDetails("huggingface://mudler/parakeet-cpp-gguf", `{"name":"parakeet-110m"}`,
|
||||
hfapi.ModelFile{Path: "tdt_ctc-110m-f16.gguf", URL: "https://hf/f16", SHA256: "aaa"},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package gallery_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
)
|
||||
|
||||
// A gallery tag that names a capability is what users filter on, and
|
||||
// known_usecases is what the server routes on. When they disagree, the entry
|
||||
// is listed under a filter it cannot serve, or is hidden from one it can.
|
||||
var _ = Describe("gallery/index.yaml vllm-cpp capability tags", func() {
|
||||
It("keeps capability tags and known_usecases in agreement", func() {
|
||||
entries, err := loadGalleryIndex()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
tagToFlag := map[string]config.ModelConfigUsecase{
|
||||
"decisions": config.FLAG_DECISIONS,
|
||||
"vision": config.FLAG_VISION,
|
||||
"token-classify": config.FLAG_TOKEN_CLASSIFY,
|
||||
"scoring": config.FLAG_SCORE,
|
||||
}
|
||||
|
||||
var violations []string
|
||||
seen := 0
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
if backend, _ := e.Overrides["backend"].(string); backend != "vllm-cpp" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
declared := e.GetKnownUsecases()
|
||||
for tag, flag := range tagToFlag {
|
||||
tagged := slices.Contains(e.Tags, tag)
|
||||
has := declared != nil && *declared&flag == flag
|
||||
if tagged != has {
|
||||
violations = append(violations, fmt.Sprintf("%s: tag %q present=%v but known_usecases declares it=%v", e.Name, tag, tagged, has))
|
||||
}
|
||||
}
|
||||
}
|
||||
Expect(seen).To(BeNumerically(">", 0))
|
||||
Expect(violations).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
// artifacts: is a model-config key, so the installer only sees it inside
|
||||
// overrides:. At the top level of an entry it is silently dropped, the
|
||||
// installed config keeps a bare HF repo id as its model, and a backend that
|
||||
// does not infer artifacts (vllm-cpp among them) fails the first load with
|
||||
// "model path not found" while the install itself reported success.
|
||||
var _ = Describe("gallery/index.yaml artifacts placement", func() {
|
||||
It("declares artifacts under overrides, never at the entry top level", func() {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "gallery", "index.yaml"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var raw []map[string]any
|
||||
Expect(yaml.Unmarshal(data, &raw)).To(Succeed())
|
||||
|
||||
var misplaced []string
|
||||
for _, e := range raw {
|
||||
if _, ok := e["artifacts"]; ok {
|
||||
misplaced = append(misplaced, fmt.Sprint(e["name"]))
|
||||
}
|
||||
}
|
||||
Expect(misplaced).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,11 @@ var RouteFeatureRegistry = []RouteFeature{
|
||||
// Detection
|
||||
{"POST", "/v1/detection", FeatureDetection},
|
||||
|
||||
// Decisions API (SystemOne wire contract)
|
||||
{"POST", "/v1/systemone", FeatureDecisions},
|
||||
{"POST", "/v1/systemone/permute", FeatureDecisions},
|
||||
{"POST", "/v1/systemone/separate", FeatureDecisions},
|
||||
|
||||
// Face recognition
|
||||
{"POST", "/v1/face/verify", FeatureFaceRecognition},
|
||||
{"POST", "/v1/face/analyze", FeatureFaceRecognition},
|
||||
@@ -209,5 +214,6 @@ func APIFeatureMetas() []FeatureMeta {
|
||||
{FeatureVoiceRecognition, "Voice Recognition", true},
|
||||
{FeatureAudioTransform, "Audio Transform", true},
|
||||
{FeaturePIIFilter, "PII Analyze / Redact", true},
|
||||
{FeatureDecisions, "Decisions", true},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
. "github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Decisions feature registration", func() {
|
||||
It("gates the three decision routes behind one default-on API feature", func() {
|
||||
Expect(APIFeatures).To(ContainElement(FeatureDecisions))
|
||||
|
||||
patterns := []string{}
|
||||
for _, route := range RouteFeatureRegistry {
|
||||
if route.Feature == FeatureDecisions {
|
||||
Expect(route.Method).To(Equal("POST"))
|
||||
patterns = append(patterns, route.Pattern)
|
||||
}
|
||||
}
|
||||
Expect(patterns).To(ConsistOf("/v1/systemone", "/v1/systemone/permute", "/v1/systemone/separate"))
|
||||
|
||||
Expect(APIFeatureMetas()).To(ContainElement(FeatureMeta{Key: FeatureDecisions, Label: "Decisions", DefaultValue: true}))
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,7 @@ const (
|
||||
FeatureFaceRecognition = "face_recognition"
|
||||
FeatureVoiceRecognition = "voice_recognition"
|
||||
FeatureAudioTransform = "audio_transform"
|
||||
FeatureDecisions = "decisions"
|
||||
// FeaturePIIFilter gates the synchronous PII analyze/redact service
|
||||
// (POST /api/pii/{analyze,redact}). Default ON like the other API
|
||||
// features; the admin-only events log is gated separately in-handler.
|
||||
@@ -78,7 +79,7 @@ var APIFeatures = []string{
|
||||
FeatureVAD, FeatureDetection, FeatureVideo, Feature3D, FeatureEmbeddings, FeatureSound,
|
||||
FeatureRealtime, FeatureModeration, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
|
||||
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
|
||||
FeaturePIIFilter,
|
||||
FeaturePIIFilter, FeatureDecisions,
|
||||
}
|
||||
|
||||
// AllFeatures lists all known features (used by UI and validation).
|
||||
|
||||
@@ -105,6 +105,12 @@ var instructionDefs = []instructionDef{
|
||||
Tags: []string{"voice-recognition"},
|
||||
Intro: "Voice (speaker) recognition — the audio analog to /v1/face/*. Use /v1/voice/verify for 1:1 speaker comparison, /v1/voice/identify for 1:N match against the registered store, /v1/voice/{register,forget} to manage that store, /v1/voice/embed for a raw speaker-encoder vector, and /v1/voice/analyze for age / gender / emotion inferred from speech. Registrations are in-memory by default and lost on restart. Audio inputs accept URL, base64, or data-URI; /v1/embeddings remains text-only.",
|
||||
},
|
||||
{
|
||||
Name: "decisions",
|
||||
Description: "Typed decisions (choice, noul, score) over a state text with calibrated confidence",
|
||||
Tags: []string{"systemone"},
|
||||
Intro: "POST /v1/systemone answers every question in one pass; /v1/systemone/permute re-runs one choice question under n_perm option orders; /v1/systemone/separate answers each question in its own pass. Request: { model, state, questions: { <id>: { type: choice|noul|score, instructions, criteria } } }. A decision model declares known_usecases: [decisions] and serves only /v1/systemone; a zero-shot NER model declares token_classify and serves all three routes (through the NER path); /permute and /separate return 400 for decision models. A vllm-cpp config that declares no usecases is treated as a decision model. Responses carry per-question answers with confidence and probabilities plus token usage. Field names and question types follow Ollama's /v1/systemone, with differences in confidence, error shape and keep_alive (see the Decisions API docs). A request over 64 KiB, with more than 64 questions, or with a malformed question is refused.",
|
||||
},
|
||||
{
|
||||
Name: "branding",
|
||||
Description: "Whitelabel the instance: configure name, tagline, logo, and favicon",
|
||||
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
|
||||
instructions, ok := resp["instructions"].([]any)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(instructions).To(HaveLen(20))
|
||||
Expect(instructions).To(HaveLen(21))
|
||||
|
||||
// Verify each instruction has required fields and correct URL format
|
||||
for _, s := range instructions {
|
||||
@@ -82,6 +82,7 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
"voice-library",
|
||||
"3d",
|
||||
"failover",
|
||||
"decisions",
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -136,6 +137,17 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
Expect(string(body)).NotTo(ContainSubstring("/v1/3d/generations"))
|
||||
})
|
||||
|
||||
It("should advertise the Decisions API", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/instructions/decisions", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
body, _ := io.ReadAll(rec.Body)
|
||||
Expect(string(body)).To(ContainSubstring("POST /v1/systemone"))
|
||||
Expect(string(body)).To(ContainSubstring("known_usecases: [decisions]"))
|
||||
})
|
||||
|
||||
It("should return JSON fragment when format=json", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/instructions/chat-inference?format=json", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -2,6 +2,7 @@ package localai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
@@ -371,6 +372,191 @@ func systemOneError(c echo.Context, status int, msg string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// systemOneModelAllowed keeps chat and embedding models out of the decision
|
||||
// API with an actionable error instead of a backend failure. A config that
|
||||
// declares no usecases predates the flag and stays allowed, and a
|
||||
// token_classify model is allowed because the NER path serves it.
|
||||
func systemOneModelAllowed(cfg config.ModelConfig) error {
|
||||
if cfg.KnownUsecases == nil {
|
||||
return nil
|
||||
}
|
||||
if *cfg.KnownUsecases&(config.FLAG_DECISIONS|config.FLAG_TOKEN_CLASSIFY) != 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("model %q does not declare the decisions usecase (known_usecases: [decisions])", cfg.Name)
|
||||
}
|
||||
|
||||
// checkSystemOneModel applies systemOneModelAllowed to a model looked up by
|
||||
// name. An unknown model passes here so the existing not-found handling
|
||||
// downstream keeps its status code.
|
||||
func checkSystemOneModel(app *application.Application, modelName string) error {
|
||||
cl := app.ModelConfigLoader()
|
||||
if cl == nil {
|
||||
return nil
|
||||
}
|
||||
cfg, ok := cl.GetModelConfig(modelName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return systemOneModelAllowed(cfg)
|
||||
}
|
||||
|
||||
// systemOneUsesDecisionPipeline reports whether /v1/systemone forwards the
|
||||
// request to the backend's Score RPC (the decision pipeline) for this model.
|
||||
// A model that declares token_classify without systemone is a zero-shot NER
|
||||
// model: the backend's decision entry point refuses those architectures, so it
|
||||
// goes to the NER path instead. A config that declares nothing keeps the
|
||||
// decision pipeline, which is what setups that predate the decisions usecase
|
||||
// relied on.
|
||||
func systemOneUsesDecisionPipeline(cfg config.ModelConfig) bool {
|
||||
if !backendSupportsScore(cfg.Backend) {
|
||||
return false
|
||||
}
|
||||
if cfg.KnownUsecases == nil {
|
||||
return true
|
||||
}
|
||||
declared := *cfg.KnownUsecases
|
||||
if declared&config.FLAG_DECISIONS != 0 {
|
||||
return true
|
||||
}
|
||||
return declared&config.FLAG_TOKEN_CLASSIFY == 0
|
||||
}
|
||||
|
||||
// systemOneNERAllowed guards /permute and /separate, which always run the NER
|
||||
// path. A decision model cannot serve them: the backend's NER entry point
|
||||
// refuses its architecture, and the caller would see a backend error.
|
||||
func systemOneNERAllowed(cfg config.ModelConfig) error {
|
||||
if cfg.KnownUsecases == nil {
|
||||
return nil
|
||||
}
|
||||
declared := *cfg.KnownUsecases
|
||||
if declared&config.FLAG_DECISIONS != 0 && declared&config.FLAG_TOKEN_CLASSIFY == 0 {
|
||||
return fmt.Errorf("model %q is a decision model: /permute and /separate use the NER path, use POST /v1/systemone instead", cfg.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSystemOneNERModel applies systemOneNERAllowed to a model looked up by
|
||||
// name; an unknown model passes so the not-found handling keeps its status.
|
||||
func checkSystemOneNERModel(app *application.Application, modelName string) error {
|
||||
cl := app.ModelConfigLoader()
|
||||
if cl == nil {
|
||||
return nil
|
||||
}
|
||||
cfg, ok := cl.GetModelConfig(modelName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return systemOneNERAllowed(cfg)
|
||||
}
|
||||
|
||||
// systemOneMaxBody and systemOneMaxQuestions bound one request. They keep a
|
||||
// single call from pinning a decision model on an unbounded prompt, and match
|
||||
// the limits Ollama documents for the same wire contract, so a client written
|
||||
// for one server behaves the same on the other. The engine enforces any
|
||||
// per-model option cap (letter-answer models refuse more than 26 options).
|
||||
const (
|
||||
systemOneMaxBody = 64 << 10
|
||||
systemOneMaxQuestions = 64
|
||||
)
|
||||
|
||||
// systemOneBind binds the JSON body with a size cap. Bind reads the whole body
|
||||
// first, so the cap has to be on the reader.
|
||||
func systemOneBind(c echo.Context, v any) error {
|
||||
c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, systemOneMaxBody)
|
||||
return c.Bind(v)
|
||||
}
|
||||
|
||||
// systemOneBindStatus maps a bind failure to its status: 413 when the body
|
||||
// exceeded the cap, 400 for anything else.
|
||||
func systemOneBindStatus(err error) int {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return http.StatusRequestEntityTooLarge
|
||||
}
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
func systemOneBindMessage(err error) string {
|
||||
if systemOneBindStatus(err) == http.StatusRequestEntityTooLarge {
|
||||
return fmt.Sprintf("request body exceeds %d KiB", systemOneMaxBody>>10)
|
||||
}
|
||||
return "invalid request body"
|
||||
}
|
||||
|
||||
// validateSystemOneRequest checks the structure every path needs, before the
|
||||
// request is forwarded to a decision model or run through the NER path. The
|
||||
// forwarded path never sees parseSystemOneRequest, so without this a malformed
|
||||
// question would surface as a backend error instead of a 400.
|
||||
func validateSystemOneRequest(req *schema.SystemOneRequest) error {
|
||||
if len(req.State) == 0 || string(req.State) == "null" {
|
||||
return fmt.Errorf("state is required")
|
||||
}
|
||||
var state any
|
||||
if err := json.Unmarshal(req.State, &state); err != nil {
|
||||
return fmt.Errorf("state is not valid JSON: %w", err)
|
||||
}
|
||||
if s, ok := state.(string); ok && strings.TrimSpace(s) == "" {
|
||||
return fmt.Errorf("state is required")
|
||||
}
|
||||
if len(req.Questions) == 0 {
|
||||
return fmt.Errorf("questions is required and must contain at least one question")
|
||||
}
|
||||
if len(req.Questions) > systemOneMaxQuestions {
|
||||
return fmt.Errorf("questions must contain at most %d questions", systemOneMaxQuestions)
|
||||
}
|
||||
qids := make([]string, 0, len(req.Questions))
|
||||
for id := range req.Questions {
|
||||
qids = append(qids, id)
|
||||
}
|
||||
sort.Strings(qids)
|
||||
for _, id := range qids {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("question ids must not be blank")
|
||||
}
|
||||
q := req.Questions[id]
|
||||
switch q.Type {
|
||||
case "choice":
|
||||
var criteria map[string]json.RawMessage
|
||||
if err := json.Unmarshal(q.Criteria, &criteria); err != nil {
|
||||
return fmt.Errorf("question %q (choice) requires a criteria object", id)
|
||||
}
|
||||
if len(criteria) < 2 {
|
||||
return fmt.Errorf("question %q (choice) requires at least 2 options", id)
|
||||
}
|
||||
for k := range criteria {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
return fmt.Errorf("question %q (choice) has a blank option key", id)
|
||||
}
|
||||
}
|
||||
case "score":
|
||||
var criteria []json.RawMessage
|
||||
if err := json.Unmarshal(q.Criteria, &criteria); err != nil {
|
||||
return fmt.Errorf("question %q (score) requires a criteria array", id)
|
||||
}
|
||||
if len(criteria) < 2 {
|
||||
return fmt.Errorf("question %q (score) requires at least 2 levels", id)
|
||||
}
|
||||
case "noul":
|
||||
if len(q.Criteria) == 0 || string(q.Criteria) == "null" {
|
||||
continue
|
||||
}
|
||||
var criteria map[string]json.RawMessage
|
||||
if err := json.Unmarshal(q.Criteria, &criteria); err != nil {
|
||||
return fmt.Errorf("question %q (noul) criteria must be an object with \"false\" and \"true\" descriptions", id)
|
||||
}
|
||||
for k := range criteria {
|
||||
if k != "false" && k != "true" {
|
||||
return fmt.Errorf("question %q (noul) criteria may only have \"false\" and \"true\" keys", id)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("question %q has unknown type: %s", id, q.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backendSupportsScore reports whether the named backend implements the
|
||||
// Score gRPC RPC. vllm-cpp does (kev/laya decision pipeline and cua-s1-forms
|
||||
// scoring via the unified vllm_decide C ABI); other backends fall through to
|
||||
@@ -402,18 +588,24 @@ func backendSupportsScore(backendName string) bool {
|
||||
func SystemOneEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req schema.SystemOneRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, "invalid request body")
|
||||
if err := systemOneBind(c, &req); err != nil {
|
||||
return systemOneError(c, systemOneBindStatus(err), systemOneBindMessage(err))
|
||||
}
|
||||
if req.Model == "" {
|
||||
return systemOneError(c, http.StatusBadRequest, "model is required")
|
||||
}
|
||||
if err := checkSystemOneModel(app, req.Model); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if err := validateSystemOneRequest(&req); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
// vllm-cpp models (kev/laya) implement the decision pipeline natively
|
||||
// via the vllm_decide C ABI. Forward the raw request JSON through the
|
||||
// Score RPC and return the backend's response as-is.
|
||||
cl := app.ModelConfigLoader()
|
||||
if cl != nil {
|
||||
if cfg, ok := cl.GetModelConfig(req.Model); ok && backendSupportsScore(cfg.Backend) {
|
||||
if cfg, ok := cl.GetModelConfig(req.Model); ok && systemOneUsesDecisionPipeline(cfg) {
|
||||
reqJSON, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return systemOneError(c, http.StatusInternalServerError, "failed to marshal request: "+err.Error())
|
||||
@@ -468,12 +660,21 @@ func SystemOneEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
func SystemOnePermuteEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req schema.SystemOnePermuteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, "invalid request body")
|
||||
if err := systemOneBind(c, &req); err != nil {
|
||||
return systemOneError(c, systemOneBindStatus(err), systemOneBindMessage(err))
|
||||
}
|
||||
if req.Request.Model == "" {
|
||||
return systemOneError(c, http.StatusBadRequest, "model is required")
|
||||
}
|
||||
if err := checkSystemOneModel(app, req.Request.Model); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if err := checkSystemOneNERModel(app, req.Request.Model); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if err := validateSystemOneRequest(&req.Request); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if req.Question == "" {
|
||||
return systemOneError(c, http.StatusBadRequest, "question is required")
|
||||
}
|
||||
@@ -604,12 +805,21 @@ func SystemOnePermuteEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
func SystemOneSeparateEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var req schema.SystemOneRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, "invalid request body")
|
||||
if err := systemOneBind(c, &req); err != nil {
|
||||
return systemOneError(c, systemOneBindStatus(err), systemOneBindMessage(err))
|
||||
}
|
||||
if req.Model == "" {
|
||||
return systemOneError(c, http.StatusBadRequest, "model is required")
|
||||
}
|
||||
if err := checkSystemOneModel(app, req.Model); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if err := checkSystemOneNERModel(app, req.Model); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if err := validateSystemOneRequest(&req); err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
parsed, err := parseSystemOneRequest(&req)
|
||||
if err != nil {
|
||||
return systemOneError(c, http.StatusBadRequest, err.Error())
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package localai
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("systemOneModelAllowed", func() {
|
||||
mk := func(usecases ...string) config.ModelConfig {
|
||||
return config.ModelConfig{
|
||||
Name: "m",
|
||||
Backend: "vllm-cpp",
|
||||
KnownUsecases: config.GetUsecasesFromYAML(usecases),
|
||||
}
|
||||
}
|
||||
|
||||
It("accepts a declared decisions model", func() {
|
||||
Expect(systemOneModelAllowed(mk("decisions"))).To(Succeed())
|
||||
})
|
||||
|
||||
It("accepts a token_classify model, which the NER path serves", func() {
|
||||
Expect(systemOneModelAllowed(mk("token_classify"))).To(Succeed())
|
||||
})
|
||||
|
||||
It("keeps configs that declare no usecases working", func() {
|
||||
Expect(systemOneModelAllowed(config.ModelConfig{Name: "laya", Backend: "vllm-cpp"})).To(Succeed())
|
||||
})
|
||||
|
||||
It("refuses a chat-only model with an actionable message", func() {
|
||||
Expect(systemOneModelAllowed(mk("chat"))).To(MatchError(ContainSubstring("known_usecases: [decisions]")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("systemone routing by model kind", func() {
|
||||
mk := func(backend string, usecases ...string) config.ModelConfig {
|
||||
c := config.ModelConfig{Name: "m", Backend: backend}
|
||||
if len(usecases) > 0 {
|
||||
c.KnownUsecases = config.GetUsecasesFromYAML(usecases)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
Describe("systemOneUsesDecisionPipeline", func() {
|
||||
It("sends a declared decision model to the decision pipeline", func() {
|
||||
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp", "decisions"))).To(BeTrue())
|
||||
})
|
||||
It("sends a token_classify model to the NER path, since vllm_decide refuses NER architectures", func() {
|
||||
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp", "token_classify"))).To(BeFalse())
|
||||
})
|
||||
It("keeps configs that declare nothing on the decision pipeline", func() {
|
||||
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp"))).To(BeTrue())
|
||||
})
|
||||
It("prefers the decision pipeline when both usecases are declared", func() {
|
||||
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp", "decisions", "token_classify"))).To(BeTrue())
|
||||
})
|
||||
It("never uses it for a backend without the Score RPC", func() {
|
||||
Expect(systemOneUsesDecisionPipeline(mk("no-such-backend", "decisions"))).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("systemOneNERAllowed", func() {
|
||||
It("refuses a decision model on the NER-only routes with an actionable message", func() {
|
||||
Expect(systemOneNERAllowed(mk("vllm-cpp", "decisions"))).To(MatchError(ContainSubstring("/v1/systemone")))
|
||||
})
|
||||
It("accepts a token_classify model", func() {
|
||||
Expect(systemOneNERAllowed(mk("vllm-cpp", "token_classify"))).To(Succeed())
|
||||
})
|
||||
It("accepts configs that declare nothing", func() {
|
||||
Expect(systemOneNERAllowed(mk("vllm-cpp"))).To(Succeed())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
package localai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("validateSystemOneRequest", func() {
|
||||
req := func(state string, questions string) *schema.SystemOneRequest {
|
||||
r := &schema.SystemOneRequest{Model: "m", State: json.RawMessage(state)}
|
||||
Expect(json.Unmarshal([]byte(questions), &r.Questions)).To(Succeed())
|
||||
return r
|
||||
}
|
||||
|
||||
It("accepts the three question types", func() {
|
||||
r := req(`"ticket text"`, `{
|
||||
"team": {"type":"choice","instructions":"which","criteria":{"a":"A","b":null}},
|
||||
"refund": {"type":"noul","instructions":"refund?","criteria":{"false":"No refund","true":"Refund asked"}},
|
||||
"urgency": {"type":"score","instructions":"how urgent","criteria":["low","high"]}
|
||||
}`)
|
||||
Expect(validateSystemOneRequest(r)).To(Succeed())
|
||||
})
|
||||
|
||||
It("accepts a noul question with no criteria", func() {
|
||||
Expect(validateSystemOneRequest(req(`"x"`, `{"q":{"type":"noul","instructions":"i"}}`))).To(Succeed())
|
||||
})
|
||||
|
||||
DescribeTable("refuses a malformed request with a message that names the problem",
|
||||
func(state, questions, want string) {
|
||||
Expect(validateSystemOneRequest(req(state, questions))).To(MatchError(ContainSubstring(want)))
|
||||
},
|
||||
Entry("missing state", ``, `{"q":{"type":"noul","instructions":"i"}}`, "state is required"),
|
||||
Entry("null state", `null`, `{"q":{"type":"noul","instructions":"i"}}`, "state is required"),
|
||||
Entry("blank string state", `" "`, `{"q":{"type":"noul","instructions":"i"}}`, "state is required"),
|
||||
Entry("no questions", `"x"`, `{}`, "at least one question"),
|
||||
Entry("blank question id", `"x"`, `{" ":{"type":"noul","instructions":"i"}}`, "blank"),
|
||||
Entry("unknown type", `"x"`, `{"q":{"type":"rank","instructions":"i"}}`, "unknown type"),
|
||||
Entry("choice with one option", `"x"`, `{"q":{"type":"choice","instructions":"i","criteria":{"a":"A"}}}`, "at least 2"),
|
||||
Entry("choice with a blank option key", `"x"`, `{"q":{"type":"choice","instructions":"i","criteria":{"a":"A"," ":"B"}}}`, "blank"),
|
||||
Entry("score with one level", `"x"`, `{"q":{"type":"score","instructions":"i","criteria":["only"]}}`, "at least 2"),
|
||||
Entry("noul criteria with a stray key", `"x"`, `{"q":{"type":"noul","instructions":"i","criteria":{"maybe":"M"}}}`, `"false" and "true"`),
|
||||
)
|
||||
|
||||
It("refuses more than 64 questions", func() {
|
||||
var b strings.Builder
|
||||
b.WriteString("{")
|
||||
for i := 0; i < 65; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
b.WriteString(`"q` + strings.Repeat("x", i) + `":{"type":"noul","instructions":"i"}`)
|
||||
}
|
||||
b.WriteString("}")
|
||||
Expect(validateSystemOneRequest(req(`"x"`, b.String()))).To(MatchError(ContainSubstring("at most 64")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("systemOneBind", func() {
|
||||
bind := func(body string) (int, error) {
|
||||
e := echo.New()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/systemone", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
c := e.NewContext(r, httptest.NewRecorder())
|
||||
var out schema.SystemOneRequest
|
||||
if err := systemOneBind(c, &out); err != nil {
|
||||
return systemOneBindStatus(err), err
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
It("binds a normal body", func() {
|
||||
status, err := bind(`{"model":"m","state":"x","questions":{}}`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("answers 413 for a body over 64 KiB", func() {
|
||||
status, err := bind(`{"model":"m","state":"` + strings.Repeat("a", 65*1024) + `"}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(status).To(Equal(http.StatusRequestEntityTooLarge))
|
||||
})
|
||||
|
||||
It("answers 400 for malformed JSON", func() {
|
||||
status, err := bind(`{not json`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(status).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
@@ -99,6 +99,7 @@ type fakeModel struct {
|
||||
transcribeDeltas []string
|
||||
transcribeFinal *schema.TranscriptionResult
|
||||
transcribeErr error
|
||||
lastDiarize bool // diarize flag of the last Transcribe/TranscribeStream call
|
||||
|
||||
// TranscribeLive scripting: liveErr makes the open fail (degrade path);
|
||||
// liveEvents are delivered to onEvent synchronously at open;
|
||||
@@ -200,7 +201,8 @@ func (m *fakeModel) VAD(_ context.Context, req *schema.VADRequest) (*schema.VADR
|
||||
return &schema.VADResponse{Segments: m.vadSegments}, nil
|
||||
}
|
||||
|
||||
func (m *fakeModel) Transcribe(context.Context, string, string, bool, bool, string) (*schema.TranscriptionResult, error) {
|
||||
func (m *fakeModel) Transcribe(_ context.Context, _, _ string, _, diarize bool, _ string) (*schema.TranscriptionResult, error) {
|
||||
m.lastDiarize = diarize
|
||||
return m.transcribeFinal, m.transcribeErr
|
||||
}
|
||||
|
||||
@@ -247,7 +249,8 @@ func (m *fakeModel) TTSStream(_ context.Context, _, _, _ string, onAudio func(pc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeModel) TranscribeStream(_ context.Context, _, _ string, _, _ bool, _ string, onDelta func(text string)) (*schema.TranscriptionResult, error) {
|
||||
func (m *fakeModel) TranscribeStream(_ context.Context, _, _ string, _, diarize bool, _ string, onDelta func(text string)) (*schema.TranscriptionResult, error) {
|
||||
m.lastDiarize = diarize
|
||||
for _, d := range m.transcribeDeltas {
|
||||
onDelta(d)
|
||||
}
|
||||
|
||||
@@ -209,6 +209,35 @@ func (l *liveTurnState) drainEvents(audioSec float64) {
|
||||
if ev.Final != nil && strings.TrimSpace(ev.Final.Text) != "" {
|
||||
l.finalText = ev.Final.Text
|
||||
}
|
||||
// Speaker and sound events from a companion diarization/scene
|
||||
// stream: forward each as its own event under the turn's item
|
||||
// id, same as caption deltas. Text is empty — the event exists
|
||||
// to carry the speaker/segment boundary, not transcript text.
|
||||
if l.transport != nil && l.itemID != "" {
|
||||
for _, seg := range ev.Speakers {
|
||||
sendEvent(l.transport, types.ConversationItemInputAudioTranscriptionSegmentEvent{
|
||||
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
|
||||
ItemID: l.itemID,
|
||||
ContentIndex: 0,
|
||||
Speaker: seg.Speaker,
|
||||
Start: seg.Start,
|
||||
End: seg.End,
|
||||
})
|
||||
}
|
||||
for _, sound := range ev.Sounds {
|
||||
start, end := sound.Start, sound.End
|
||||
sendEvent(l.transport, types.ConversationItemSoundDetectionEvent{
|
||||
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
|
||||
ItemID: l.itemID,
|
||||
ContentIndex: 0,
|
||||
Detections: []types.SoundDetectionTag{
|
||||
{Label: sound.Label, Score: sound.Peak, Index: sound.Index},
|
||||
},
|
||||
Start: &start,
|
||||
End: &end,
|
||||
})
|
||||
}
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,6 +291,67 @@ var _ = Describe("liveTurnState", func() {
|
||||
Expect(ftr.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionFailed)).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scene events (speakers and sounds)", func() {
|
||||
It("emits a segment event per speaker with empty text under the turn's item id", func() {
|
||||
Expect(lts.openTurn(context.Background(), "item1")).To(BeTrue())
|
||||
turnID := lts.itemID
|
||||
|
||||
m.liveSession.onEvent(backend.LiveTranscriptionEvent{
|
||||
Speakers: []backend.LiveSpeakerSegment{{Speaker: "1", Start: 1.2, End: 3.4}},
|
||||
})
|
||||
lts.drainEvents(3.4)
|
||||
|
||||
var got []types.ConversationItemInputAudioTranscriptionSegmentEvent
|
||||
for _, e := range ftr.events() {
|
||||
if seg, ok := e.(types.ConversationItemInputAudioTranscriptionSegmentEvent); ok {
|
||||
got = append(got, seg)
|
||||
}
|
||||
}
|
||||
Expect(got).To(HaveLen(1))
|
||||
Expect(got[0].ItemID).To(Equal(turnID))
|
||||
Expect(got[0].Speaker).To(Equal("1"))
|
||||
Expect(got[0].Start).To(BeNumerically("~", 1.2, 1e-9))
|
||||
Expect(got[0].End).To(BeNumerically("~", 3.4, 1e-9))
|
||||
Expect(got[0].Text).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("emits a sound_detection event per sound with one tag and start/end", func() {
|
||||
Expect(lts.openTurn(context.Background(), "item1")).To(BeTrue())
|
||||
turnID := lts.itemID
|
||||
|
||||
m.liveSession.onEvent(backend.LiveTranscriptionEvent{
|
||||
Sounds: []backend.LiveSoundEvent{{Label: "Dog bark", Index: 5, Peak: 0.8, Start: 0.5, End: 0.9}},
|
||||
})
|
||||
lts.drainEvents(1.0)
|
||||
|
||||
var got []types.ConversationItemSoundDetectionEvent
|
||||
for _, e := range ftr.events() {
|
||||
if sd, ok := e.(types.ConversationItemSoundDetectionEvent); ok {
|
||||
got = append(got, sd)
|
||||
}
|
||||
}
|
||||
Expect(got).To(HaveLen(1))
|
||||
Expect(got[0].ItemID).To(Equal(turnID))
|
||||
Expect(got[0].Detections).To(HaveLen(1))
|
||||
Expect(got[0].Detections[0].Label).To(Equal("Dog bark"))
|
||||
Expect(got[0].Detections[0].Score).To(BeNumerically("~", 0.8, 1e-6))
|
||||
Expect(got[0].Detections[0].Index).To(Equal(5))
|
||||
Expect(got[0].Start).NotTo(BeNil())
|
||||
Expect(*got[0].Start).To(BeNumerically("~", 0.5, 1e-9))
|
||||
Expect(got[0].End).NotTo(BeNil())
|
||||
Expect(*got[0].End).To(BeNumerically("~", 0.9, 1e-9))
|
||||
})
|
||||
|
||||
It("sends neither event when a live event carries no speakers or sounds", func() {
|
||||
Expect(lts.openTurn(context.Background(), "item1")).To(BeTrue())
|
||||
m.liveSession.onEvent(backend.LiveTranscriptionEvent{Delta: "hi"})
|
||||
lts.drainEvents(1.0)
|
||||
|
||||
Expect(ftr.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionSegment)).To(Equal(0))
|
||||
Expect(ftr.countEvents(types.ServerEventTypeConversationItemSoundDetection)).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// commitUtteranceWithTranscript routes the three transcript sources: the
|
||||
|
||||
@@ -3,6 +3,7 @@ package openai
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
@@ -14,6 +15,69 @@ import (
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
)
|
||||
|
||||
// ConversationItemSoundDetectionEvent gained optional Start/End (seconds)
|
||||
// for the live scene-event path; the unary/windowed paths never set them,
|
||||
// so existing consumers must see no start/end keys at all.
|
||||
var _ = Describe("ConversationItemSoundDetectionEvent JSON", func() {
|
||||
It("omits start and end when nil", func() {
|
||||
ev := types.ConversationItemSoundDetectionEvent{
|
||||
ItemID: "item1",
|
||||
Detections: []types.SoundDetectionTag{{Label: "Speech", Score: 0.5, Index: 7}},
|
||||
}
|
||||
b, err := json.Marshal(ev)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var got map[string]any
|
||||
Expect(json.Unmarshal(b, &got)).To(Succeed())
|
||||
_, hasStart := got["start"]
|
||||
_, hasEnd := got["end"]
|
||||
Expect(hasStart).To(BeFalse())
|
||||
Expect(hasEnd).To(BeFalse())
|
||||
})
|
||||
|
||||
It("includes start and end when set", func() {
|
||||
start, end := 0.5, 0.9
|
||||
ev := types.ConversationItemSoundDetectionEvent{
|
||||
ItemID: "item1",
|
||||
Start: &start,
|
||||
End: &end,
|
||||
}
|
||||
b, err := json.Marshal(ev)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var got map[string]any
|
||||
Expect(json.Unmarshal(b, &got)).To(Succeed())
|
||||
Expect(got["start"]).To(BeNumerically("~", 0.5, 1e-9))
|
||||
Expect(got["end"]).To(BeNumerically("~", 0.9, 1e-9))
|
||||
})
|
||||
})
|
||||
|
||||
// ConversationItemInputAudioTranscriptionSegmentEvent.Start/End are plain
|
||||
// float64 (no omitempty): a speaker segment starting at 0.0s must still
|
||||
// carry "start" in the JSON, unlike the sound-detection event's optional
|
||||
// pointer fields above.
|
||||
var _ = Describe("ConversationItemInputAudioTranscriptionSegmentEvent JSON", func() {
|
||||
It("marshals start:0 and end:1.5 even when start is the zero value", func() {
|
||||
ev := types.ConversationItemInputAudioTranscriptionSegmentEvent{
|
||||
ItemID: "item1",
|
||||
Speaker: "1",
|
||||
Start: 0,
|
||||
End: 1.5,
|
||||
}
|
||||
b, err := json.Marshal(ev)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var got map[string]any
|
||||
Expect(json.Unmarshal(b, &got)).To(Succeed())
|
||||
_, hasStart := got["start"]
|
||||
_, hasEnd := got["end"]
|
||||
Expect(hasStart).To(BeTrue())
|
||||
Expect(hasEnd).To(BeTrue())
|
||||
Expect(got["start"]).To(BeNumerically("~", 0.0, 1e-9))
|
||||
Expect(got["end"]).To(BeNumerically("~", 1.5, 1e-9))
|
||||
})
|
||||
})
|
||||
|
||||
// emitSoundDetection classifies a committed utterance and emits a single
|
||||
// conversation.item.sound_detection event carrying the scored AudioSet tags.
|
||||
var _ = Describe("emitSoundDetection", func() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
)
|
||||
|
||||
// emitPrecomputedTranscription emits the transcription events for a turn
|
||||
@@ -42,9 +43,10 @@ func emitPrecomputedTranscription(t Transport, itemID string, deltas []string, t
|
||||
// a single completed event. delta and completed events share itemID.
|
||||
func emitTranscription(ctx context.Context, t Transport, session *Session, itemID, audioPath string) (string, error) {
|
||||
cfg := session.InputAudioTranscription
|
||||
diarize := session.ModelConfig != nil && session.ModelConfig.Pipeline.Diarization
|
||||
|
||||
if session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamTranscription() {
|
||||
final, err := session.ModelInterface.TranscribeStream(ctx, audioPath, cfg.Language, false, false, cfg.Prompt, func(delta string) {
|
||||
final, err := session.ModelInterface.TranscribeStream(ctx, audioPath, cfg.Language, false, diarize, cfg.Prompt, func(delta string) {
|
||||
_ = t.SendEvent(types.ConversationItemInputAudioTranscriptionDeltaEvent{
|
||||
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
|
||||
ItemID: itemID,
|
||||
@@ -58,6 +60,11 @@ func emitTranscription(ctx context.Context, t Transport, session *Session, itemI
|
||||
transcript := ""
|
||||
if final != nil {
|
||||
transcript = final.Text
|
||||
if diarize {
|
||||
if err := emitSpeakerSegments(t, itemID, final); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := t.SendEvent(types.ConversationItemInputAudioTranscriptionCompletedEvent{
|
||||
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
|
||||
@@ -71,13 +78,18 @@ func emitTranscription(ctx context.Context, t Transport, session *Session, itemI
|
||||
}
|
||||
|
||||
// Unary fallback: transcribe the whole utterance, emit one completed event.
|
||||
tr, err := session.ModelInterface.Transcribe(ctx, audioPath, cfg.Language, false, false, cfg.Prompt)
|
||||
tr, err := session.ModelInterface.Transcribe(ctx, audioPath, cfg.Language, false, diarize, cfg.Prompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tr == nil {
|
||||
return "", fmt.Errorf("transcribe result is nil")
|
||||
}
|
||||
if diarize {
|
||||
if err := emitSpeakerSegments(t, itemID, tr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := t.SendEvent(types.ConversationItemInputAudioTranscriptionCompletedEvent{
|
||||
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
|
||||
ItemID: itemID,
|
||||
@@ -88,3 +100,29 @@ func emitTranscription(ctx context.Context, t Transport, session *Session, itemI
|
||||
}
|
||||
return tr.Text, nil
|
||||
}
|
||||
|
||||
// emitSpeakerSegments forwards each speaker-labelled segment of a committed
|
||||
// turn's transcript as a conversation.item.input_audio_transcription.segment
|
||||
// event (pipeline.diarization), before the turn's completed event. Times are
|
||||
// relative to the turn's audio and speaker labels are only consistent within
|
||||
// the turn, as on the live path. Segments without a speaker are skipped.
|
||||
func emitSpeakerSegments(t Transport, itemID string, tr *schema.TranscriptionResult) error {
|
||||
for _, seg := range tr.Segments {
|
||||
if seg.Speaker == "" {
|
||||
continue
|
||||
}
|
||||
if err := t.SendEvent(types.ConversationItemInputAudioTranscriptionSegmentEvent{
|
||||
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
|
||||
ItemID: itemID,
|
||||
ContentIndex: 0,
|
||||
ID: fmt.Sprintf("seg_%d", seg.Id),
|
||||
Speaker: seg.Speaker,
|
||||
Start: seg.Start.Seconds(),
|
||||
End: seg.End.Seconds(),
|
||||
Text: seg.Text,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -51,4 +52,86 @@ var _ = Describe("emitTranscription", func() {
|
||||
Expect(t.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionDelta)).To(Equal(0))
|
||||
Expect(t.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionCompleted)).To(Equal(1))
|
||||
})
|
||||
|
||||
Context("pipeline.diarization", func() {
|
||||
labelled := &schema.TranscriptionResult{
|
||||
Text: "hi there. hello",
|
||||
Segments: []schema.TranscriptionSegment{
|
||||
{Id: 0, Text: "hi there.", Start: 0, End: 600 * time.Millisecond, Speaker: "0"},
|
||||
{Id: 1, Text: "hello", Start: time.Second, End: 1400 * time.Millisecond, Speaker: "1"},
|
||||
{Id: 2, Text: "unlabelled"},
|
||||
},
|
||||
}
|
||||
|
||||
segmentEvents := func(t *fakeTransport) []types.ConversationItemInputAudioTranscriptionSegmentEvent {
|
||||
var out []types.ConversationItemInputAudioTranscriptionSegmentEvent
|
||||
for _, e := range t.sent {
|
||||
if seg, ok := e.(types.ConversationItemInputAudioTranscriptionSegmentEvent); ok {
|
||||
out = append(out, seg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
It("requests speakers and emits one segment event per labelled segment", func() {
|
||||
m := &fakeModel{transcribeFinal: labelled}
|
||||
session := &Session{
|
||||
InputAudioTranscription: &types.AudioTranscription{},
|
||||
ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Diarization: true}},
|
||||
ModelInterface: m,
|
||||
}
|
||||
t := &fakeTransport{}
|
||||
|
||||
transcript, err := emitTranscription(context.Background(), t, session, "item1", "/tmp/x.wav")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(transcript).To(Equal("hi there. hello"))
|
||||
Expect(m.lastDiarize).To(BeTrue())
|
||||
segs := segmentEvents(t)
|
||||
Expect(segs).To(HaveLen(2))
|
||||
Expect(segs[0].ItemID).To(Equal("item1"))
|
||||
Expect(segs[0].Speaker).To(Equal("0"))
|
||||
Expect(segs[0].Text).To(Equal("hi there."))
|
||||
Expect(segs[1].Speaker).To(Equal("1"))
|
||||
Expect(segs[1].Start).To(BeNumerically("~", 1.0, 1e-9))
|
||||
Expect(segs[1].End).To(BeNumerically("~", 1.4, 1e-9))
|
||||
Expect(t.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionCompleted)).To(Equal(1))
|
||||
})
|
||||
|
||||
It("also emits segment events on the streaming transcription path", func() {
|
||||
on := true
|
||||
m := &fakeModel{transcribeDeltas: []string{"hi"}, transcribeFinal: labelled}
|
||||
session := &Session{
|
||||
InputAudioTranscription: &types.AudioTranscription{},
|
||||
ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{
|
||||
Diarization: true,
|
||||
Streaming: config.PipelineStreaming{Transcription: &on},
|
||||
}},
|
||||
ModelInterface: m,
|
||||
}
|
||||
t := &fakeTransport{}
|
||||
|
||||
_, err := emitTranscription(context.Background(), t, session, "item1", "/tmp/x.wav")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.lastDiarize).To(BeTrue())
|
||||
Expect(segmentEvents(t)).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("neither asks for speakers nor emits segments when off", func() {
|
||||
m := &fakeModel{transcribeFinal: labelled}
|
||||
session := &Session{
|
||||
InputAudioTranscription: &types.AudioTranscription{},
|
||||
ModelConfig: &config.ModelConfig{},
|
||||
ModelInterface: m,
|
||||
}
|
||||
t := &fakeTransport{}
|
||||
|
||||
_, err := emitTranscription(context.Background(), t, session, "item1", "/tmp/x.wav")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.lastDiarize).To(BeFalse())
|
||||
Expect(segmentEvents(t)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -210,18 +210,20 @@ func TranscriptEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, app
|
||||
}
|
||||
for _, word := range tr.Words {
|
||||
trs.Words = append(trs.Words, schema.TranscriptionWordSeconds{
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Speaker: word.Speaker,
|
||||
})
|
||||
}
|
||||
for _, seg := range tr.Segments {
|
||||
segWords := []schema.TranscriptionWordSeconds{}
|
||||
for _, word := range seg.Words {
|
||||
segWords = append(segWords, schema.TranscriptionWordSeconds{
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Start: word.Start.Seconds(),
|
||||
End: word.End.Seconds(),
|
||||
Text: word.Text,
|
||||
Speaker: word.Speaker,
|
||||
})
|
||||
}
|
||||
trs.Segments = append(trs.Segments, schema.TranscriptionSegmentSeconds{
|
||||
@@ -338,12 +340,16 @@ func streamTranscription(c echo.Context, req backend.TranscriptionRequest, ml *m
|
||||
if len(finalResult.Segments) > 0 {
|
||||
segs := make([]map[string]any, 0, len(finalResult.Segments))
|
||||
for _, seg := range finalResult.Segments {
|
||||
segs = append(segs, map[string]any{
|
||||
entry := map[string]any{
|
||||
"id": seg.Id,
|
||||
"start": seg.Start.Seconds(),
|
||||
"end": seg.End.Seconds(),
|
||||
"text": seg.Text,
|
||||
})
|
||||
}
|
||||
if seg.Speaker != "" {
|
||||
entry["speaker"] = seg.Speaker
|
||||
}
|
||||
segs = append(segs, entry)
|
||||
}
|
||||
doneEvent["segments"] = segs
|
||||
}
|
||||
|
||||
@@ -512,6 +512,15 @@ type ConversationItemSoundDetectionEvent struct {
|
||||
|
||||
// The scored sound-event tags, in score-descending order.
|
||||
Detections []SoundDetectionTag `json:"detections"`
|
||||
|
||||
// The start time of the detection window in seconds, when known. Set by
|
||||
// the live scene-event path (a companion sound stream alongside live
|
||||
// transcription); omitted by the unary/windowed sound-detection paths,
|
||||
// which have no per-event timing.
|
||||
Start *float64 `json:"start,omitempty"`
|
||||
|
||||
// The end time of the detection window in seconds, when known.
|
||||
End *float64 `json:"end,omitempty"`
|
||||
}
|
||||
|
||||
func (m ConversationItemSoundDetectionEvent) ServerEventType() ServerEventType {
|
||||
@@ -586,11 +595,13 @@ type ConversationItemInputAudioTranscriptionSegmentEvent struct {
|
||||
// The speaker label for the segment, if available.
|
||||
Speaker string `json:"speaker,omitempty"`
|
||||
|
||||
// The start time of the segment in seconds.
|
||||
Start float64 `json:"start,omitempty"`
|
||||
// The start time of the segment in seconds. Always present (not
|
||||
// omitempty: a segment starting at 0.0s must still carry "start").
|
||||
Start float64 `json:"start"`
|
||||
|
||||
// The end time of the segment in seconds.
|
||||
End float64 `json:"end,omitempty"`
|
||||
// The end time of the segment in seconds. Always present (not
|
||||
// omitempty: see Start).
|
||||
End float64 `json:"end"`
|
||||
|
||||
// The text content of the segment.
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Single-page PDF with a real text layer, built byte by byte so the xref
|
||||
// offsets are valid and the spec needs no binary fixture.
|
||||
function buildPdf(text) {
|
||||
const stream = `BT /F1 18 Tf 20 100 Td (${text}) Tj ET`
|
||||
const objs = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
|
||||
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
]
|
||||
let out = '%PDF-1.4\n'
|
||||
const offsets = []
|
||||
objs.forEach((body, i) => {
|
||||
offsets.push(out.length)
|
||||
out += `${i + 1} 0 obj\n${body}\nendobj\n`
|
||||
})
|
||||
const xref = out.length
|
||||
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`
|
||||
for (const o of offsets) out += `${String(o).padStart(10, '0')} 00000 n \n`
|
||||
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
|
||||
return Buffer.from(out, 'latin1')
|
||||
}
|
||||
|
||||
async function openChat(page) {
|
||||
await page.route('**/api/models/capabilities', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }),
|
||||
})
|
||||
})
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
test.describe('Chat - PDF attachments', () => {
|
||||
test('sends the extracted text layer, not the raw PDF bytes', async ({ page }) => {
|
||||
let requestBody = ''
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
requestBody = route.request().postData() || ''
|
||||
route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'stop' } }) })
|
||||
})
|
||||
await openChat(page)
|
||||
|
||||
await page.locator('input[type=file]').setInputFiles({
|
||||
name: 'report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: buildPdf('Quarterly revenue grew 42 percent'),
|
||||
})
|
||||
await expect(page.locator('.chat-file-name', { hasText: 'report.pdf' })).toBeVisible()
|
||||
|
||||
await page.locator('.chat-input').fill('Summarize')
|
||||
await page.locator('.chat-send-btn').click()
|
||||
|
||||
await expect.poll(() => requestBody).toContain('Quarterly revenue grew 42 percent')
|
||||
expect(requestBody).toContain('File: report.pdf')
|
||||
expect(requestBody).not.toContain('%PDF')
|
||||
})
|
||||
|
||||
test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => {
|
||||
await openChat(page)
|
||||
|
||||
await page.locator('input[type=file]').setInputFiles({
|
||||
name: 'broken.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('%PDF-1.4 this is not a real document'),
|
||||
})
|
||||
|
||||
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('.chat-file-name', { hasText: 'broken.pdf' })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Home - PDF attachments', () => {
|
||||
test('attaches a PDF that has a text layer', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
|
||||
name: 'notes.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: buildPdf('Meeting notes for Tuesday'),
|
||||
})
|
||||
await expect(page.locator('.home-file-tag', { hasText: 'notes.pdf' })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('rejects a PDF that cannot be parsed', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
|
||||
name: 'broken.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('%PDF-1.4 this is not a real document'),
|
||||
})
|
||||
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('.home-file-tag')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -172,6 +172,19 @@ test.describe('Models lifecycle', () => {
|
||||
await expect(installedPane(page)).toContainText('Worker one')
|
||||
})
|
||||
|
||||
test('shows the decisions use case on a decision model', async ({ page }) => {
|
||||
await page.route('**/api/models/capabilities', route => route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [...installedModels, { id: 'decider', backend: 'vllm-cpp', capabilities: ['FLAG_DECISIONS'] }],
|
||||
}),
|
||||
}))
|
||||
await page.goto('/app/models?view=installed&model=decider')
|
||||
|
||||
await expect(installedPane(page)).toContainText('decider')
|
||||
await expect(installedPane(page)).toContainText('Decisions')
|
||||
})
|
||||
|
||||
test('stops a running model with confirmation', async ({ page }) => {
|
||||
await page.goto('/app/models?view=installed&model=alpha')
|
||||
|
||||
|
||||
Generated
+271
@@ -29,6 +29,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
"marked": "^15.0.7",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.6",
|
||||
@@ -1021,6 +1022,256 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -5219,6 +5470,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/node-readable-to-web-readable-stream": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.54",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||
@@ -5752,6 +6010,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.6.205",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
|
||||
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.96",
|
||||
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
"marked": "^15.0.7",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.6",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
"copied": "Copied to clipboard",
|
||||
"copyFailed": "Could not copy to clipboard",
|
||||
"chatCopied": "Chat copied to clipboard",
|
||||
"forked": "Created a new chat"
|
||||
"forked": "Created a new chat",
|
||||
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged."
|
||||
},
|
||||
"menu": {
|
||||
"trigger": "Chats",
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"enterToSend": "Enter to send",
|
||||
"selectModelFirst": "Select a model first",
|
||||
"sendMessage": "Send message",
|
||||
"selectModelToast": "Please select a model first"
|
||||
"selectModelToast": "Please select a model first",
|
||||
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged."
|
||||
},
|
||||
"quickLinks": {
|
||||
"manageByChat": "Manage by chat",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"open": {
|
||||
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
|
||||
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score"
|
||||
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/arti
|
||||
import CanvasPanel from '../components/CanvasPanel'
|
||||
import Toggle from '../components/Toggle'
|
||||
import { fileToBase64, modelsApi, mcpApi } from '../utils/api'
|
||||
import { readAttachmentText } from '../utils/pdf'
|
||||
import { CAP_CHAT } from '../utils/capabilities'
|
||||
import { useMCPClient } from '../hooks/useMCPClient'
|
||||
import MCPAppFrame from '../components/MCPAppFrame'
|
||||
@@ -842,13 +843,18 @@ export default function Chat() {
|
||||
const base64 = await fileToBase64(file)
|
||||
const entry = { name: file.name, type: file.type, base64 }
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) {
|
||||
entry.textContent = await file.text().catch(() => '')
|
||||
try {
|
||||
entry.textContent = await readAttachmentText(file)
|
||||
} catch {
|
||||
addToast(t('toasts.pdfReadFailed', { name: file.name }), 'error')
|
||||
continue
|
||||
}
|
||||
}
|
||||
newFiles.push(entry)
|
||||
}
|
||||
setFiles(prev => [...prev, ...newFiles])
|
||||
e.target.value = ''
|
||||
}, [])
|
||||
}, [addToast, t])
|
||||
|
||||
const handlePaste = useCallback(async (e) => {
|
||||
const items = e.clipboardData?.items
|
||||
|
||||
@@ -12,6 +12,7 @@ import HomeConnect from '../components/HomeConnect'
|
||||
import { useResources } from '../hooks/useResources'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
|
||||
import { readAttachmentText } from '../utils/pdf'
|
||||
import { API_CONFIG } from '../utils/config'
|
||||
import { greetingKey } from '../utils/greeting'
|
||||
import StatusPill from '../components/StatusPill'
|
||||
@@ -158,12 +159,17 @@ export default function Home() {
|
||||
const base64 = await fileToBase64(file)
|
||||
const entry = { name: file.name, type: file.type, base64 }
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) {
|
||||
entry.textContent = await file.text().catch(() => '')
|
||||
try {
|
||||
entry.textContent = await readAttachmentText(file)
|
||||
} catch {
|
||||
addToast(t('input.pdfReadFailed', { name: file.name }), 'error')
|
||||
continue
|
||||
}
|
||||
}
|
||||
newFiles.push(entry)
|
||||
}
|
||||
setter(prev => [...prev, ...newFiles])
|
||||
}, [])
|
||||
}, [addToast, t])
|
||||
|
||||
const removeFile = useCallback((file) => {
|
||||
const removeFn = (prev) => prev.filter(f => f !== file)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS,
|
||||
CAP_TRANSCRIPT, CAP_SOUND_GENERATION, CAP_FACE_RECOGNITION,
|
||||
CAP_SPEAKER_RECOGNITION, CAP_EMBEDDINGS, CAP_RERANK,
|
||||
CAP_VAD, CAP_SCORE,
|
||||
CAP_VAD, CAP_SCORE, CAP_DECISIONS,
|
||||
} from '../utils/capabilities'
|
||||
|
||||
const USE_CASES = [
|
||||
@@ -39,6 +39,7 @@ const USE_CASES = [
|
||||
{ cap: CAP_RERANK, labelKey: 'rerank' },
|
||||
{ cap: CAP_VAD, labelKey: 'vad' },
|
||||
{ cap: CAP_SCORE, labelKey: 'score' },
|
||||
{ cap: CAP_DECISIONS, labelKey: 'decisions' },
|
||||
]
|
||||
|
||||
export function modelUseCases(model) {
|
||||
|
||||
@@ -29,4 +29,5 @@ export const CAP_SPEAKER_RECOGNITION = 'FLAG_SPEAKER_RECOGNITION'
|
||||
export const CAP_AUDIO_TRANSFORM = 'FLAG_AUDIO_TRANSFORM'
|
||||
export const CAP_REALTIME_AUDIO = 'FLAG_REALTIME_AUDIO'
|
||||
export const CAP_SCORE = 'FLAG_SCORE'
|
||||
export const CAP_DECISIONS = 'FLAG_DECISIONS'
|
||||
export const CAP_TOKEN_CLASSIFY = 'FLAG_TOKEN_CLASSIFY'
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
export function isPdf(file) {
|
||||
return file?.type === 'application/pdf' || /\.pdf$/i.test(file?.name || '')
|
||||
}
|
||||
|
||||
// pdf.js and its worker are loaded on first use so the main bundle does not
|
||||
// pay for them when nobody attaches a PDF.
|
||||
async function loadPdfjs() {
|
||||
const [pdfjs, worker] = await Promise.all([
|
||||
import('pdfjs-dist'),
|
||||
import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
|
||||
])
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = worker.default
|
||||
return pdfjs
|
||||
}
|
||||
|
||||
// Returns the text layer of every page, one block per page. Throws when the
|
||||
// file cannot be parsed or has no text layer (scanned PDFs): sending an empty
|
||||
// attachment to the model would look like success and silently lose the file.
|
||||
export async function extractPdfText(file) {
|
||||
const pdfjs = await loadPdfjs()
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
const doc = await pdfjs.getDocument({ data }).promise
|
||||
try {
|
||||
const pages = []
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i)
|
||||
const content = await page.getTextContent()
|
||||
let text = ''
|
||||
for (const item of content.items) {
|
||||
text += item.str
|
||||
text += item.hasEOL ? '\n' : ''
|
||||
}
|
||||
pages.push(text.trim())
|
||||
}
|
||||
const text = pages.filter(Boolean).join('\n\n')
|
||||
if (!text) throw new Error('PDF has no extractable text')
|
||||
return text
|
||||
} finally {
|
||||
await doc.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Text of an attached non-media file. PDFs go through pdf.js; everything else
|
||||
// is read as UTF-8.
|
||||
export async function readAttachmentText(file) {
|
||||
if (isPdf(file)) return extractPdfText(file)
|
||||
return file.text().catch(() => '')
|
||||
}
|
||||
@@ -13,9 +13,10 @@ type TranscriptionSegment struct {
|
||||
}
|
||||
|
||||
type TranscriptionWord struct {
|
||||
Start time.Duration `json:"start"`
|
||||
End time.Duration `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Start time.Duration `json:"start"`
|
||||
End time.Duration `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Speaker string `json:"speaker,omitempty"`
|
||||
}
|
||||
|
||||
type TranscriptionResult struct {
|
||||
@@ -42,9 +43,10 @@ type TranscriptionSegmentSeconds struct {
|
||||
}
|
||||
|
||||
type TranscriptionWordSeconds struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Speaker string `json:"speaker,omitempty"`
|
||||
}
|
||||
|
||||
type TranscriptionResultSeconds struct {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package agentpool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/services/agentpool"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type fakeLLMRequest struct {
|
||||
Model string
|
||||
Stream bool
|
||||
Messages []map[string]any
|
||||
Tools []string
|
||||
ToolChoice any
|
||||
}
|
||||
|
||||
// fakeLLM is an OpenAI-compatible chat endpoint. The standalone pool reaches its
|
||||
// LLM over HTTP (apiURL), so a real server is the only seam that exercises the
|
||||
// whole agent loop without a model.
|
||||
type fakeLLM struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
reply string
|
||||
requests []fakeLLMRequest
|
||||
toolName string
|
||||
toolArgs string
|
||||
// failStatus, when non-zero, makes every chat completion fail with that
|
||||
// HTTP status so a spec can drive the agent's error path.
|
||||
failStatus int
|
||||
}
|
||||
|
||||
func newFakeLLM(reply string) *fakeLLM {
|
||||
f := &fakeLLM{reply: reply}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(f.handle))
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeLLM) URL() string { return f.srv.URL }
|
||||
func (f *fakeLLM) Close() { f.srv.Close() }
|
||||
|
||||
func (f *fakeLLM) SetReply(r string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.reply = r
|
||||
}
|
||||
|
||||
// SetToolCall makes the fake answer with one call to the named function until
|
||||
// the conversation carries a tool result, then with the plain reply. Keying on
|
||||
// the tool message rather than a request counter keeps the fake independent of
|
||||
// how many planning requests the agent makes before it runs the tool. Only
|
||||
// the LocalAGI counter-action specs use it today; P2 keeps it for the MCP
|
||||
// tool fixture that replaces them, since the native executor ignores
|
||||
// Actions. The
|
||||
// flip side: tool mode stays on until a request carries a role "tool"
|
||||
// message, so a client that restarts with trimmed history would get the
|
||||
// tool call again and loop until its iteration cap.
|
||||
func (f *fakeLLM) SetToolCall(name, argsJSON string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.toolName = name
|
||||
f.toolArgs = argsJSON
|
||||
}
|
||||
|
||||
// SetFailure makes every chat completion answer with the given HTTP status and
|
||||
// an OpenAI-style error body, which is what an unreachable or broken backend
|
||||
// looks like to the agent. Requests are still recorded so a spec can count
|
||||
// retries.
|
||||
func (f *fakeLLM) SetFailure(status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.failStatus = status
|
||||
}
|
||||
|
||||
func (f *fakeLLM) Requests() []fakeLLMRequest {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]fakeLLMRequest(nil), f.requests...)
|
||||
}
|
||||
|
||||
func (f *fakeLLM) handle(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
Messages []map[string]any `json:"messages"`
|
||||
Tools []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
} `json:"tools"`
|
||||
ToolChoice any `json:"tool_choice"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
|
||||
var tools []string
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, t.Function.Name)
|
||||
}
|
||||
hasToolResult := false
|
||||
for _, m := range req.Messages {
|
||||
if m["role"] == "tool" {
|
||||
hasToolResult = true
|
||||
}
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.requests = append(f.requests, fakeLLMRequest{Model: req.Model, Stream: req.Stream, Messages: req.Messages, Tools: tools, ToolChoice: req.ToolChoice})
|
||||
reply := f.reply
|
||||
failStatus := f.failStatus
|
||||
var toolCall map[string]any
|
||||
if f.toolName != "" && !hasToolResult {
|
||||
toolCall = map[string]any{
|
||||
"index": 0, "id": "call_fake", "type": "function",
|
||||
"function": map[string]any{"name": f.toolName, "arguments": f.toolArgs},
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
if failStatus != 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(failStatus)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{"message": "fake backend failure", "type": "server_error", "code": failStatus},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message := map[string]any{"role": "assistant", "content": reply}
|
||||
finish := "stop"
|
||||
if toolCall != nil {
|
||||
// "index" belongs only to streaming deltas, so the non-streaming
|
||||
// message carries a copy without it.
|
||||
plain := map[string]any{}
|
||||
for k, v := range toolCall {
|
||||
if k != "index" {
|
||||
plain[k] = v
|
||||
}
|
||||
}
|
||||
message = map[string]any{"role": "assistant", "content": "", "tool_calls": []map[string]any{plain}}
|
||||
finish = "tool_calls"
|
||||
}
|
||||
|
||||
if !req.Stream {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-fake", "object": "chat.completion", "model": req.Model,
|
||||
"choices": []map[string]any{{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finish,
|
||||
}},
|
||||
"usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
// A write error means the client went away mid-stream; the handler just
|
||||
// stops writing so a disconnect never blocks or fails the fake.
|
||||
write := func(s string) bool {
|
||||
_, err := io.WriteString(w, s)
|
||||
return err == nil
|
||||
}
|
||||
chunk := func(delta map[string]any, finish any) bool {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"id": "chatcmpl-fake", "object": "chat.completion.chunk", "model": req.Model,
|
||||
"choices": []map[string]any{{"index": 0, "delta": delta, "finish_reason": finish}},
|
||||
})
|
||||
return write(fmt.Sprintf("data: %s\n\n", b))
|
||||
}
|
||||
first := map[string]any{"role": "assistant", "content": reply}
|
||||
if toolCall != nil {
|
||||
first = map[string]any{"role": "assistant", "tool_calls": []map[string]any{toolCall}}
|
||||
}
|
||||
if !chunk(first, nil) || !chunk(map[string]any{}, finish) || !write("data: [DONE]\n\n") {
|
||||
return
|
||||
}
|
||||
if fl, ok := w.(http.Flusher); ok {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// startStandalone boots a real standalone AgentPoolService on stateDir with its
|
||||
// LLM pointed at llmURL. It registers no cleanup itself: callers own Stop().
|
||||
func startStandalone(stateDir, llmURL string) *agentpool.AgentPoolService {
|
||||
cfg := config.NewApplicationConfig()
|
||||
cfg.AgentPool = config.AgentPoolConfig{
|
||||
Enabled: true,
|
||||
StateDir: stateDir,
|
||||
APIURL: llmURL,
|
||||
DefaultModel: "fake-model",
|
||||
Timeout: "30s",
|
||||
}
|
||||
svc, err := agentpool.NewAgentPoolService(cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc.Start(context.Background())).To(Succeed())
|
||||
return svc
|
||||
}
|
||||
|
||||
func newAgentConfig(name string) *state.AgentConfig {
|
||||
return &state.AgentConfig{
|
||||
Name: name,
|
||||
Model: "fake-model",
|
||||
Description: "contract test agent",
|
||||
SystemPrompt: "You are a test agent.",
|
||||
}
|
||||
}
|
||||
|
||||
// Engine-specific: awaitRunning and collectSSE reach into LocalAGI types
|
||||
// (agent.Agent via GetAgentForUser, types.NewJob, sse.Manager and
|
||||
// sse.NewClient). P1 must re-seat them when LocalAGI types leave the service
|
||||
// signatures; the specs that call them should not need to change.
|
||||
|
||||
// awaitRunning blocks until the agent's Run loop is serving jobs. The pool
|
||||
// starts Run in a goroutine and LocalAGI's Scheduler.Start and Scheduler.Stop
|
||||
// are unsynchronized: a Stop (update, delete, svc.Stop) that lands while Start
|
||||
// is still running can nil the scheduler context under the poll goroutine and
|
||||
// crash the test binary. Run starts its workers only after Scheduler.Start has
|
||||
// returned, and jobQueue is unbuffered, so Execute returning proves Start is
|
||||
// done. The job's context is already cancelled, so the worker finishes it as
|
||||
// expired without calling the LLM or recording an observable. This depends on
|
||||
// LocalAGI not short-circuiting a cancelled job before a worker receives it,
|
||||
// so re-check it when LocalAGI is bumped; the timeout turns a hang into a
|
||||
// failure if that ever changes.
|
||||
func awaitRunning(svc *agentpool.AgentPoolService, userID, name string) {
|
||||
a := svc.GetAgentForUser(userID, name)
|
||||
Expect(a).ToNot(BeNil())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
a.Execute(types.NewJob(types.WithContext(ctx)))
|
||||
}()
|
||||
Eventually(done, "10s").Should(BeClosed())
|
||||
}
|
||||
|
||||
type sseEvent struct {
|
||||
Name string
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
// collectSSE registers a listener on the agent's SSE manager and records every
|
||||
// event until stop is called. Subscribe before Chat so nothing is missed.
|
||||
// The manager replays its last 10 events on Register, so a spec that chats
|
||||
// twice with a fresh collector sees the first turn's completed status too.
|
||||
func collectSSE(svc *agentpool.AgentPoolService, userID, name string) (events func() []sseEvent, stop func()) {
|
||||
mgr := svc.GetSSEManagerForUser(userID, name)
|
||||
Expect(mgr).ToNot(BeNil())
|
||||
// Include the user: the manager keys listeners by ID, so two users'
|
||||
// same-named agents must never share one if a manager is ever shared.
|
||||
client := sse.NewClient("contract-" + userID + "-" + name)
|
||||
mgr.Register(client)
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []sseEvent
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case env, ok := <-client.Chan():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ev := sseEvent{}
|
||||
for _, line := range strings.Split(env.String(), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event:"):
|
||||
ev.Name = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
// The parse error is ignored because the hud event's
|
||||
// data is not JSON; those events keep only their name.
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &ev.Data)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
got = append(got, ev)
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() []sseEvent {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]sseEvent(nil), got...)
|
||||
}, func() {
|
||||
close(done)
|
||||
mgr.Unregister(client.ID())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package agentpool_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/agentpool"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("standalone chat contract", func() {
|
||||
var (
|
||||
llm *fakeLLM
|
||||
svc *agentpool.AgentPoolService
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
llm = newFakeLLM("pong")
|
||||
// Ginkgo runs cleanups LIFO: registering Close first stops the pool
|
||||
// before its LLM goes away.
|
||||
DeferCleanup(llm.Close)
|
||||
svc = startStandalone(GinkgoT().TempDir(), llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("chatty"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "chatty")
|
||||
})
|
||||
|
||||
It("streams the user message, processing, agent reply and completed status over SSE", func() {
|
||||
events, stop := collectSSE(svc, "alice", "chatty")
|
||||
defer stop()
|
||||
|
||||
msgID, err := svc.ChatForUser("alice", "chatty", "ping")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(msgID).ToNot(BeEmpty())
|
||||
|
||||
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "30s", "100ms").
|
||||
ShouldNot(BeEmpty(), "no completed status event")
|
||||
|
||||
var user, agent, processing bool
|
||||
for _, e := range events() {
|
||||
switch {
|
||||
case e.Name == "json_message" && e.Data["sender"] == "user":
|
||||
Expect(e.Data["content"]).To(Equal("ping"))
|
||||
user = true
|
||||
case e.Name == "json_message" && e.Data["sender"] == "agent":
|
||||
Expect(e.Data["content"]).To(ContainSubstring("pong"))
|
||||
// Current standalone shape: the reply id is the id ChatForUser
|
||||
// returned plus "-agent". The UI correlates on message_id
|
||||
// (AgentChat.jsx), which the distributed dispatcher sends; a
|
||||
// native engine may send either, so flip this deliberately.
|
||||
Expect(e.Data).To(HaveKeyWithValue("id", msgID+"-agent"))
|
||||
agent = true
|
||||
case e.Name == "json_message_status" && e.Data["status"] == "processing":
|
||||
processing = true
|
||||
}
|
||||
}
|
||||
Expect(user).To(BeTrue(), "user json_message")
|
||||
Expect(processing).To(BeTrue(), "processing status")
|
||||
Expect(agent).To(BeTrue(), "agent json_message")
|
||||
})
|
||||
|
||||
It("sends the user's message to the LLM under the configured model", func() {
|
||||
_, err := svc.ChatForUser("alice", "chatty", "ping")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(llm.Requests, "30s", "100ms").ShouldNot(BeEmpty())
|
||||
req := llm.Requests()[0]
|
||||
Expect(req.Model).To(Equal("fake-model"))
|
||||
// Observed shape: content is a plain string, not a parts array. A
|
||||
// switch to parts would change what an OpenAI-compatible backend sees.
|
||||
Expect(req.Messages).To(ContainElement(And(
|
||||
HaveKeyWithValue("role", "user"),
|
||||
HaveKeyWithValue("content", "ping"),
|
||||
)))
|
||||
})
|
||||
|
||||
// The chat page clears its "processing" state only on an agent
|
||||
// json_message or a json_error, so a failed turn must end in json_error
|
||||
// followed by completed, never in silence. The fake fails every request;
|
||||
// cogito retries the decision 5 times with a linear 1s..5s backoff, so the
|
||||
// turn settles after about 15s, hence the 60s budget. The error text is
|
||||
// cogito's wrapped chain and is not pinned.
|
||||
It("reports a failing LLM as json_error then completed, with no agent reply", func() {
|
||||
llm.SetFailure(http.StatusInternalServerError)
|
||||
events, stop := collectSSE(svc, "alice", "chatty")
|
||||
defer stop()
|
||||
|
||||
_, err := svc.ChatForUser("alice", "chatty", "ping")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "60s", "100ms").
|
||||
ShouldNot(BeEmpty(), "no completed status event")
|
||||
|
||||
errorAt, completedAt := -1, -1
|
||||
for i, e := range events() {
|
||||
switch {
|
||||
case e.Name == "json_error" && errorAt < 0:
|
||||
errorAt = i
|
||||
Expect(e.Data).To(HaveKeyWithValue("error", And(BeAssignableToTypeOf(""), Not(BeEmpty()))))
|
||||
case e.Name == "json_message_status" && e.Data["status"] == "completed" && completedAt < 0:
|
||||
completedAt = i
|
||||
case e.Name == "json_message" && e.Data["sender"] == "agent":
|
||||
Fail("a failed turn must not produce an agent json_message")
|
||||
}
|
||||
}
|
||||
Expect(errorAt).To(BeNumerically(">=", 0), "no json_error event")
|
||||
Expect(errorAt).To(BeNumerically("<", completedAt), "json_error must precede completed")
|
||||
Expect(llm.Requests()).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("reports chat with an unknown agent as ErrAgentNotFound", func() {
|
||||
_, err := svc.ChatForUser("alice", "ghost", "hi")
|
||||
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
|
||||
})
|
||||
|
||||
It("reports chat with a deleted agent as ErrAgentNotFound", func() {
|
||||
Expect(svc.DeleteAgentForUser("alice", "chatty")).To(Succeed())
|
||||
_, err := svc.ChatForUser("alice", "chatty", "hi")
|
||||
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
|
||||
})
|
||||
|
||||
It("does not deliver one user's chat events to another user's agent of the same name", func() {
|
||||
Expect(svc.CreateAgentForUser("bob", newAgentConfig("chatty"))).To(Succeed())
|
||||
awaitRunning(svc, "bob", "chatty")
|
||||
aliceEvents, stopAlice := collectSSE(svc, "alice", "chatty")
|
||||
defer stopAlice()
|
||||
bobEvents, stopBob := collectSSE(svc, "bob", "chatty")
|
||||
defer stopBob()
|
||||
|
||||
_, err := svc.ChatForUser("alice", "chatty", "ping")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() []sseEvent { return statusEvents(aliceEvents(), "completed") }, "30s", "100ms").ShouldNot(BeEmpty())
|
||||
|
||||
// LocalAGI pushes a "hud" snapshot of each agent's own state every
|
||||
// second, so bob's stream is not silent; what must never reach it is
|
||||
// anything produced by alice's chat.
|
||||
Consistently(func() []string {
|
||||
var names []string
|
||||
for _, e := range bobEvents() {
|
||||
if e.Name != "hud" {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}, "1s", "100ms").Should(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
func statusEvents(events []sseEvent, status string) []sseEvent {
|
||||
var out []sseEvent
|
||||
for _, e := range events {
|
||||
if e.Name == "json_message_status" && e.Data["status"] == status {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package agentpool_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/agentpool"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("standalone agent service contract", func() {
|
||||
var (
|
||||
llm *fakeLLM
|
||||
dir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
llm = newFakeLLM("hello from the fake model")
|
||||
// DeferCleanup runs after AfterEach and in LIFO order, so registering
|
||||
// here lets a pool started later stop before its LLM goes away.
|
||||
DeferCleanup(llm.Close)
|
||||
dir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
It("boots against a fake LLM and lists no agents", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
|
||||
Expect(svc.ListAgentsForUser("")).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("agent CRUD", func() {
|
||||
var svc *agentpool.AgentPoolService
|
||||
BeforeEach(func() {
|
||||
svc = startStandalone(dir, llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
})
|
||||
|
||||
It("creates, reads back, updates and deletes an agent", func() {
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("helper"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "helper")
|
||||
|
||||
got := svc.GetAgentConfigForUser("alice", "helper")
|
||||
Expect(got).ToNot(BeNil())
|
||||
// The pool stores the key ("alice:helper"); the API must show the bare name.
|
||||
Expect(got.Name).To(Equal("helper"))
|
||||
Expect(got.Model).To(Equal("fake-model"))
|
||||
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("helper", true))
|
||||
|
||||
updated := newAgentConfig("helper")
|
||||
updated.Description = "changed"
|
||||
Expect(svc.UpdateAgentForUser("alice", "helper", updated)).To(Succeed())
|
||||
// Update restarts the agent, so the new instance needs the same wait.
|
||||
awaitRunning(svc, "alice", "helper")
|
||||
Expect(svc.GetAgentConfigForUser("alice", "helper").Description).To(Equal("changed"))
|
||||
|
||||
Expect(svc.DeleteAgentForUser("alice", "helper")).To(Succeed())
|
||||
Expect(svc.GetAgentConfigForUser("alice", "helper")).To(BeNil())
|
||||
Expect(svc.ListAgentsForUser("alice")).ToNot(HaveKey("helper"))
|
||||
})
|
||||
|
||||
It("reports an update of a missing agent as ErrAgentNotFound", func() {
|
||||
err := svc.UpdateAgentForUser("alice", "ghost", newAgentConfig("ghost"))
|
||||
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
|
||||
})
|
||||
|
||||
It("keeps two users' agents with the same name apart", func() {
|
||||
a := newAgentConfig("shared-name")
|
||||
a.Description = "alice's"
|
||||
b := newAgentConfig("shared-name")
|
||||
b.Description = "bob's"
|
||||
Expect(svc.CreateAgentForUser("alice", a)).To(Succeed())
|
||||
Expect(svc.CreateAgentForUser("bob", b)).To(Succeed())
|
||||
awaitRunning(svc, "alice", "shared-name")
|
||||
awaitRunning(svc, "bob", "shared-name")
|
||||
|
||||
Expect(svc.GetAgentConfigForUser("alice", "shared-name").Description).To(Equal("alice's"))
|
||||
Expect(svc.GetAgentConfigForUser("bob", "shared-name").Description).To(Equal("bob's"))
|
||||
|
||||
Expect(svc.DeleteAgentForUser("alice", "shared-name")).To(Succeed())
|
||||
Expect(svc.GetAgentConfigForUser("alice", "shared-name")).To(BeNil())
|
||||
Expect(svc.GetAgentConfigForUser("bob", "shared-name")).ToNot(BeNil())
|
||||
|
||||
grouped := svc.ListAllAgentsGrouped()
|
||||
Expect(grouped).To(HaveKey("bob"))
|
||||
Expect(grouped).ToNot(HaveKey("alice"))
|
||||
})
|
||||
|
||||
It("round-trips a config through export and import without a user", func() {
|
||||
cfg := newAgentConfig("portable")
|
||||
cfg.Description = "carry me"
|
||||
Expect(svc.CreateAgentForUser("", cfg)).To(Succeed())
|
||||
awaitRunning(svc, "", "portable")
|
||||
|
||||
data, err := svc.ExportAgentForUser("", "portable")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(svc.DeleteAgentForUser("", "portable")).To(Succeed())
|
||||
Expect(svc.ImportAgentForUser("", data)).To(Succeed())
|
||||
awaitRunning(svc, "", "portable")
|
||||
|
||||
got := svc.GetAgentConfigForUser("", "portable")
|
||||
Expect(got).ToNot(BeNil())
|
||||
Expect(got.Description).To(Equal("carry me"))
|
||||
})
|
||||
|
||||
// Known defect pinned on purpose: export returns the stored config, whose
|
||||
// name is the pool key, and import refuses ":" in names. A rewrite that
|
||||
// fixes this must flip this spec rather than silently change behavior.
|
||||
It("known defect: exports a user's agent under its pool key, which import then rejects", func() {
|
||||
cfg := newAgentConfig("portable")
|
||||
cfg.Description = "carry me"
|
||||
Expect(svc.CreateAgentForUser("alice", cfg)).To(Succeed())
|
||||
awaitRunning(svc, "alice", "portable")
|
||||
|
||||
data, err := svc.ExportAgentForUser("alice", "portable")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var out map[string]any
|
||||
Expect(json.Unmarshal(data, &out)).To(Succeed())
|
||||
Expect(out["name"]).To(Equal("alice:portable"))
|
||||
|
||||
Expect(svc.DeleteAgentForUser("alice", "portable")).To(Succeed())
|
||||
Expect(svc.ImportAgentForUser("alice", data)).To(MatchError(ContainSubstring("invalid characters")))
|
||||
Expect(svc.GetAgentConfigForUser("alice", "portable")).To(BeNil())
|
||||
})
|
||||
|
||||
// Engine-specific: P2/P5 flips this because the native import drops
|
||||
// unknown fields (connectors, actions) with a warning, so the export
|
||||
// will no longer carry them.
|
||||
// P5 strips connectors and actions and the P2 migration reads old configs,
|
||||
// so record what a config that carries them looks like today. LocalAGI
|
||||
// logs "Failed to create IRC client" for this fixture because the IRC
|
||||
// config has no nickname; that is expected and is not a failure.
|
||||
It("accepts and returns a config that carries connectors and actions", func() {
|
||||
raw := []byte(`{
|
||||
"name": "legacy",
|
||||
"model": "fake-model",
|
||||
"description": "old style",
|
||||
"connectors": [{"type": "irc", "config": "{}"}],
|
||||
"actions": [{"name": "search", "config": "{}"}]
|
||||
}`)
|
||||
Expect(svc.ImportAgentForUser("alice", raw)).To(Succeed())
|
||||
awaitRunning(svc, "alice", "legacy")
|
||||
|
||||
data, err := svc.ExportAgentForUser("alice", "legacy")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var out map[string]any
|
||||
Expect(json.Unmarshal(data, &out)).To(Succeed())
|
||||
Expect(out["connectors"]).To(HaveLen(1))
|
||||
Expect(out["actions"]).To(HaveLen(1))
|
||||
// The P2 migration reads these element shapes. The action name is
|
||||
// what LocalAGI actually stores, observed as the name sent in, not a
|
||||
// resolved alias.
|
||||
Expect(out["connectors"]).To(ConsistOf(And(
|
||||
HaveKeyWithValue("type", "irc"),
|
||||
HaveKeyWithValue("config", BeAssignableToTypeOf("")),
|
||||
)))
|
||||
Expect(out["actions"]).To(ConsistOf(And(
|
||||
HaveKeyWithValue("name", "search"),
|
||||
HaveKeyWithValue("config", BeAssignableToTypeOf("")),
|
||||
)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("pause and resume", func() {
|
||||
It("toggles the active flag reported by the list", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("napper"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "napper")
|
||||
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("napper", true))
|
||||
|
||||
Expect(svc.PauseAgentForUser("alice", "napper")).To(Succeed())
|
||||
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("napper", false))
|
||||
|
||||
Expect(svc.ResumeAgentForUser("alice", "napper")).To(Succeed())
|
||||
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("napper", true))
|
||||
})
|
||||
|
||||
It("reports pausing a missing agent as ErrAgentNotFound", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
Expect(svc.PauseAgentForUser("alice", "ghost")).To(MatchError(agentpool.ErrAgentNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
package agentpool_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("standalone persistence contract", func() {
|
||||
var (
|
||||
llm *fakeLLM
|
||||
dir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
llm = newFakeLLM("ok")
|
||||
dir = GinkgoT().TempDir()
|
||||
DeferCleanup(llm.Close)
|
||||
})
|
||||
|
||||
// The P2 migration imports this file, so its layout is an interface.
|
||||
It("writes pool.json keyed by userID:name with the agent config as value", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("keeper"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "keeper")
|
||||
Expect(svc.CreateAgentForUser("", newAgentConfig("anon"))).To(Succeed())
|
||||
awaitRunning(svc, "", "anon")
|
||||
svc.Stop()
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "pool.json"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var pool map[string]map[string]any
|
||||
Expect(json.Unmarshal(raw, &pool)).To(Succeed())
|
||||
Expect(pool).To(HaveKey("alice:keeper"))
|
||||
Expect(pool).To(HaveKey("anon"))
|
||||
Expect(pool["alice:keeper"]["model"]).To(Equal("fake-model"))
|
||||
// The stored name repeats the key, prefix included: the P2 importer
|
||||
// strips the prefix, so it depends on this.
|
||||
Expect(pool["alice:keeper"]["name"]).To(Equal("alice:keeper"))
|
||||
Expect(pool["anon"]["name"]).To(Equal("anon"))
|
||||
})
|
||||
|
||||
It("restores agents after a restart on the same state dir", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("survivor"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "survivor")
|
||||
svc.Stop()
|
||||
|
||||
again := startStandalone(dir, llm.URL())
|
||||
DeferCleanup(again.Stop)
|
||||
awaitRunning(again, "alice", "survivor")
|
||||
Expect(again.GetAgentConfigForUser("alice", "survivor")).ToNot(BeNil())
|
||||
Expect(again.ListAgentsForUser("alice")).To(HaveKey("survivor"))
|
||||
})
|
||||
|
||||
// Pause is only an in-memory flag on the running agent: pool.json has no
|
||||
// status field and no per-agent file records pause, so a restart brings
|
||||
// the agent back active. Pinned as a known gap for the native-store migration to
|
||||
// close on purpose rather than by accident.
|
||||
It("known gap: does not keep a paused agent paused across a restart", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("sleeper"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "sleeper")
|
||||
Expect(svc.PauseAgentForUser("alice", "sleeper")).To(Succeed())
|
||||
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("sleeper", false))
|
||||
svc.Stop()
|
||||
|
||||
again := startStandalone(dir, llm.URL())
|
||||
DeferCleanup(again.Stop)
|
||||
awaitRunning(again, "alice", "sleeper")
|
||||
Expect(again.ListAgentsForUser("alice")).To(HaveKeyWithValue("sleeper", true))
|
||||
})
|
||||
|
||||
// The /v1/responses interceptor decides "is this model an agent" with
|
||||
// GetAgent(name) using the raw pool key, with no user prefix.
|
||||
// Known gap, not a contract: any caller who sends model "alice:mine" runs
|
||||
// alice's agent (the interceptor has no user check), while alice's own
|
||||
// request for "mine" falls through; a later fix must not read as a break.
|
||||
It("known gap: resolves an agent by its raw key for the responses interceptor", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
Expect(svc.CreateAgentForUser("", newAgentConfig("global-agent"))).To(Succeed())
|
||||
awaitRunning(svc, "", "global-agent")
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("mine"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "mine")
|
||||
|
||||
Expect(svc.GetAgent("global-agent")).ToNot(BeNil())
|
||||
Expect(svc.GetAgent("alice:mine")).ToNot(BeNil())
|
||||
Expect(svc.GetAgent("mine")).To(BeNil(), "current gap: a user's own agent is not found by its bare name, only by its pool key")
|
||||
Expect(svc.GetAgent("nope")).To(BeNil())
|
||||
})
|
||||
|
||||
It("exposes the state dir it was started with", func() {
|
||||
svc := startStandalone(dir, llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
Expect(svc.StateDir()).To(Equal(dir))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
package agentpool_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/agentpool"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("standalone status and observables contract", func() {
|
||||
var (
|
||||
llm *fakeLLM
|
||||
svc *agentpool.AgentPoolService
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
llm = newFakeLLM("done")
|
||||
// Cleanups run in reverse, so the pool stops before the fake LLM goes away.
|
||||
DeferCleanup(llm.Close)
|
||||
svc = startStandalone(GinkgoT().TempDir(), llm.URL())
|
||||
DeferCleanup(svc.Stop)
|
||||
Expect(svc.CreateAgentForUser("alice", newAgentConfig("observed"))).To(Succeed())
|
||||
awaitRunning(svc, "alice", "observed")
|
||||
})
|
||||
|
||||
// runOnce chats once and waits for the completed status. ChatForUser sends
|
||||
// that status as soon as Ask returns, before the job finalizers have written
|
||||
// the finished observable, so callers that read observables use
|
||||
// settledObservable instead.
|
||||
runOnce := func() {
|
||||
events, stop := collectSSE(svc, "alice", "observed")
|
||||
defer stop()
|
||||
_, err := svc.ChatForUser("alice", "observed", "go")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "30s", "100ms").
|
||||
ShouldNot(BeEmpty(), "no completed status event")
|
||||
}
|
||||
|
||||
// settleRun chats once with the named agent and returns its observables
|
||||
// and SSE events after the job finalizers are done. Three observer.Update
|
||||
// calls follow Finish (the Execute finalizer, the consumeJob finalizer and
|
||||
// the deferred MakeLastProgressCompletion update, LocalAGI agent.go
|
||||
// 1182-1187), and Update re-appends an observable whose id is gone, so
|
||||
// clearing while one is still pending would bring the observable back. Each Update also sends an observable_update event, so the run is
|
||||
// settled once the root observable carries a completion, the completed
|
||||
// status went out, and no further observable_update arrives.
|
||||
settleRun := func(name string) ([]map[string]any, []sseEvent) {
|
||||
events, stop := collectSSE(svc, "alice", name)
|
||||
defer stop()
|
||||
_, err := svc.ChatForUser("alice", name, "go")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "30s", "100ms").
|
||||
ShouldNot(BeEmpty(), "no completed status event")
|
||||
Eventually(func(g Gomega) {
|
||||
raw, err := svc.GetAgentObservablesForUser("alice", name)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
rootDone := false
|
||||
for _, r := range raw {
|
||||
var o map[string]any
|
||||
g.Expect(json.Unmarshal(r, &o)).To(Succeed())
|
||||
if _, child := o["parent_id"]; !child {
|
||||
_, rootDone = o["completion"]
|
||||
}
|
||||
}
|
||||
g.Expect(rootDone).To(BeTrue())
|
||||
}, "30s", "100ms").Should(Succeed())
|
||||
|
||||
updates := func() int {
|
||||
n := 0
|
||||
for _, e := range events() {
|
||||
if e.Name == "observable_update" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
var last int
|
||||
Eventually(func() bool {
|
||||
n := updates()
|
||||
stable := n == last
|
||||
last = n
|
||||
return stable
|
||||
}, "10s", "300ms").Should(BeTrue())
|
||||
Consistently(updates, "300ms", "50ms").Should(Equal(last))
|
||||
|
||||
raw, err := svc.GetAgentObservablesForUser("alice", name)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
obs := make([]map[string]any, len(raw))
|
||||
for i, r := range raw {
|
||||
Expect(json.Unmarshal(r, &obs[i])).To(Succeed())
|
||||
}
|
||||
return obs, events()
|
||||
}
|
||||
|
||||
// settledObservable returns the first observable of a settled plain run.
|
||||
settledObservable := func() map[string]any {
|
||||
obs, _ := settleRun("observed")
|
||||
Expect(obs).ToNot(BeEmpty())
|
||||
return obs[0]
|
||||
}
|
||||
|
||||
It("returns an empty observable list before any run", func() {
|
||||
obs, err := svc.GetAgentObservablesForUser("alice", "observed")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(obs).To(BeEmpty())
|
||||
})
|
||||
|
||||
// Discovery: a plain-content reply (no tool call) is enough for LocalAGI
|
||||
// to record a "job" observable, so the action fallback was not needed.
|
||||
// parent_id is not pinned: it is omitempty and a root job has none.
|
||||
It("records observables after a run with the fields the agent status UI reads", func() {
|
||||
first := settledObservable()
|
||||
Expect(first).To(HaveKey("id"))
|
||||
Expect(first).To(HaveKey("creation"))
|
||||
Expect(first).To(HaveKey("completion"))
|
||||
})
|
||||
|
||||
It("clears observables", func() {
|
||||
settledObservable()
|
||||
|
||||
Expect(svc.ClearAgentObservablesForUser("alice", "observed")).To(Succeed())
|
||||
obs, err := svc.GetAgentObservablesForUser("alice", "observed")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(obs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("reports observables of a missing agent as ErrAgentNotFound", func() {
|
||||
_, err := svc.GetAgentObservablesForUser("alice", "ghost")
|
||||
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
|
||||
Expect(svc.ClearAgentObservablesForUser("alice", "ghost")).To(MatchError(agentpool.ErrAgentNotFound))
|
||||
})
|
||||
|
||||
// LocalAGI only creates a status entry when an action result is recorded,
|
||||
// so an agent whose runs never called an action looks the same as a
|
||||
// missing one. Pinned as current behavior, not as a desirable contract.
|
||||
It("returns a nil status for an agent with no action results, as for a missing one", func() {
|
||||
Expect(svc.GetAgentStatusForUser("alice", "observed")).To(BeNil())
|
||||
runOnce()
|
||||
Consistently(func() any { return svc.GetAgentStatusForUser("alice", "observed") }, "500ms", "100ms").Should(BeNil())
|
||||
Expect(svc.GetAgentStatusForUser("alice", "ghost")).To(BeNil())
|
||||
})
|
||||
|
||||
// Engine-specific: P2 rewrites these three specs because they depend on
|
||||
// the LocalAGI counter action and the native executor ignores Actions;
|
||||
// P2 swaps in an MCP tool fixture. The status spec also reads
|
||||
// types.ActionState, a LocalAGI type that P1 must re-seat when LocalAGI
|
||||
// types leave the service signatures.
|
||||
Context("after a run that calls a tool", func() {
|
||||
BeforeEach(func() {
|
||||
cfg := newAgentConfig("tooled")
|
||||
// counter is pure and in-memory, so the action result is
|
||||
// deterministic without any external service.
|
||||
cfg.Actions = []state.ActionsConfig{{Name: "counter", Config: "{}"}}
|
||||
Expect(svc.CreateAgentForUser("alice", cfg)).To(Succeed())
|
||||
awaitRunning(svc, "alice", "tooled")
|
||||
llm.SetToolCall("counter", `{"name":"contract","adjustment":1}`)
|
||||
})
|
||||
|
||||
It("records a status entry the status endpoint renders with action, params and result", func() {
|
||||
settleRun("tooled")
|
||||
|
||||
st := svc.GetAgentStatusForUser("alice", "tooled")
|
||||
Expect(st).ToNot(BeNil())
|
||||
// Select by action name rather than position: the order of
|
||||
// status entries is a LocalAGI detail, not part of the contract.
|
||||
var h types.ActionState
|
||||
Expect(st.Results()).To(ContainElement(Satisfy(func(s types.ActionState) bool {
|
||||
return s.ActionCurrentState.Action != nil &&
|
||||
s.ActionCurrentState.Action.Definition().Name.String() == "counter"
|
||||
}), &h))
|
||||
Expect(h.ActionCurrentState.Params).To(HaveKeyWithValue("name", "contract"))
|
||||
Expect(h.Result).To(ContainSubstring("Created counter 'contract'"))
|
||||
|
||||
// Same format string as GetAgentStatusEndpoint: this text is what
|
||||
// the agent status page shows, so the params must render as JSON
|
||||
// through ActionParams.String rather than as a Go map.
|
||||
rendered := fmt.Sprintf("Reasoning: %s\nAction taken: %s\nParameters: %+v\nResult: %s",
|
||||
h.Reasoning, h.ActionCurrentState.Action.Definition().Name.String(), h.ActionCurrentState.Params, h.Result)
|
||||
Expect(rendered).To(ContainSubstring("Action taken: counter\n"))
|
||||
Expect(rendered).To(ContainSubstring(`"name":"contract"`))
|
||||
Expect(rendered).To(ContainSubstring("Result: Created counter 'contract'"))
|
||||
})
|
||||
|
||||
// The observables tree nests the action under the job that ran it.
|
||||
// Only the link is pinned: ids, ordering and conversation contents are
|
||||
// LocalAGI internals.
|
||||
It("records a child action observable linked to the root job by parent_id", func() {
|
||||
obs, _ := settleRun("tooled")
|
||||
Expect(len(obs)).To(BeNumerically(">", 1))
|
||||
|
||||
var roots, children []map[string]any
|
||||
for _, o := range obs {
|
||||
if _, ok := o["parent_id"]; ok {
|
||||
children = append(children, o)
|
||||
} else {
|
||||
roots = append(roots, o)
|
||||
}
|
||||
}
|
||||
Expect(roots).To(HaveLen(1))
|
||||
Expect(children).ToNot(BeEmpty())
|
||||
for _, c := range children {
|
||||
// Compared as decoded JSON, whatever type the ids have: the id
|
||||
// type is a LocalAGI detail, the parent link is the contract.
|
||||
Expect(c["parent_id"]).To(Equal(roots[0]["id"]))
|
||||
}
|
||||
// "action" is the LocalAGI name, visible in AgentStatus.jsx; a
|
||||
// native engine may name it differently, so flip deliberately.
|
||||
Expect(children).To(ContainElement(And(
|
||||
HaveKeyWithValue("name", "action"),
|
||||
HaveKeyWithValue("completion", HaveKeyWithValue("action_result", ContainSubstring("Created counter"))),
|
||||
)))
|
||||
})
|
||||
|
||||
It("still delivers the agent's final reply over SSE", func() {
|
||||
_, events := settleRun("tooled")
|
||||
var replies []sseEvent
|
||||
for _, e := range events {
|
||||
if e.Name == "json_message" && e.Data["sender"] == "agent" {
|
||||
replies = append(replies, e)
|
||||
}
|
||||
}
|
||||
Expect(replies).ToNot(BeEmpty())
|
||||
Expect(replies[0].Data["content"]).To(ContainSubstring("done"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user