mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cea8011f3d | |||
| 68835c18db | |||
| 89db544671 | |||
| a021055011 |
@ -34,11 +34,15 @@ type StaticEmbedder struct {
|
|||||||
jieba *gojieba.Jieba
|
jieba *gojieba.Jieba
|
||||||
stopWords map[string]bool
|
stopWords map[string]bool
|
||||||
|
|
||||||
words map[string][]float64
|
// words 是词向量表。**用 float32 存**:源文件(fastText 文本格式)本身就是 float32,
|
||||||
|
// 用 float64 存等于把 578 万……不,是 57.8 万词 × 300 维的常驻内存凭空翻倍
|
||||||
|
// (实测生产:float64 → 1.29GB,float32 → 0.65GB)。相似度计算仍在 float64 里累加,
|
||||||
|
// 精度不受影响。改回 float64 会被 TestStaticEmbedder_VectorMemIsFloat32 拦住。
|
||||||
|
words map[string][]float32
|
||||||
dim int
|
dim int
|
||||||
loaded bool
|
loaded bool
|
||||||
|
|
||||||
unkVec []float64
|
unkVec []float32
|
||||||
unkNorm float64
|
unkNorm float64
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +154,7 @@ func NewStaticEmbedder(modelPaths ...string) *StaticEmbedder {
|
|||||||
e := &StaticEmbedder{
|
e := &StaticEmbedder{
|
||||||
jieba: GetJieba(),
|
jieba: GetJieba(),
|
||||||
stopWords: sw,
|
stopWords: sw,
|
||||||
words: make(map[string][]float64),
|
words: make(map[string][]float32),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(modelPaths) == 0 {
|
if len(modelPaths) == 0 {
|
||||||
@ -254,15 +258,16 @@ func (e *StaticEmbedder) load(spec string, primary bool) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
vec := make([]float64, dim)
|
vec := make([]float32, dim)
|
||||||
for i := 0; i < dim; i++ {
|
for i := 0; i < dim; i++ {
|
||||||
v, _ := strconv.ParseFloat(fields[i+1], 64)
|
// 源文件是 float32 精度的文本向量:用 32 位解析,与源数据一致。
|
||||||
vec[i] = v
|
v, _ := strconv.ParseFloat(fields[i+1], 32)
|
||||||
|
vec[i] = float32(v)
|
||||||
}
|
}
|
||||||
e.words[word] = vec
|
e.words[word] = vec
|
||||||
if primary {
|
if primary {
|
||||||
for i := range vecSum {
|
for i := range vecSum {
|
||||||
vecSum[i] += vec[i]
|
vecSum[i] += float64(vec[i])
|
||||||
}
|
}
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
@ -276,11 +281,13 @@ func (e *StaticEmbedder) load(spec string, primary bool) error {
|
|||||||
for i := range vecSum {
|
for i := range vecSum {
|
||||||
vecSum[i] /= float64(count)
|
vecSum[i] /= float64(count)
|
||||||
}
|
}
|
||||||
e.unkVec = make([]float64, dim)
|
e.unkVec = make([]float32, dim)
|
||||||
copy(e.unkVec, vecSum)
|
for i, v := range vecSum {
|
||||||
|
e.unkVec[i] = float32(v)
|
||||||
|
}
|
||||||
var normSq float64
|
var normSq float64
|
||||||
for _, v := range e.unkVec {
|
for _, v := range e.unkVec {
|
||||||
normSq += v * v
|
normSq += float64(v) * float64(v)
|
||||||
}
|
}
|
||||||
e.unkNorm = float64(math.Sqrt(normSq))
|
e.unkNorm = float64(math.Sqrt(normSq))
|
||||||
e.loaded = true
|
e.loaded = true
|
||||||
@ -366,11 +373,11 @@ func (e *StaticEmbedder) Vectorize(text string) vector.Vector {
|
|||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
for i, v := range unkVec {
|
for i, v := range unkVec {
|
||||||
sum[i] += w * v
|
sum[i] += w * float64(v)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for i, v := range vec {
|
for i, v := range vec {
|
||||||
sum[i] += w * v
|
sum[i] += w * float64(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
weightSum += w
|
weightSum += w
|
||||||
|
|||||||
37
internal/memory/static_embedder_mem_test.go
Normal file
37
internal/memory/static_embedder_mem_test.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 词向量必须用 float32 存。
|
||||||
|
//
|
||||||
|
// 这条判据是拿生产内存换来的:向量本体 = 词数 × 维数 × 每元素字节数。
|
||||||
|
// 生产配置加载了 200000(zh) + 378151(en) = 57.8 万词 × 300 维 ⇒
|
||||||
|
// float64 = 1.29GB、float32 = 0.65GB(差 0.65GB 常驻)。
|
||||||
|
// 源数据(fastText 文本格式)本身就是 float32 精度,用 float64 存没有任何收益。
|
||||||
|
//
|
||||||
|
// 若有人把类型改回 float64,本测试**编译失败**(`var vec []float32` 的类型断言),
|
||||||
|
// 这正是想要的效果。
|
||||||
|
func TestStaticEmbedder_VectorMemIsFloat32(t *testing.T) {
|
||||||
|
e := newSynthEmbedder(t, 300)
|
||||||
|
|
||||||
|
words := 0
|
||||||
|
bytes := 0
|
||||||
|
for _, vec := range e.words {
|
||||||
|
var typed []float32 = vec // 编译期断言:存储必须是 []float32
|
||||||
|
if len(typed) != e.dim {
|
||||||
|
t.Fatalf("维度不符: %d != %d", len(typed), e.dim)
|
||||||
|
}
|
||||||
|
words++
|
||||||
|
bytes += len(typed) * int(unsafe.Sizeof(typed[0]))
|
||||||
|
}
|
||||||
|
if words == 0 {
|
||||||
|
t.Fatal("合成模型应至少加载一个词")
|
||||||
|
}
|
||||||
|
// float32:每词 300×4 = 1200 字节;float64 会是 2400
|
||||||
|
if want := words * e.dim * 4; bytes != want {
|
||||||
|
t.Fatalf("向量本体字节数应 %d(float32),实际 %d", want, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/plugin/channel_warn_test.go
Normal file
43
internal/plugin/channel_warn_test.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 延迟判定的语义:看的是"插件 Start 结束后最终声明了什么",
|
||||||
|
// 而不是"注册出站通道的那一刻有没有入站声明"。
|
||||||
|
//
|
||||||
|
// 为什么必须这样判:声明顺序自由 —— qq/weather 都是**先** RegisterOutputChannel
|
||||||
|
// **后** RegisterInputChannel,按注册时刻判会把它们误报成"只声明了输出通道"
|
||||||
|
// (实测发生过:用户据此以为 qq 插件没更新)。
|
||||||
|
func TestWarnOutputOnlyChannels(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
oldOut := log.Writer()
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
defer log.SetOutput(oldOut)
|
||||||
|
|
||||||
|
// ① 出站+入站都声明了(先出站后入站)⇒ 不该告警
|
||||||
|
r.noteChannel("qq", "qq", true)
|
||||||
|
r.noteChannel("qq", "qq", false)
|
||||||
|
r.warnOutputOnlyChannels("qq")
|
||||||
|
if s := buf.String(); s != "" {
|
||||||
|
t.Fatalf("qq 声明了入站通道,不应告警,实际: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 只声明出站 ⇒ 应告警,且只报这一个通道
|
||||||
|
buf.Reset()
|
||||||
|
r.noteChannel("weather", "weather_weather_out", true)
|
||||||
|
r.noteChannel("weather", "weather_weather_in", false)
|
||||||
|
r.warnOutputOnlyChannels("weather")
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "weather_weather_out") {
|
||||||
|
t.Fatalf("只声明出站的通道应被告警,实际: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "weather_weather_in") {
|
||||||
|
t.Fatalf("已声明入站的通道不该被牵连,实际: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -307,12 +307,15 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
|||||||
// 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入,
|
// 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入,
|
||||||
// 例:cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。
|
// 例:cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。
|
||||||
// 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败
|
// 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败
|
||||||
// (实测报 `划入 inputch cli: inputch 未注册`)。兜底要**留痕**,
|
// (实测报 `划入 inputch cli: inputch 未注册`)。
|
||||||
// 否则插件作者永远不知道该补一行 RegisterInputChannel。
|
//
|
||||||
|
// ❗这里**不能**判"是否声明过入站通道"并告警:声明顺序是自由的,
|
||||||
|
// 先 RegisterOutputChannel 再 RegisterInputChannel 是常见写法(qq 就是),
|
||||||
|
// 按此刻的状态判会对它误报(实测:把 qq 报成"只声明了输出通道")。
|
||||||
|
// 真正该问的问题是"插件 Start 结束后,这个出站通道有没有对应的入站声明" ——
|
||||||
|
// 那在 load 完成后统一判(见 warnOutputOnlyChannels)。
|
||||||
if _, ok := r.iom.LookupInputChannel(chName); !ok {
|
if _, ok := r.iom.LookupInputChannel(chName); !ok {
|
||||||
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
|
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
|
||||||
log.Printf("[plugin] %s 只声明了输出通道 %q,已按双向通道兜底登记 inputch;"+
|
|
||||||
"若要明确意图请显式 RegisterInputChannel", name, chName)
|
|
||||||
}
|
}
|
||||||
r.noteChannel(name, chName, true)
|
r.noteChannel(name, chName, true)
|
||||||
return nil
|
return nil
|
||||||
@ -452,6 +455,7 @@ func (r *Registry) Load(dir string) error {
|
|||||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||||
r.instances = append(r.instances, p)
|
r.instances = append(r.instances, p)
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
r.warnOutputOnlyChannels(name)
|
||||||
log.Printf("[plugin] loaded: %s", name)
|
log.Printf("[plugin] loaded: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -545,6 +549,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
|
|||||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||||
r.sdkRefs[name] = plgSDK
|
r.sdkRefs[name] = plgSDK
|
||||||
r.instances = append(r.instances, plg)
|
r.instances = append(r.instances, plg)
|
||||||
|
r.warnOutputOnlyChannels(name)
|
||||||
if h := pluginEntryHash(plgDir); h != "" {
|
if h := pluginEntryHash(plgDir); h != "" {
|
||||||
r.pluginHashes[name] = h
|
r.pluginHashes[name] = h
|
||||||
} else {
|
} else {
|
||||||
@ -579,6 +584,31 @@ func (r *Registry) stageRegistrarFor() (func(plugin string, stage sdk.Stage, han
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnOutputOnlyChannels 在插件 Start 结束后,报告"只声明了出站、没有入站声明"的通道。
|
||||||
|
//
|
||||||
|
// 为什么放在 Start 之后:声明顺序自由(先出站后入站很常见),注册时刻的状态
|
||||||
|
// 判不出意图。这里看的是**插件最终声明了什么**,因此不会误报 qq 这种写法。
|
||||||
|
//
|
||||||
|
// 注:这类通道内核已兜底登记 inputch(功能可用),告警只是提醒插件作者把意图写明。
|
||||||
|
func (r *Registry) warnOutputOnlyChannels(plugin string) {
|
||||||
|
r.channelsMu.Lock()
|
||||||
|
set := r.pluginChannels[plugin]
|
||||||
|
var only []string
|
||||||
|
if set != nil {
|
||||||
|
for ch := range set.outputs {
|
||||||
|
if !set.inputs[ch] {
|
||||||
|
only = append(only, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.channelsMu.Unlock()
|
||||||
|
sort.Strings(only)
|
||||||
|
for _, ch := range only {
|
||||||
|
log.Printf("[plugin] %s 只声明了出站通道 %q(未 RegisterInputChannel);"+
|
||||||
|
"内核已兜底登记 inputch,若这是有意为之可忽略", plugin, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。
|
// noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。
|
||||||
// forgetChannel 把某个通道从"本插件注册过哪些通道"的记账里摘掉(注销通道时用)。
|
// forgetChannel 把某个通道从"本插件注册过哪些通道"的记账里摘掉(注销通道时用)。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -138,28 +138,39 @@ func (p *Plugin) Stop() error {
|
|||||||
func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
||||||
s.RegisterTool("plugin_install", sdk.ToolDef{
|
s.RegisterTool("plugin_install", sdk.ToolDef{
|
||||||
Name: "plugin_install",
|
Name: "plugin_install",
|
||||||
Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
|
Description: "安装 HomeAgent 插件包(.hmap)。两种来源:url(http/https 下载)或 path(本机路径,配合 plugindev_build 的产物用这个)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"url": map[string]interface{}{
|
"url": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "插件包的下载 URL",
|
"description": "插件包的下载 URL(http/https)",
|
||||||
|
},
|
||||||
|
"path": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "插件包在**本机**的路径(.hmap)。与 url 二选一;同时给出时以 path 为准",
|
||||||
},
|
},
|
||||||
"overwrite": map[string]interface{}{
|
"overwrite": map[string]interface{}{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "已存在时原地更新(保留配置)。默认 false",
|
"description": "已存在时原地更新(保留配置)。默认 false",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"url"},
|
|
||||||
},
|
},
|
||||||
}, func(args map[string]interface{}) (interface{}, error) {
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
url, _ := args["url"].(string)
|
|
||||||
if url == "" {
|
|
||||||
return map[string]interface{}{"error": "url is required"}, nil
|
|
||||||
}
|
|
||||||
overwrite, _ := args["overwrite"].(bool)
|
overwrite, _ := args["overwrite"].(bool)
|
||||||
return p.installFromURL(url, overwrite)
|
// path 优先:它对应"agent 自己构建出产物再装"的场景(plugindev_build → plugin_install)。
|
||||||
|
if path, _ := args["path"].(string); strings.TrimSpace(path) != "" {
|
||||||
|
pth := strings.TrimSpace(path)
|
||||||
|
if st, err := os.Stat(pth); err != nil || st.IsDir() {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("path 无效(必须是存在的 .hmap 文件): %s", pth)}, nil
|
||||||
|
}
|
||||||
|
return p.installFromPath(pth, overwrite)
|
||||||
|
}
|
||||||
|
url, _ := args["url"].(string)
|
||||||
|
if strings.TrimSpace(url) == "" {
|
||||||
|
return map[string]interface{}{"error": "需要 url 或 path(二选一)"}, nil
|
||||||
|
}
|
||||||
|
return p.installFromURL(strings.TrimSpace(url), overwrite)
|
||||||
})
|
})
|
||||||
|
|
||||||
s.RegisterTool("plugin_list", sdk.ToolDef{
|
s.RegisterTool("plugin_list", sdk.ToolDef{
|
||||||
@ -455,12 +466,12 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
|
|||||||
|
|
||||||
if existing && !overwrite {
|
if existing && !overwrite {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"error": "plugin already exists",
|
"error": "plugin already exists",
|
||||||
"name": pkg.Name,
|
"name": pkg.Name,
|
||||||
"version": pkg.Version,
|
"version": pkg.Version,
|
||||||
"current": oldVersion,
|
"current": oldVersion,
|
||||||
"action": "remove_first",
|
"action": "remove_first",
|
||||||
"hint": `传 "overwrite": true 可原地更新(保留配置)`,
|
"hint": `传 "overwrite": true 可原地更新(保留配置)`,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -475,7 +486,7 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
|
|||||||
os.RemoveAll(backup)
|
os.RemoveAll(backup)
|
||||||
if err := os.Rename(target, backup); err != nil {
|
if err := os.Rename(target, backup); err != nil {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"error": "backup old plugin dir failed",
|
"error": "backup old plugin dir failed",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@ -484,8 +495,8 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
|
|||||||
os.RemoveAll(target)
|
os.RemoveAll(target)
|
||||||
if rbErr := os.Rename(backup, target); rbErr != nil {
|
if rbErr := os.Rename(backup, target); rbErr != nil {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"error": "extract failed AND rollback failed",
|
"error": "extract failed AND rollback failed",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
"rollback": rbErr.Error(),
|
"rollback": rbErr.Error(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@ -507,15 +518,15 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
|
|||||||
action = "reinstalled"
|
action = "reinstalled"
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"status": "installed",
|
"status": "installed",
|
||||||
"name": pkg.Name,
|
"name": pkg.Name,
|
||||||
"version": pkg.Version,
|
"version": pkg.Version,
|
||||||
"previous_version": oldVersion,
|
"previous_version": oldVersion,
|
||||||
"entry": pkg.Entry,
|
"entry": pkg.Entry,
|
||||||
"checksum": checksum,
|
"checksum": checksum,
|
||||||
"action": action,
|
"action": action,
|
||||||
"reload_required": true,
|
"reload_required": true,
|
||||||
"config_kept": true,
|
"config_kept": true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
third_party/homeagent-sdk/sdk/plugin.go
vendored
10
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -480,7 +480,15 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
|||||||
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
||||||
// 若该通道同时也是你的注入入口,两个都要登记。
|
// 若该通道同时也是你的注入入口,两个都要登记。
|
||||||
//
|
//
|
||||||
// name: channel name (e.g. "qq", "webui")
|
// name: channel name (e.g. "qq", "webui")。
|
||||||
|
//
|
||||||
|
// ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
|
||||||
|
// 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
|
||||||
|
// 而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
// 网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
|
||||||
|
// 所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
|
||||||
|
// 若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
|
||||||
|
// 而不是把原始值直接当通道名。
|
||||||
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||||
// desc: description of the channel, expected meta format, and type enum
|
// desc: description of the channel, expected meta format, and type enum
|
||||||
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||||
|
|||||||
Reference in New Issue
Block a user