阅读视图

发现新文章,点击刷新页面。
🔲 ⭐

比残忍更残忍

在成都你六姐吃饭,看到店内的宣传图画,每幅图画上都有只卡通的牛,牠们在很开心的笑,然后旁边写着牠们说的话,有些是在介绍自己的肉,有些是说「我很好吃」「快来吃我」之类的。

🔲 ☆

Mac Tips 分享:创建一个 Shortcut 快速调整窗口尺寸

场景

之前发布 Chrome 扩展到 Chrome WebStore 时,WebStore 要求提供几张截图,而且必须是 1280x800 或者 640x400,而如果想要手动调整窗口大小为特定尺寸的话,会非常痛苦。所以一直想找到一种方法可以快速调整窗口尺寸到指定的大小。之前尝试过 AppleScript,甚至想过开发一个 Mac 原生应用来解决,但都遇到了一些问题(主要是权限问题),直到昨天看到一篇文章启发了吾辈。之前从未使用过 Shortcuts,没想到 Mac 自带的自动化工具还不错,完全解决了吾辈的问题。

尝试

AppleScript

在早前,吾辈曾经就该问题询问过 AI,得到的答案是创建一个 AppleScript 来自动化这个操作,看起来脚本很简单。

1
2
3
4
5
6
7
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
tell process frontApp
set position of window 1 to {0, 0}
set size of window 1 to {1280, 800}
end tell
end tell

事实上,如果在 Automactor 中直接执行,也确实符合预期,可以修改窗口大小。但在吾辈将之保存为 App 后,再次运行却出现了权限错误。

1
2
Can’t get window 1 of «class prcs» "Resize1280x800" of application "System Events". Invalid index.
System Events got an error: Can’t get window 1 of process "Resize1280x800". Invalid index. (-1719)

而 System Event 也确实是给了的,不知道发生了什么。🤷

1729049171168.jpg

Mac App 开发

在使用简单的脚本实现受挫之后,吾辈考虑快速开发一个 Mac App 来解决这个问题,但实际上仍然遇到了一些问题。主要是对 Swift 没有任何先验知识,XCode 相比之前使用 IDE(Jetbrains/VSCode)非常难用,再加上 AI 对 Swift 代码生成和修改支持并不好,所以开发起来很痛苦,而且最终仍然遇到了与上面 AppleScript 类似的权限问题。吾辈猜测这是个愚蠢的问题,熟悉 Mac App 开发的人或许十分钟就能解决,但确实卡住了吾辈。

Shortcuts

终于,Shortcuts 可以以低代码的方式创建一个应用。基本思路是,获取所有窗口 => 找到置顶的窗口 => 修改窗口尺寸。

  1. 拖拽一个 Find Windows Action
    1729049857159.jpg
  2. 修改 Find Windows 配置
    1729049910814.jpg
  3. 再拖拽一个 Resize Window Action
    1729049972605.jpg
  4. 修改 Resize Window 的配置
    1729050031565.jpg
  5. 尝试运行一下,确保没有问题
    1729050099222.jpg
  6. 现在,可以使用 Spotlight Search 输入 Resize Window 来快速运行这个 Shortcut 啦
    1729050190520.jpg

另外吾辈已经把这个 Shortcut 导出放到 GitHub 上了,可以自行下载使用:https://github.com/rxliuli/mac-resize-window

参考

The Easiest Way to Resize All Windows on Your Mac Simultaneously to the Same Dimensions

🔲 ⭐

使用 JavaScript 创建 PoeAI 的 服务端 bot

背景

Poe 是一个 AI 聊天机器人,它支持多种 AI 模型,包括 GPT-4o、Claude 3.5 Sonnet、Gemini Pro 等。还支持各种类型的 Bot,其中 Server Bot 是最自由的,可以自己编写 Bot 的逻辑。但是,Poe 的 Server Bot 官方仅支持 Python,而吾辈更喜欢 JavaScript,所以研究了一下怎么实现。

初始化项目

一开始使用 express 实现服务端,但后面发现 express 无法部署到 edge runtime,例如 Cloudflare Workers,所以改用 hono.js 实现。

首先使用 hono.js 创建一个项目并选择 cloudflare-workers 模板

1
2
3
pnpm create hono@latest
? Target directory hono-demo
? Which template do you want to use? cloudflare-workers

src/index.ts 是项目入口,内容如下

1
2
3
4
5
6
7
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello, world!'))

export default app

首先在 Poe 网站上创建 Server Bot,得到一个 NameAccess Key

1724235940301.jpg

协议分析

根据 Poe 协议规范,实现一个 Bot 需要实现一个特定的 post 请求,具体如下

API 接口传入的参数会有两个固定字段,type 是请求类型,version 是协议版本。

1
2
3
4
type BotRequest = {
version: string
type: 'query' | 'settings' | 'report_feedback' | 'report_error'
}

其中 query 和 settings 是必须实现的,report_feedback 和 report_error 是可选的。

下面来实现一个基本的 post 请求结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 其他代码...

app.post('/', async (c) => {
const request = await c.req.json()
const { version, type } = request
function handleQuery(c: Context) {
throw new Error('Not implemented')
}
function handleSettings(c: Context) {
throw new Error('Not implemented')
}

switch (type) {
case 'query':
return handleQuery(c)
case 'settings':
return handleSettings(c)
default:
throw new Error('Invalid request type')
}
})

Settings 请求

Settings 请求没有额外的参数,仅要求返回这个 bot 相关的一些设置。

1
2
3
4
5
6
7
8
9
interface SettingsResponse {
server_bot_dependencies?: Record<string, number> // 声明依赖的其他 bot
allow_attachments?: boolean // 是否允许附件
introduction_message?: string // 初始化消息
expand_text_attachments?: boolean // 是否扩展文本附件
enable_image_comprehension?: boolean // 是否启用图像理解,如果启用,则图片会被 Poe 解析为文本传给当前 Bot
enforce_author_role_alternation?: boolean // 是否强制交替用户/机器人角色
enable_multi_bot_chat_prompting?: boolean
}

例如,实现上面的 settings 请求,这是一个简单的响应

1
2
3
4
5
6
7
8
function handleSettings(c: Context) {
return c.json({
server_bot_dependencies: {
'GPT-4o': 1,
},
introduction_message: 'Hello, I am a server bot.',
})
}

参考 https://creator.poe.com/docs/poe-protocol-specification#settings

Query 请求

query 是关键部分,不管是请求还是响应都很复杂。

下面是请求的类型定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type Identifier = string // Matches the identifier format described in the spec
type ContentType = 'text/markdown' | 'text/plain'
type FeedbackType = 'like' | 'dislike'
interface MessageFeedback {
type: FeedbackType
reason?: string
}
interface Attachment {
url: string
content_type: string
name: string
parsed_content?: string
}
interface ProtocolMessage {
role: 'system' | 'user' | 'bot'
content: string
content_type: ContentType
timestamp: number
message_id: Identifier
feedback: MessageFeedback[]
attachments: Attachment[]
}
interface QueryRequest {
query: ProtocolMessage[]
user_id: Identifier
conversation_id: Identifier
message_id: Identifier
access_key: string
temperature?: number
skip_system_prompt?: boolean
logit_bias?: Record<string, number>
stop_sequences?: string[]
language_code?: string
}

响应要求返回 SSE 流式响应多条消息,具体也有很多类型

meta 类型,应该返回的第一条消息,主要是用来声明一些设置

1
2
3
4
5
6
7
interface MetaMessage {
event: 'meta'
data: {
content_type?: 'text/markdown' | 'text/plain' // 内容类型,默认为 text/markdown
suggested_replies?: boolean // Poe 是否显示建议的回复,默认为 false
}
}

接下来是两种消息类型,区别只在于是否替换之前已经发送的消息

1
2
3
4
5
6
7
8
9
10
11
12
interface TextMessage {
event: 'text'
data: {
text: string
}
}
interface ReplaceMessage {
event: 'replace_response'
data: {
text: string
}
}

还有两种特殊格式的消息,一种是用来返回 JSON 数据(通常给 OpenAI 这种支持函数调用的 Bot 使用),另一种是建议回复的消息,这会出现在回复消息的下方。

1724233538152.jpg

1
2
3
4
5
6
7
8
9
10
interface JsonMessage {
event: 'json'
data: Record<string, any>
}
interface SuggestedReplyMessage {
event: 'suggested_reply'
data: {
text: string
}
}

最后是结束和错误消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
interface ErrorMessage {
event: 'error'
data: {
allow_retry: boolean
text: string
raw_response: string
error_type: string
}
}
interface DoneMessage {
event: 'done'
data: {}
}

现在来实现一个简单的 query 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function handleQuery(c: Context) {
return streamSSE(c, async (stream) => {
const writeSSE = (
message:
| MetaMessage
| TextMessage
| ReplaceMessage
| JsonMessage
| SuggestedReplyMessage
| ErrorMessage
| DoneMessage,
) => {
stream.writeSSE({
event: message.event,
data: JSON.stringify(message.data),
})
}
writeSSE({
event: 'meta',
data: { content_type: 'text/markdown', suggested_replies: true },
})
writeSSE({ event: 'text', data: { text: 'Hello, World!' } })
writeSSE({ event: 'done', data: {} })
})
}

参考:https://creator.poe.com/docs/poe-protocol-specification#query

发布到 Cloudflare Workers

现在发布到 Cloudflare Workers 上,得到一个 URL,例如 https://xxx.workers.dev

1
pnpm run deploy

现在在 Poe 网站上填写 Server URL,然后点击 Run check,如果成功,继续创建 Bot 就可以在 Poe 上使用了。

1724236097284.jpg
1724236179345.jpg

验证请求

根据 Poe 官方的建议,还应该为 post 请求添加验证,确定是来自 Poe 的请求。首先添加环境变量

1
2
3
ACCESS_KEY="<YOUR_ACCESS_KEY>"
echo ACCESS_KEY=\"$ACCESS_KEY\" > .dev.vars # 在本地添加
echo $ACCESS_KEY | pnpm wrangler secret put ACCESS_KEY # 在生产环境中添加

然后在 src/index.ts 中添加验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Bindings = {
ACCESS_KEY: string
}
// 声明环境变量绑定
const app = new Hono<{ Bindings: Bindings }>()

app.post('/', async (c) => {
const request = await c.req.json()
const authHeader = c.req.header().authorization
if (authHeader !== `Bearer ${c.env.ACCESS_KEY}`) {
return c.text('Unauthorized', 401)
}
// 其他代码...
})

主动调用

Bot 除了可以被 Poe 调用,也可以主动调用 Poe 的 API 来实现一些功能,下面介绍其中两个。

刷新 Bot Settings

修改 Bot Settings 的实现后,还需要主动通知 Poe 调用接口刷新设置。

例如修改了 handleSettings 函数,更新了 server_bot_dependencies,不再使用 GPT-4o,而是使用 Claude-3.5-Sonnet

1
2
3
4
5
6
7
function handleSettings(c: Context) {
return c.json({
server_bot_dependencies: {
'Claude-3.5-Sonnet': 1,
},
})
}

然后在项目初始化的时候主动通知 Poe 刷新设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function syncBotSettings(
botName: string,
accessKey: string = '',
): Promise<void> {
const PROTOCOL_VERSION = '1.0'
const baseUrl = 'https://api.poe.com/bot/fetch_settings'
const resp = await fetch(
`${baseUrl}/${botName}/${accessKey}/${PROTOCOL_VERSION}`,
{ method: 'post' },
)
const text = await resp.text()
if (!resp.ok) {
throw new Error(`Error fetching settings for bot ${botName}: ${text}`)
}
console.log(text)
}

app.get('/sync-bot-settings', async (c) => {
await syncBotSettings('BotT6R4NKNGZ9', c.env.ACCESS_KEY)
return c.text('Synced')
})

然后在浏览器中直接访问这个 URL 就可以通知 Poe 刷新设置了。

参考:https://creator.poe.com/docs/server-bots-functional-guides#3-make-a-post-request-to-poes-refetch-settings-endpoint-with-your-bot-name-and-access-key

调用其他 Bot

接下来,说明如何调用第三方的 Bot,这里仅以文本 => 文本的 Bot 为例(除此之外,现在的 Bot 还支持附件文件、图片、音视频等)。遗憾的是,官方文档没有记录 API 接口,只说明了使用 python 模块 fastapi_poe 来实现,所以只能分析 fastapi_poe 的源码来实现。

在其中可以找到关键接口 https://api.poe.com/bot/<botName>,然后接口会以 SSE 流式响应多条消息,先做个测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
async function* requestStream(
request: QueryRequest,
botName: string,
accessKey: string,
): AsyncGenerator<{
event: 'text'
data: any
}> {
// region 解析 SSE 流
const response = await fetch(`https://api.poe.com/bot/${botName}`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessKey}`,
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: JSON.stringify(request),
})
if (!response.ok || !response.body) {
console.error(response.statusText)
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader()
let chunk = await reader.read()
while (!chunk.done) {
console.log('chunk: ', chunk.value)
chunk = await reader.read()
}
// endregion
}

function handleQuery(c: Context<{ Bindings: Bindings }>) {
return streamSSE(c, async (stream) => {
const writeSSE = (
message:
| MetaMessage
| TextMessage
| ReplaceMessage
| JsonMessage
| SuggestedReplyMessage
| ErrorMessage
| DoneMessage,
) => {
stream.writeSSE({
event: message.event,
data: JSON.stringify(message.data),
})
}
writeSSE({
event: 'meta',
data: { content_type: 'text/markdown', suggested_replies: true },
})
for await (const chunk of requestStream(
request,
'GPT-4o',
c.env.ACCESS_KEY,
)) {
if (chunk.event === 'text') {
writeSSE(chunk)
}
}
writeSSE({ event: 'done', data: {} })
})
}

终端打印的结果。可以消息分为三种:

  • text: 普通文本消息,但可能会被拆分成多个 chunk 返回
  • done: 结束消息
  • 空消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
chunk:  event: text
data: {"text":
chunk: ""}

event: text
data: {"text": "Hello"}
chunk: event: text
data: {"text":
chunk: "!"}

event: text
data: {"text": " How"}
chunk: event: text
data: {"text":
chunk: " can"}

event: text
data: {"text": " I"}
chunk: event: text
data: {"text":
chunk: " assist"}

event: text
data: {"text": " you"}
chunk: event: text
data: {"text":
chunk: " today"}

event: text
data: {"text": "?"}
chunk: event: text
data: {"text":
chunk: ""}
chunk: event: done
data: {}
chunk:

因此需要实现一个 TransformStream 来将 SSE 文本流转换为结构化的数据,并处理 text 多条消息合并。实现本身并不复杂,但主要的问题是多个模型拆分规则可能规则会不一致,例如 GPT-4o chunk 中可能包含一条完整消息,也可能不包含,而 Claude 3.5 Sonnet 中则总是由两个 chunk 组成一条完整消息。还有一些模型会返回 ping 消息,而且 ping 消息的格式也略有不同,像是 ping: ping 等。

下面是 GPT-4o 的消息示例

1
2
3
4
;[
'event: text\r\ndata: {"text": ',
'""}\r\n\r\nevent: text\r\ndata: {"text": "Hi"}\r\n\r\n',
]

Claude 3.5 Sonnet 的消息示例

1
;['event: text\r\ndata: {"text": ', '"Hello"}\r\n\r\n']

所以实现的 TransformStream 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
function sseTransformStream() {
let buffer = ''
return new TransformStream<
string,
{
event: 'text'
data: any
}
>({
transform(chunk: string, controller: TransformStreamDefaultController) {
buffer += chunk
const textRegexp = /^event: text[\r\n]+data: ({[\s\S]*?})/
const doneRegexp = /^event: done[\r\n]+data: ({})/
while (buffer) {
// console.log(
// !/^ping$/m.test(buffer) &&
// !doneRegexp.test(buffer) &&
// !textRegexp.test(buffer) &&
// buffer !== '' &&
// buffer !== 'event: text\r\ndata: {"text": ' &&
// buffer.trim() !== ': ping',
// )
buffer = buffer.trimStart()
if (textRegexp.test(buffer)) {
const match = buffer.match(textRegexp)
if (match) {
controller.enqueue({
event: 'text',
data: JSON.parse(match[1]),
})
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (doneRegexp.test(buffer)) {
const match = buffer.match(doneRegexp)
if (match) {
// controller.enqueue({
// event: 'done',
// data: JSON.parse(match[1]),
// })
buffer = buffer.replace(match[0], '').trimStart()
controller.terminate()
return
}
// ignore ping
} else if (/^ping$/m.test(buffer)) {
const match = buffer.match(/^ping$/m)
if (match) {
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (buffer.trim() === ': ping') {
buffer = buffer.replace(': ping', '').trimStart()
} else {
return
}
}
},
flush() {
if (buffer.trim()) {
console.warn('Unprocessed data in buffer:', buffer)
}
},
})
}

修改 requestStream 来使用这个 TransformStream

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function* requestStream(
request: QueryRequest,
botName: string,
accessKey: string,
): AsyncGenerator<string> {
// 其他代码...
const reader = response
.body!.pipeThrough(new TextDecoderStream())
.pipeThrough(sseTransformStream())
.getReader()
let chunk = await reader.read()
while (!chunk.done) {
yield chunk.value
chunk = await reader.read()
}
}

完整代码

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
import { Context, Hono } from 'hono'
import { streamSSE } from 'hono/streaming'

interface SettingsResponse {
server_bot_dependencies?: Record<string, number> // 声明依赖的其他 bot
allow_attachments?: boolean // 是否允许附件
introduction_message?: string // 初始化消息
expand_text_attachments?: boolean // 是否扩展文本附件
enable_image_comprehension?: boolean // 是否启用图像理解,如果启用,则图片会被 Poe 解析为文本传给当前 Bot
enforce_author_role_alternation?: boolean // 是否强制交替用户/机器人角色
enable_multi_bot_chat_prompting?: boolean
}

type Identifier = string // Matches the identifier format described in the spec
type ContentType = 'text/markdown' | 'text/plain'
type FeedbackType = 'like' | 'dislike'
interface MessageFeedback {
type: FeedbackType
reason?: string
}
interface Attachment {
url: string
content_type: string
name: string
parsed_content?: string
}
interface ProtocolMessage {
role: 'system' | 'user' | 'bot'
content: string
content_type: ContentType
timestamp: number
message_id: Identifier
feedback: MessageFeedback[]
attachments: Attachment[]
}
interface QueryRequest {
query: ProtocolMessage[]
user_id: Identifier
conversation_id: Identifier
message_id: Identifier
access_key: string
temperature?: number
skip_system_prompt?: boolean
logit_bias?: Record<string, number>
stop_sequences?: string[]
language_code?: string
}
interface MetaMessage {
event: 'meta'
data: {
content_type?: 'text/markdown' | 'text/plain' // 内容类型,默认为 text/markdown
suggested_replies?: boolean // Poe 是否显示建议的回复,默认为 false
}
}
interface TextMessage {
event: 'text'
data: {
text: string
}
}
interface ReplaceMessage {
event: 'replace_response'
data: {
text: string
}
}
interface JsonMessage {
event: 'json'
data: Record<string, any>
}
interface SuggestedReplyMessage {
event: 'suggested_reply'
data: {
text: string
}
}
interface ErrorMessage {
event: 'error'
data: {
allow_retry: boolean
text: string
raw_response: string
error_type: string
}
}
interface DoneMessage {
event: 'done'
data: {}
}

type Bindings = {
ACCESS_KEY: string
}

const app = new Hono<{ Bindings: Bindings }>()

app.get('/', (c) => c.text('Hello, World!'))
app.post('/', async (c) => {
const request = await c.req.json()
const authHeader = c.req.header().authorization
if (authHeader !== `Bearer ${c.env.ACCESS_KEY}`) {
return c.text('Unauthorized', 401)
}
const { type } = request
function handleSettings(c: Context) {
return c.json({
server_bot_dependencies: {
'GPT-4o': 1,
},
introduction_message: 'Hello, I am a server bot.',
} as SettingsResponse)
}
function handleQuery(c: Context<{ Bindings: Bindings }>) {
return streamSSE(c, async (stream) => {
const writeSSE = (
message:
| MetaMessage
| TextMessage
| ReplaceMessage
| JsonMessage
| SuggestedReplyMessage
| ErrorMessage
| DoneMessage,
) => {
stream.writeSSE({
event: message.event,
data: JSON.stringify(message.data),
})
}
writeSSE({
event: 'meta',
data: { content_type: 'text/markdown', suggested_replies: true },
})
for await (const chunk of requestStream(
request,
'GPT-4o',
c.env.ACCESS_KEY,
)) {
if (chunk.event === 'text') {
writeSSE(chunk)
}
}
// writeSSE({ event: 'text', data: { text: 'Hello, World!' } })
writeSSE({ event: 'done', data: {} })
})
}
switch (type) {
case 'query':
return handleQuery(c)
case 'settings':
return handleSettings(c)
default:
throw new Error('Invalid request type')
}
})

function sseTransformStream() {
let buffer = ''
return new TransformStream<
string,
{
event: 'text'
data: any
}
>({
transform(chunk: string, controller: TransformStreamDefaultController) {
buffer += chunk
const textRegexp = /^event: text[\r\n]+data: ({[\s\S]*?})/
const doneRegexp = /^event: done[\r\n]+data: ({})/
while (buffer) {
// console.log(
// !/^ping$/m.test(buffer) &&
// !doneRegexp.test(buffer) &&
// !textRegexp.test(buffer) &&
// buffer !== '' &&
// buffer !== 'event: text\r\ndata: {"text": ' &&
// buffer.trim() !== ': ping',
// )
buffer = buffer.trimStart()
if (textRegexp.test(buffer)) {
const match = buffer.match(textRegexp)
if (match) {
controller.enqueue({
event: 'text',
data: JSON.parse(match[1]),
})
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (doneRegexp.test(buffer)) {
const match = buffer.match(doneRegexp)
if (match) {
// controller.enqueue({
// event: 'done',
// data: JSON.parse(match[1]),
// })
buffer = buffer.replace(match[0], '').trimStart()
controller.terminate()
return
}
// ignore ping
} else if (/^ping$/m.test(buffer)) {
const match = buffer.match(/^ping$/m)
if (match) {
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (buffer.trim() === ': ping') {
buffer = buffer.replace(': ping', '').trimStart()
} else {
return
}
}
},
flush() {
if (buffer.trim()) {
console.warn('Unprocessed data in buffer:', buffer)
}
},
})
}

async function* requestStream(
request: QueryRequest,
botName: string,
accessKey: string,
): AsyncGenerator<{
event: 'text'
data: any
}> {
const response = await fetch(`https://api.poe.com/bot/${botName}`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessKey}`,
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: JSON.stringify(request),
})
if (!response.ok || !response.body) {
console.error(response.statusText)
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response
.body!.pipeThrough(new TextDecoderStream())
.pipeThrough(sseTransformStream())
.getReader()
let chunk = await reader.read()
while (!chunk.done) {
yield chunk.value
chunk = await reader.read()
}
}

async function syncBotSettings(
botName: string,
accessKey: string = '',
): Promise<void> {
const PROTOCOL_VERSION = '1.0'
const baseUrl = 'https://api.poe.com/bot/fetch_settings'
const resp = await fetch(
`${baseUrl}/${botName}/${accessKey}/${PROTOCOL_VERSION}`,
{ method: 'post' },
)
const text = await resp.text()
if (!resp.ok) {
throw new Error(`Error fetching settings for bot ${botName}: ${text}`)
}
console.log(text)
}

app.get('/sync-bot-settings', async (c) => {
await syncBotSettings('BotT6R4NKNGZ9', c.env.ACCESS_KEY)
return c.text('Synced')
})

export default app

现在,重新发布至 Cloudflare Workers,然后就可以向这个 Bot 聊天,并在服务端调用 GPT-4o 模型。

1724255995710.jpg

总结

上面只是一个非常简单的 demo,Poe Server Bot 实际上还可以做很多事情,但对 JavaScript 缺乏官方支持,让想要尝试变得比较麻烦。吾辈发布了一个 npm 模块 fastapi-poe,来尝试像官方的 python 模块 fastapi_poe 一样使用。

🔲 ☆

稻荷山

爬上山顶,就是爬上山顶,山顶什么也没有,心里也只有一种感受:噢,原来这就是山顶。爬山的时候是黄金时间,下山的时候是蓝调时刻,路过一个观景点时,太阳刚好完全落山,只听见了一群人鼓掌的余声。

🔲 ☆

使用 ChatGPT 生成图标

场景

现在无论是创建什么东西,都需要一个图标。吾辈尝试过的有 PC/Mobile 应用、VSCode/Chrome 插件、甚至各种开发者相关的库或者工具。而作为没有太多绘画能力的开发者,ChatGPT 内置集成的 DALL·E 非常好用,可以用来生成需要的图片,即便存在道德限制 – 但这不是这里要讨论的问题,它也不会影响创建需要的图标。

使用 ChatGPT 生成

目前,只有订阅了 ChatGPT Plus 才提供这个功能,免费版账户只能使用 ChatGPT 3.5。

例如吾辈最近创建了一个 Chrome 插件,用来自动分割长文本发送到 ChatGPT,其中图标就是使用 ChatGPT 集成的 DALL·E 创建。

方法也很简单,选择 ChatGPT 4 开始聊天,说出想要创建的图片,可以介绍一下相关背景之类的。例如

1
帮我创建一个 logo,它是一个 Chrome 扩展,用来自动拆分长文本并输入到到 ChatGPT。

1708696709429.jpg

如果不太满意,可以要求 ChatGPT 微调它。

1
不需要强调 chrome 扩展的部分

1708696807176.jpg

这点也是吾辈认为 ChatGPT 内集成 DALL·E 要比使用标签魔法的 Stable Diffusion 要好,尽管道德限限制确实局限了一些可用性。

吾辈最后得到的是

1708696884865.jpg

使用 Photopea 编辑

之前一直使用的是 fotor,它也是一个在线的图片编辑器,但免费版强制显示广告,而且一些需要的功能免费版也不能用,例如删除图片背景。所以找到了更好的 Photopea,它有点像是免费的线上 Photoshop,对于吾辈而言完全够用了。

裁剪

直接从工具栏选择 Crop Tool 进行裁剪即可。

Snipaste\_2024-02-23\_04-10-44.jpg

背景透明

使用 Select > Magic Cut 来自动将背景色删除,就可以获得透明背景的图片了。

Snipaste\_2024-02-23\_04-06-28.jpg

导出

通常应用需要多个不同尺寸的图标,可以使用 File > Export as > PNG 来导出,并且选择需要的大小。

Snipaste\_2024-02-23\_04-03-07.jpg


如果使用 Mac,还可以使用 imagemagick,下面是基于 logo.png 生成一系列的 icon。

1
2
3
4
5
6
7
8
brew install imagemagick

mkdir -p ./icon
magick logo.png -resize 16x16 ./icon/16.png
magick logo.png -resize 32x32 ./icon/32.png
magick logo.png -resize 48x48 ./icon/48.png
magick logo.png -resize 96x96 ./icon/96.png
magick logo.png -resize 128x128 ./icon/128.png

总结

自 2022 年底 ChatGPT 3.5 发布以来,吾辈一直在使用它,也尝试尝试了一些相关的东西,例如前面提到的 Stable Diffusion。现在,不管是平时编码、学习日语时遇到疑惑、还是出门旅游寻求建议,吾辈都会先问一声 ChatGPT 找点线索,然后再去具体搜索相关信息。无论如何,吾辈仍然相信它会像之前的电脑、互联网和智能手机一样改变世界,尽管现在已经不像最初那样焦虑,但仍然一直在使用和了解它的能力,以便紧跟时代。

PS: 非计算机行业的人似乎真的基本不太使用它,可能知道有这么个东西,但并没有真正融入到生活和工作中使用。

🔲 ⭐

多读单写场景下的thread-safe的map

题目

基于HashMap,实现多读单写场景下的thread-safe的map

代码

import java.util.HashMap;import java.util.Map;import java.util.concurrent.Semaphore;import java.util.concurrent.locks.ReadWriteLock;import java.util.concurrent.locks.ReentrantReadWriteLock;/** * 思路1:借助 ReadWriteLock 实现多读单写的线程安全map */class MultiReadSingleWriteMap1<K, V> {    private final Map<K, V> map = new HashMap<>();    // 使用ReadWriteLock实现读写锁    // 读操作使用读锁 readLock(),写操作使用写锁 writeLock()    private final ReadWriteLock lock = new ReentrantReadWriteLock();    /**     * get操作     * 类型:读     */    public V get(K key) {        // 读取时允许多个线程同时读取        lock.readLock().lock();        try {            return map.get(key);        } finally {            lock.readLock().unlock();        }    }    /**     * put操作     * 类型:写     */    public void put(K key, V value) {        // 写入时只有一个线程可以访问 Map        lock.writeLock().lock();        try {            // 新增/修改目标值            map.put(key, value);        } finally {            // 释放写锁            lock.writeLock().unlock();        }    }    /**     * remove操作     * 类型:写     */    public void remove(K key) {        // remove属于写操作,只有一个线程可以访问Map        lock.writeLock().lock();        try {            // 删除目标值            map.remove(key);        } finally {            // 释放写锁            lock.writeLock().unlock();        }    }}/** * 思路2:借助 synchronized、信号量 实现多读单写的线程安全map */class MultiReadSingleWriteMap2<K, V> {    // 定义读信号量、写信号量    private final Semaphore readLock;    private final Semaphore writeLock;    // 定义读计数器,用于判断占据和释放写锁的时机    private int readCount;    // 定义用于实际存储键值对的Map    private final Map<K, V> map = new HashMap<>();    /**     * 构造函数     */    public MultiReadSingleWriteMap2(int readLimit) {        this.readCount = 0;        // 读信号量初始化为输入上限值        this.readLock = new Semaphore(readLimit);        // 写信号量初始化上限为1        this.writeLock = new Semaphore(1);    }    /**     * get操作     * 类型:读     */    public V get(K key) throws InterruptedException {        // 获取读信号量        readLock.acquire();        try {            // 使用synchronized锁住临界区,对读计数器操作            synchronized (this) {                // 拿到读信号量,读线程计数器加1,占据写信号量                readCount ++;                if (readCount == 1) {                    // 因为写信号量只有1个,所以在读计数器等于1时占据一次就足够                    writeLock.acquire();                }            }        } finally {            readLock.release();        }        // 读取目标值        V value = map.get(key);        // 重新获取读信号量        readLock.acquire();        try {            synchronized (this) {                // 读取完毕,读线程计数器减1                readCount --;                // 如果读线程计数器为0,没有线程在读,可以释放写信号量                if (readCount == 0) {                    writeLock.release();                }            }        } finally {            readLock.release();        }        return value;    }    /**     * put操作     * 类型:写     */    public void put(K key, V value) throws InterruptedException {        // 尝试获取写信号量        writeLock.acquire();        try {            // 新增/修改目标值            map.put(key, value);        } finally {            // 释放写信号量            writeLock.release();        }    }    /**     * remove操作     * 类型:写     */    public void remove(K key) throws InterruptedException {        // 尝试获取写信号量        writeLock.acquire();        try {            // 删除目标值            map.remove(key);        } finally {            // 释放写信号量            writeLock.release();        }    }}
🔲 ☆

全排列问题(非递归实现和递归实现)

题目

  • 给定一个不含重复数字的数组 nums ,返回其所有可能的全排列

代码

1. 非递归实现,借助栈

import java.util.ArrayList;import java.util.List;import java.util.Stack;/** * @author: Li Qiang * @date: 2023/7/26 * @description: 全排列问题的非递归实现 */public class Permutations {    public static void main(String[] args) {        Solution solution = new Solution();        int[] nums = {1, 2, 3, 4};        List<List<Integer>> permutations = solution.permute(nums);        // 输出验证        for (List<Integer> permutation : permutations) {            System.out.println(permutation);        }    }}class Solution {    public List<List<Integer>> permute(int[] nums) {        int n = nums.length;        // 定义结果列表        List<List<Integer>> ans = new ArrayList<>();        // 借助栈模拟递归过程        Stack<List<Integer>> stack = new Stack<>();        stack.push(new ArrayList<>());        while (!stack.isEmpty()) {            // currentPath代表当前的排列(不一定是全排列)            // 其中的元素代表已将被当前排列采用的元素            List<Integer> currentPath = stack.pop();            if (currentPath.size() == n) {                // 如果当前的排列是全排列,将其添加到结果列表中                ans.add(currentPath);            } else {                // 如果当前的排列没有完成,通过添加未使用的元素来补充它                for (int element : nums) {                    if (!currentPath.contains(element)) {                        // 如果当前排列的元素中不包含element,则采纳当前数组元素并将其放进新排列中                        List<Integer> newPath = new ArrayList<>(currentPath);                        newPath.add(element);                        // 新的排列(不一定是全排列)暂时压入栈中                        stack.push(newPath);                    }                }            }        }        return ans;    }}

2. 递归实现

/** * @author: Li Qiang * @date: 2023/7/26 * @description: 全排列问题的递归实现 */class Solution {    List<List<Integer>> ans;    boolean[] visited;    List<Integer> path;    public List<List<Integer>> permute(int[] nums) {        int n = nums.length;        ans = new ArrayList<>();        visited = new boolean[n];        path = new ArrayList<>();                loop(nums, 0, n);        return ans;    }    public void loop(int[] nums, int len, int n) {        if (len == n) {            ans.add(new ArrayList<>(path));            return;        }        for (int i = 0; i < n; i ++) {            if (visited[i] == true) {                continue;            } else {                visited[i] = true;                path.add(nums[i]);                loop(nums, len + 1, n);                // 回溯                path.remove(path.size() - 1);                visited[i] = false;            }        }    }}
🔲 ☆

Protobuf 编码&避坑指南

我们现在所有的协议、配置、数据库的表达都是以 protobuf 来进行承载的,所以我想深入总结一下 protobuf 这个协议,以免踩坑。

先简单介绍一下 Protocol Buffers(protobuf),它是Google开发的一种数据序列化协议(与XML、JSON类似)。它具有很多优点,但也有一些需要注意的缺点:

优点:

  1. 效率高:Protobuf以二进制格式存储数据,比如XML和JSON等文本格式更紧凑,也更快。序列化和反序列化的速度也很快。
  2. 跨语言支持:Protobuf支持多种编程语言,包括C++、Java、Python等。
  3. 清晰的结构定义:使用protobuf,可以清晰地定义数据的结构,这有助于维护和理解。
  4. 向后兼容性:你可以添加或者删除字段,而不会破坏老的应用程序。这对于长期的项目来说是非常有价值的。

缺点:

  1. 不直观:由于protobuf是二进制格式,人不能直接阅读和修改它。这对于调试和测试来说可能会有些困难。
  2. 缺乏一些数据类型:例如没有内建的日期、时间类型,对于这些类型的数据,需要手动转换成可以支持的类型,如string或int。
  3. 需要额外的编译步骤:你需要先定义数据结构,然后使用protobuf的编译器将其编译成目标语言的代码,这是一个额外的步骤,可能会影响开发流程。

总的来说,Protobuf是一个强大而高效的数据序列化工具,我们一方面看重它的性能以及兼容性,除此之外就是它强制要求清晰的定义出来,以文件的形式呈现出来方便我们维护管理。下面我们主要看它的编码原理,以及在使用上有什么需要注意的地方。

编码原理

概述

对于 protobuf 它的编码是很紧凑的,我们先看一下 message 的结构,举一个简单的例子:

message Student {
  string name = 1;
  int32 age = 2; 
}

message 是一系列键值对,编码过之后实际上只有 tag 序列号和对应的值,这一点相比我们熟悉的 json 很不一样,所以对于 protobuf 来说没有 .proto 文件是无法解出来的:

pb1

对于 tag 来说,它保存了message 字段的编号以及类型信息,我们可以做个实验,把 name 这个tag编码后的二进制打印出来:

func main() {
    student := student.Student{}
    student.Name = "t" 
    marshal, _ := proto.Marshal(&student)
    fmt.Println(fmt.Sprintf("%08b", marshal)) // 00001010 00000001 01110100
}

打印出来的结果是这样:

pb3

上图中,由于 name 是 string 类型,所以第一个 byte 是tag,第二 byte 是 string 的长度,第三个 byte 是值,也就是我们上面设置的 “t”。我们下面先看看 tag:

pb2

tag 里面会包含两部分信息:字段序号,字段类型,计算方式就是上图的公式。上图中将 name 这个字段序列化成二进制我们可以看到,第一个 bit 是标记位,表示是否字段结尾,这里是0表示tag 已结尾,tag 占用1byte;接下来 4 个 bit 表示的是字段序号,所以范围 1 到 15 中的字段编号只需要 1 bit进行编码,我们可以做个实验看看,将tag 改成16:

pb4

由上图所示,每个byte 第一个bit表示是否结束,0表示结束,所以上面 tag 用两个 byte 表示,并且protobuf 是小端编码的,需要转成大端方便阅读,所以我们可以知道 tag 去掉每个 byte 第一个 bit 之后,后三位表示类型,是3,其余位是编号表示 16。

所以从上面编码规则我们也可以知道,字段尽可能精简一些,字段尽量不要超过 16 个,这样就可以用一个byte表示了。

同时我们也可以知道,protobuf 序列化是不带字段名的,所以如果客户端的 proto 文件只修改了字段名,请求服务端是安全的,服务端继续用根据序列编号还是解出来原来的字段。但是需要注意的是不要修改字段类型。

接下来我们看看类型,protobuf 共定义了 6 种类型,其中两种是废弃的:

ID Name Used For
0 VARINT int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 I64 fixed64, sfixed64, double
2 LEN string, bytes, embedded messages, packed repeated fields
3 SGROUP group start (deprecated)
4 EGROUP group end (deprecated)
5 I32 fixed32, sfixed32, float

上面的例子中,Name 是 string 类型所以上面 tag 类型解出来是 010 ,也就是 2。

Varints 编码

对于 protobuf 来说对数字类型做了压缩的,普通情况下一个 int32 类型需要 4 byte,而 protobuf 表示127以内的数字只需要 2 byte。因为对于一个普通 int32 类型数字,如果数字很小,那么实际上有效位很少,比如要表示 1 这个数字,二进制可能是这样:

00000000 00000000 00000000 00000001

前 3 个字节都是 0 没有表示任何信息,protobuf 就是将这些 0 都去除了,用 1 byte 表示 1 这个数字,再用 1 byte 表示 tag 的编号和类型,所以占用了 2byte。

比如我们对上面 student 设置 age 等于 150:

func main() {
    student := student.Student{}
    student.Age = 150
    marshal, _ := proto.Marshal(&student)
    fmt.Println(fmt.Sprintf("%08b", marshal)) //00010000 10010110 00000001
    fmt.Println(fmt.Sprintf("%08b", "a"))
}

上面打印出来的二进制如下,因为 150 超过 127,所以需要用两个 byte 表示:

pb5

第一个 byte 是 tag 这里就不再重复介绍了。后面两个 byte 是真实的值,每个 byte 的最高位 bit 是标记位,表示是否结束。然后我们转换成大端表示,串联起来就可以得到它的值是 150。

ZigZag 编码

Varints 编码之所以可缩短数字所占的存储字节数是因为去掉了 0 ,但是对于负数来说就不行了,因为负数的符号位为 1,并且对于32 位的有符号数都会转换成 64 位无符号来处理,例如 -1,用 Varints 编码之后的二进制:

pb6

所以 Varints 编码负数总共会恒定占用 11 byte,tag 一个byte,值占用 10 byte。

为此 Google Protocol Buffer 定义了 sint32 这种类型,采用 zigzag 编码。将所有整数映射成无符号整数,然后再采用 varint 编码方式编码。例如:

Signed Original Encoded As
0 0
-1 1
1 2
-2 3
0x7fffffff 0xfffffffe
-0x80000000 0xffffffff

参照上面的表,也就是将 -1 编码成 1,将 1 编码成 2,全部都做映射,实际的 Zigzag 映射函数为:

(n << 1) ^ (n >> 31)  //for 32 bit 
(n << 1) ^ (n >> 63)  //for 64 bit

对于使用来说,只是编码方式变了,使用是不受影响,所以对于如果有很高比例负数的数据,可以尝试使用 sint 类型,节省一些空间。

embedded messages & repeated

比如现在定义这样的 proto:

message Lecture {
  int32 price =1 ; 
}

message Student {
  repeated int32 scores = 1;
  Lecture lecture = 2;
}

给 scores 取值为 [1,2,3],编码之后发现其实和上面讲的 string 类型很像。第一个 byte 是 tag;第二 byte 是 len,长度为 3;后面三个byte 都是值,我们设定的 1,2,3。

pb7

再来看看 embedded messages 类型,让 Lecture 的 price 设置为150 好了,编码之后是这样:

pb8

其实结构也很简单,左边的是 Student 类型,右边是 Lecture 类型。有点不同的是对于 embedded messages 会将大小计算出来。

最佳实践

字段编号

需要注意的是范围 1 到 15 中的字段编号需要一个字节进行编码,包括字段编号和字段类型;范围 16 至 2047 中的字段编号需要两个字节。所以你应该保留数字 1 到 15 作为非常频繁出现的消息元素。

因为使用了 VarInts,所以单字节的最高位是零,而最低三位表示类型,所以只剩下 4 位可用了。也就是说,当你的字段数量超过 16 时,就需要用两个以上的字节表示了。

保留字段

一般的情况下,我们是不会轻易的删除字段的,防止客户端和服务端出现协议不一致的情况,如果您通过完全删除某个字段或将其注释掉来更新消息类型,那么未来的其他人不知道这个 tag 或字段被删除过了,我们可以使用 reserved 来标记被删除的字段,如:

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

当然除了 message ,reserved也可以在枚举类型中使用。

不要修改字段 tag 编号以及字段类型

protobuf 序列化是不带字段名的,所以如果客户端的 proto 文件只修改了字段名,请求服务端是安全的,服务端继续用根据序列编号还是解出来原来的字段,但是需要注意的是不要修改字段类型,以及序列编号,修改了之后就可能按照编号找错类型。

不要使用 required 关键字

required 意味着消息中必须包含这个字段,并且字段的值必须被设置。如果在序列化或者反序列化的过程中,该字段没有被设置,那么protobuf库就会抛出一个错误。

如果你在初期定义了一个 required 字段,但是在后来的版本中你想要删除它,那么这就会造成问题,因为旧的代码会期待该字段始终存在。为了确保兼容性,Google在最新版本的 protobuf(protobuf 3)中已经不再支持 required 修饰符。

尽量使用小整数

Varints 编码表示127以内的数字只需要 2 byte,1 byte 是 tag,1 byte 是值,压缩效果很好。但是如果表示一个很大的数如 :1<<31 - 1,除去 tag 外需要占用 5 byte,比普通的 int 32 多1 byte,因为 protobuf 每个byte最高位有一个标识符占用 1 bit。

如果需要传输负数,可以试试 sint32 或 sint64

因为负数的符号位为 1,并且Varints 编码对于负数如果是32 位的有符号数都会转换成 64 位无符号来处理,所以 Varints 编码负数总共会恒定占用 11 byte,tag 一个byte,值占用 10 byte。

而 sint32 和 sint64 将所有整数映射成无符号整数,然后再采用 varint 编码方式编码,如果数字比较还是可以节省一定的空间的。

Reference

https://sunyunqiang.com/blog/protobuf_encode/

https://halfrost.com/protobuf_encode/

https://protobuf.dev/programming-guides/encoding/

https://protobuf.dev/programming-guides/dos-donts/

扫码_搜索联合传播样式-白色版 1

Protobuf 编码&避坑指南最先出现在luozhiyun`s Blog

🔲 ☆

让我虚惊一场的 PDF “XSS 漏洞”

手上负责的一个项目收到了一份来自外部的漏洞报告,演示了一个跟鉴权有关的 bug (此 bug 与上传文件无关,这里原本就是允许用户上传文件的) 这个 bug 不是什么疑难杂症,很快就修好了。反倒是报告最底下的补充说明让我大吃一惊: 并且可上传 xss 文件进一步扩大危害,可诱导成员点击进一步获取 cookie 等信息 什么?pdf 居然支持嵌入 js!?而且浏览器还会执行!? 完了完了,居然有这么

The post 让我虚惊一场的 PDF “XSS 漏洞” first appeared on mokeyjay - 超能小紫.
🔲 ☆

捐赠乌克兰,有哪些靠谱的渠道?

1. 捐赠给值得信任的慈善组织

在捐赠不熟悉的项目或者机构前,可以查询该慈善机构是否获得了BBB 认证。BBB Wise Giving Alliance 是一家美国慈善监督组织,通过 20 项标准来免费评估慈善机构,帮助捐助者验证募捐组织的可信度。主流的国际慈善机构在 give.org 都可以查到 20 项标准的达标情况。

Global Giving

GlobalGiving 是总部位于美国的 501 非营利组织,为草根慈善项目提供全球众筹平台。 自 2002 年以来,超过 110 万的 GlobalGiving 捐助者已捐款超过 5.3 亿美元,用于支持 170 个国家的 28,000 多个项目。

GlobalGiving 的乌克兰援助项目的善款将用于为难民提供住所、食物和清洁水;为民众提供健康和社会心理支持;为民众提供教育和经济上的援助。

  • 捐赠页面:globalgiving.org
  • 支持的支付方式:VISA | MasterCard | PayPal|银行转账
  • 该机构 20 项 BBB 标准 均达标

Save the Children

救助儿童基金会,于 1919 年在英国成立,旨在通过更好的教育、医疗保健和经济机会以及在自然灾害中提供紧急援助来改善儿童的生活 、战争和其他冲突。 它现有 29 个国家成员,在超过 120 个国家提供服务。Save the Children 承诺超过 85% 的善款都将直接用于救助儿童。

International Rescue Committee

国际救援委员会 (IRC) 是一个全球人道主义援助、救济和发展非政府组织。 IRC 应阿尔伯特·爱因斯坦的要求成立于 1933 年,前身为国际救济协会,并在与类似的紧急救援委员会合并后于 1942 年更名,为难民和因战争、迫害、或自然灾害而流离失所的人提供紧急援助和长期援助 。 IRC 目前在大约 40 个国家和 26 个美国城市开展工作。 捐赠将用于为乌克兰等国家的难民家庭提供食物、医疗护理和紧急物资。

International Medical Corps

International Medical Corps 是一家全球性的非营利性人道主义援助组织,致力于通过向受灾害、疾病或冲突影响的人提供紧急医疗服务以及医疗保健培训和发展计划来拯救生命和减轻痛苦。

International Medical Corps 正在乌克兰为受影响的社区居民提供医疗和心理健康服务,并正在该地区帮助难民。

ICRC 红十字国际委员会

红十字会页面显示,捐款将用于为 300 万人提供清洁用水、改善 66000 人的生活条件,他们的住所因战争受到较大损失。同时,红十字国际委员会及其在红十字与红新月运动中的合作伙伴继续在乌克兰积极开展活动,拯救和保护武装冲突和暴力受害者的生命。

  • 捐赠页面 :www.icrc.org
  • 支持的支付方式:VISA | MasterCard | PayPal
  • 信息来自: gq.com.tw

Ukraine Humanitarian Fund 乌克兰人道主义基金会

乌克兰人道主义基金是联合国以国家为基础的集合基金之一。 捐款被收集到一个单一的、未指定用途的基金中,并在联合国的领导下在当地进行管理。 随着危机的发展,资金将直接和立即提供给处于响应第一线的广泛合作伙伴组织。 通过这种方式,资金可以在最需要的时候到达最需要的人手中。

  • 捐赠页面 : crisisrelief.un.org
  • 支持的支付方式:VISA | MasterCard
  • 该机构未参与BBB标准评估

Project Hope 符合18/20 项BBB标准。Doctors Without Borders 符合16/20 项BBB标准。也都是值得信赖的慈善组织。

2. 专注服务乌克兰的慈善组织

Nova Ukraine

Nova Ukraine 是一家总部位于美国的非营利组织,致力于提高美国和世界各地对乌克兰的认识,并通过提供人道主义援助来加强乌克兰的民间社会。 自 2014 年 Euromaidan 事件后成立以来,该非营利组织发起了许多旨在帮助乌克兰及其人民的慈善计划。

United Help Ukraine

United Help Ukraine 是 2014 年克里米亚事件后成立的美国非营利组织。 它目前正在筹集资金,向乌克兰发送急救箱和其他人道主义援助。 该基金已超过其 600,000 美元的目标,但仍在接受捐款。

Razom for Ukraine

Razom (укр. Разом) 是一个非营利性的乌克兰裔美国人权组织,旨在支持乌克兰人民追求一个拥有尊严、正义以及所有人的人权和公民权利的民主社会。 Razom(在乌克兰语中意为“一起”)宣布其主要目标是增加乌克兰境内的公民参与并扩大世界各地乌克兰人的声音。

3. 专注服务儿童与难民的慈善机构

Voices of Children

Voices of Children 是位于乌克兰的慈善基金会,专注于解决武装冲突对儿童的心理影响。 儿童之声成立于 2015 年,旨在应对乌克兰东部的冲突,为受创伤的儿童提供艺术治疗、流动心理学家和个性化支持。

  • 捐赠页面 voices.org.ua
  • 支持的支付方式:VISA | MasterCard | 银联| 银行转帐
  • 信息来自: fortune.com
  • 该机构未参与BBB标准评估

Malteser International

马耳他的非营利组织 Malteser International 一直在为被迫逃离家园的乌克兰人收集“日常”用品。 Malteser International 紧急救援部门负责人 Oliver Hochedez 在一份公开声明中说:“特别需要的是日常药品,以及婴儿床、毯子、食物和现金,以供许多受影响的人使用。”

Vostok SOS

Vostok SOS 与德国-瑞士非政府组织 Libereco 合作,为试图逃离家园的乌克兰人提供紧急疏散支持。 Vostok 为有需要的乌克兰人设立了一条热线,并希望在未来为俄罗斯入侵的受害者提供创伤支持。

  • 捐赠页面:vostok-sos.org
  • 支持的支付方式:VISA | MasterCard
  • 信息来自: time.com
  • 该机构未参与BBB标准评估

Sunflower of Peace

Sunflower of Peace 是一个美国非营利组织。目前正在筹集资金,为前线的医护人员和医生组装急救包。 2014 年,该组织还筹集资金为医疗专业人员建造急救背包,为逃离克里米亚吞并的人提供援助。 筹款活动的协调员 Katya Malakhova 在 Facebook 帖子中写道:“当时它对医疗专业人士意味着世界,现在对他们来说也意味着世界。” 该基金也超过了其目标——200,000 美元——但仍在继续筹款。

Support Hospitals in Ukraine

「Support Hospitals in Ukraine守护乌克兰医院」组织:该机构在过去八年中筹集了超过 400 万美元,提供与创伤治疗相关的设备和手术工具」,以及其他必须物品。他们正在通过Support Hospitals in Ukraine网站接受捐款。

  • 捐赠页面:4agc.com
  • 支持的支付方式:VISA | MasterCard
  • 信息来自: gq.com.tw
  • 该机构未参与BBB标准评估

4. 支持军队及军人的机构

乌克兰官方军队

乌克兰官方政府已通过国家银行开设特别筹款账户,以支持乌克兰武装部队。

  • 捐赠页面: ukraine.ua
  • 支持支付方式:VISA | MasterCard | 银行转账
  • 该机构未参与BBB标准评估

Revived Soldiers Ukraine

专注于提供更广泛的人道主义援助以及「乌克兰士兵的医疗康复」,在Irpin市(距离首度基辅20余公里的的小城)建立了一个大型治疗康复中心。

  • 捐赠页面: www.paypal.com
  • 支持支付方式:VISA | MasterCard | PayPal
  • 信息来自: gq.com.tw
  • 该机构未参与BBB标准评估

5. 捐赠独立媒体

The Kyiv Independent

Kyiv Independent 是一家位于乌克兰对的英文媒体机构。

2021 年, Kyiv Post 的工作人员与该报的新东家就编辑独立性发生争执。 所有者 Adnan Kivan 最终关闭了报纸并解雇了新闻编辑室的工作人员。 此后,Kyiv Post 的前编辑团队在欧洲民主基金会的支持下成立了 Kyiv Independent 。此后该独立媒体的资金主要来自众筹。

文章中存在大量超链接,如有错误,请在评论中指出,谢谢!

本站自 2021 年底重新开通 Google Adsense,每月广告收入约80-110美元/月,2022年广告收入将全部捐赠给 Save the Children 儿童救助基金会,相关捐赠记录也会更新在此页面中。

🔲 ⭐

前端该如何选择图片的格式

想当初,这个问题在面试实习的时候被问到。问题看着简单,但是想要回答好还需要下一番功夫来了解的。

如果不想看文章的话直接翻到文末去看结论即可。

🔲 ☆

FPGA 数字图像处理联合仿真平台的搭建及使用举例

前言

随着物联网技术的不断发展,对边缘计算的性能要求愈发苛刻,传统的 IoT 级别的 SoC 或者 MCU 难以应对诸如图像识别的场景需求,因此 FPGA + MCU 或 FPGA + SoC 的异构架构愈来愈多地应用于上述场景中,以满足相关需求。(以上这段话毫无意义,只是为了凑字数,另外为了纪念第三次憾负的开场白)

当前 FPGA 发展火热,众多开发者使用 FPGA 来完成部分图像处理的部分功能,但众所周知纯 FPGA 开发(或者数字 IC 设计)与常见的 ARM A 系列(with OS)或者桌面端 PC 在图像处理开发过程中最大的差别在于过程的可视化。开发过程中的可视化多少会影响到开发者对于图像处理的判断与取舍,简而言之,如果开发者都不知道经过了这一步处理后图像变成了什么样,那还如何继续接下来的步骤。因此分享交流一下在本次车牌识别设计中我们所用到的数字图像处理的联合仿真平台(注:本文仅仅介绍平台使用方式,不介绍过程中涉及到的数字图像处理原理),如果有更好的方式,希望大家可以留言给予建议~

首先是我们所需要的软件工具:

本次我们的开发环境为 Win10 所用到的软件工具分别为:Modelsim(10.5B), MATLAB(R2019a)

然后简述一下我们的操作过程(以图片 RGB 色域转换 YCbCr 色域处理为例),整体过程可简述如下:通过 MATLAB 将图片文件转换为 .txt 格式文件,使用 Verilog 读取相应文件后完成相应图像处理操作后输出为 .txt 文件,再次使用 MATLAB 将输出的 .txt 格式文件转换为图片文件并查看。以上步骤的意义在于不仅可以实现数字图像处理的单步可视化,还可以同原始的 MATLAB 对应的处理效果进行对比。(后期我们也会更新,直接使用 Verilog 完成图像读取并输出结果查看的方法)

首先我们通过以下程序(亦可查看附带工程中的 IMG2TXT.m 文件)在 MATLAB 端将图片生成 R,G,B 色域对应的 .txt 格式文件。并同时通过 MATLAB 生成 YCbCr 色域下三个分量的图像予以显示。
其关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
clear all
close all
clc
img = imread('test.jpg');
[a,b,c]= size(img);
R1=img(:,:,1);
G1=img(:,:,2);
B1=img(:,:,3);

fidR1= fopen('testR.txt','w');
fidG1= fopen('testG.txt','w');
fidB1= fopen('testB.txt','w');
for i=1:a
for j= 1:b
fprintf(fidR1,'%d\n',R1(i,j)); %frame1
fprintf(fidG1,'%d\n',G1(i,j));
fprintf(fidB1,'%d\n',B1(i,j));
end
end
fclose(fidR1);
fclose(fidG1);
fclose(fidB1);

RGB 色域文件生成

生成对应 RGB 色域 .txt 格式文件如下图所示:

RGB.txt

生成 YCbCr 色域对应分量图片(详见工程文件 YCbCr.m ),如下图所示:

YCbCr

Verilog 处理

然后在我们的 Modelsim 工程中对 .txt 格式文件进行读取(亦可查看工程中附带的 imread.v 文件),进一步的完成图像处理对应的操作后,输出对应 .txt 格式文件(亦可查看工程中附带 imwrite.v 文件)。
其 txt 读取关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
initial begin
fR = $fopen("testR.txt","r"); // read in testR.txt
fG = $fopen("testG.txt","r");
fB = $fopen("testB.txt","r");
if(fR == `NULL || fG == `NULL || fB == `NULL) begin
$display("data_file handle was NULL");
$finish;
end
else begin
$fscanf(fR,"%d;\n",R);
$fscanf(fG,"%d;\n",G);
$fscanf(fB,"%d;\n",B);
end
end

always @(posedge pixel_clk or negedge reset_n) begin
if(reset_n == 0) begin
R = 8'd0;
G = 8'd0;
B = 8'd0;
end
else if(de) begin
if(fR != 0 && fG != 0 && fB != 0) begin
$fscanf(fR,"%d;\n",R);
$fscanf(fG,"%d;\n",G);
$fscanf(fB,"%d;\n",B);
$display("time=[%d],%d,%d,%d",$realtime,R,G,B);
end
end

end

完成处理后生成 txt 关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
initial begin
fR = $fopen("R.txt","w");
fG = $fopen("G.txt","w");
fB = $fopen("B.txt","w");
if(fR == `NULL || fG == `NULL || fB == `NULL) begin
$display("can not open R.txt or G.txt or B.txt");
$finish;
end
end

always @(posedge pixel_clk or negedge reset_n) begin
if(de) begin
$display("////////////////////////////////////");
$display("time=[%d],%d,%d,%d",$realtime,R,G,B);
$fwrite(fR,"%d\n",R);
$fwrite(fG,"%d\n",G);
$fwrite(fB,"%d\n",B);
end
end

处理结果

最终在 MATLAB 端将仿真生成输出的 .txt 文件转换还原为图片(亦可查看工程中附带的 TXT2IMG.M 文件)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
clear all
close all
clc

imgori = imread('test.jpg');
[a,b,c]= size(imgori);

img1R = uint8(textread('R.txt','%u'));
img1G = uint8(textread('G.txt','%u'));
img1B = uint8(textread('B.txt','%u'));

img1(:,:,1) = reshape(img1R,[b,a]);
img1(:,:,2) = reshape(img1G,[b,a]);
img1(:,:,3) = reshape(img1B,[b,a]);

img=flipdim(img1,1);

figure,
subplot(221),imshow(imrotate(img,-90)),title('YCbCr');
subplot(222),imshow(imrotate(img(:,:,1),-90)),title('Y');
subplot(223),imshow(imrotate(img(:,:,2),-90)),title('Cb');
subplot(224),imshow(imrotate(img(:,:,3),-90)),title('Cr');

由于常见的 FPGA 数字图像处理为了减少数据读取对于缓存资源的消耗,通常其处理步骤在视频流完成,因此本次工程中我们在 VGA 模拟时序中完成相应操作。完成仿真如波形下图所示:

Waveform

生成结果 .txt 文件如下图所示:
Outfile

将生成结果 .txt 文件放回 MATLAB 工程路径下最终实现效果如下图所示:

Outfile of FPGA

MATLAB vs HDL数字图像处理 结果对比

将图像放大后,可以明显发现通过数字思想处理得到的图片结果相较于 MATLAB 直接转换得到的结果存在一定的差距,Cr 分量下表现的最为明显,具体原因在此不具体展开分析。其对比如下图(注:左侧为 MATLAB 处理结果右侧为 verilog 处理结果

Matlab vs HDL

之后我们将逐步介绍本次我们 ARM 杯车牌识别系统的每个实现过程~

并公开全套设计源码!!!

举例部分全部代码详见 GitHub 仓库:https://github.com/strongwong/FPGA-DIP

By Ricky

🔲 ☆

对于番茄学习法的误解

大部分人在使用番茄学习法进行学习时,都想全神贯注在 25 分钟的学习上,可当这种想法产生的同时,也就代表思维已从学习中抽离。其实在番茄学习法那 25 分钟的专注学习时间中,只要求尽最大可能的保持专注和高效,并不需要担忧思绪偏离学习本身。

当完成 25 分钟的学习后,进行 5 分钟的休息时间时,大部分人会愿意彻底将思维迅速投入另一件事物当中,这样强迫式的休息也许会对学习效率与兴趣起到反作用。在 25 分钟的学习时间里,人的大脑使用的是线性思维,用于一步步理解和记忆学习中的每个环节。而在 5 分钟的休息时间里,并不是不让继续学习,只是在这 5 分钟里应该使用发散思维去思考刚才所学内容,将刚才所学要点发散开再重新组合,连接,与发展。

所以真正的番茄学习法是在以 30 分钟为区间的时间中,切换这两种思维模式,这样才能更好的掌握新知识。

🔲 ☆

数字 IC 设计流程

0x00

最近即将开始要带着学弟们入门数字 IC 的设计,但很多学弟对于接下来要做什么是迷茫的,很多练就了各式各样的基本功却不知道如何施展,因此这里简单介绍一下数字 IC 设计的全过程及相关的设计工具及涉及到的相关职位,如果有写的不合适或者不正确的地方还请各位提出~

详见下图:

0x01

看了上图之后很多学弟就又问了,那平时我们都是 vivado,quartus,FPGA …… 为啥感觉和上面的都不沾边呢,这里说一点个人的看法,如果不是做硬件并行加速或者 FPGA 的嵌入式开发,那么平日 FPGA 的最大作用就是 —— 功能验证性工具。因为流片的价格非常昂贵,很少有实验室或者学校会让你不断地流片来实现你的设计,另外的,一个实验室如果没有同时具备设计,验证,版图 ……(全栈)技能同学的话要想能流片(同时具备以上技能)其实也很难的。那么这时 FPGA 就可以验证你的设计是否在一定程度上是正确的。

0x02

我们最后再来看一下数字前端的设计流程,如下图所示~

之前的 sdram 设计剩余部分,我们将尽快更新~

By Ricky

🔲 ⭐

从计数器开始,看数字 IC 设计

计数器设计

之前有实验室的学长去参加海思、中芯国际、瑞芯微、…… 数字 IC 前端方向的面试,几乎都问到了同一系列问题——设计一个计数器及相关问题。这里很多朋友就会觉得很有意思了,为什么一个简单的计数器能有这么多东西,那我们就『简单』的东西简单看。

0x00 请你设计一个 10 进制的异步复位无限循环计数器(0-9)

首先,第一个问题,请你设计一个 10 进制的异步复位无限循环计数器(0-9),你会怎么做?

相信到这很多朋友就开始洋洋洒洒地写道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module counter10(
input clk,
input rst_n,
output wire cnt_flag
);
reg[3:0] cnt_reg;

always @(posedge clk or negedge rst) begin
if( ~rst_n) begin
cnt_reg <= 4'b0;
end
else begin
if(cnt_reg == 4'd9)
cnt_reg <= 4'b0;
else
cnt_reg <= cnt_reg + 4'b1;
end
end

assign cnt_flag = (cnt_reg == 4'd9) ? 1'b1 : 1'b0;

endmodule

0x01 请画出对应的电路图

Bravo! 没有任何问题!接下来开始有分水岭了,请画出对应的电路图 ,有的朋友可能会抓脑袋了:

这里给一点提示,看你能想起什么,确定状态 —— 确定激励方程 —— 逻辑图 —— 自启动检查 —— 状态表。这是什么?数字电路基础,为什么?我们这是数字集成电路设计啊!最终还是要回归到数字电路上来啊!

好,那我们先用 D 触发器来做(不经过编码优化,只是还原最简单的设计步骤),回忆一下最初我们大一大二时怎么弄的。

状态表:

计数顺序 现状态 次状态
- Q3 Q2 Q1 Q0 D3 D2 D1 D0
0 0 0 0 0 0 0 0 1
1 0 0 0 1 0 0 1 0
2 0 0 1 0 0 0 1 1
3 0 0 1 1 0 1 0 0
4 0 1 0 0 0 1 0 1
5 0 1 0 1 0 1 1 0
6 0 1 1 0 0 1 1 1
7 0 1 1 1 1 0 0 0
8 1 0 0 0 1 0 0 1
9 1 0 0 1 0 0 0 0

激励方程:

逻辑图:

…… 剩下的估计大家都能回忆起来了

以上就是我们之前数字电路设计流程,我们回过头来看我们的硬件描述过程,有 if 判断值,那少不了比较器,有 + 运算,自然也有一个加法器 …… 当然我们真正的设计应该是先想好了有比较器和其他逻辑电路才有对应的硬件描述,但我们不妨来看一下我们设计的电路,不正是如下图所示:

0x02 上图中的关键路径是哪一条?

Ok~ 解决了电路图,下一个问题又来了,上图中的关键路径是哪一条?

要知道关键路径就需要时序分析啦,这里为接下来的另一个数字 IC 的小专栏———时序分析与约束挖下第一坑。

关键路径应该是:Q > + > MUX > D (具体分析将在后面填坑,大家也可以先想想为什么不是 Q > CMP > MUX > D)

0x03 上面图中 CMP = 9 即原描述中 cnt_reg == 4’d9 的电路是什么?

接下来的问题,上面图中 CMP = 9 即原描述中 cnt_reg == 4’d9 的电路是什么?
其实答案就在我们的触发器版原理图对应的组合电路中,精简后如下图所示:

0x04 cnt_reg == 4’d9 和 cnt_reg > 8 有什么区别?

功能上确实是一样的,但是如果是一个把 Verilog 当编程玩的朋友对于接下来的东就蛮有意义的了,cnt_reg == 4’d9 实现的电路如上图所示,是一个相对简单的组合逻辑电路。如果是 cnt_reg > 8,对于我们 4 位数据来说可能的范围为 9~15,则综合工具会把所有的情况都列出,cnt_reg == 9,10,11 …… 这样在无形中就浪费了资源。若位宽更大则会被综合为cnt_reg - 8 > 0,由此便会引入一个加法器 ……

0x05 加法器对应的电路是什么?如何验证这个计数器?……

一系列的问题,我们可以发现并非那么简单的。要知道直到现在优化加法器的文章依然不时可以出现在 sci 检索期刊中,这另外说明为什么上一问引入加法器后带来的浪费用省略号来表示,为什么关键路径是到 + 而不是比较器 ……

小结

引用《手把手教你设计 CPU——RISC-V 处理器篇》作者胡振波老师的一段话,当年第一次 Verilog 课时我的授课老师董乾博士也强调过类似的话。

先定义电路微架构而后编写代码。
谨记 Verilog 只是一种硬件描述语言,IC 设计的本质是对于电路的设计,虽然现在Verilog Coding 采用 RTL 级别的抽象描述,但是必须清楚所描述的代码能够映射出的电路结构,其面积和时序的影响都了然于胸,只有如此才能够成为一名优秀的 IC 设计工程师。
不要纠结 Verilog 的语法,而应立足实战。
Verilog 的设计语法子集非常精简简单,很快就可以上手入门。入门之后最好的学习方法是进行设计实战(实战是最好的老师),而不是进一步纠结 Verilog 的语法(不要浪费脑力试图记住大多数高级的 Verilog 语法,而是在需要使用的时候查阅即可)。

By Ricky

❌