不信人间有白头

分类: geek

曲淡歌
曲淡歌@qudange

2024 年 9 月 7 日

一键把excalidraw的头脑风暴输出为线性文章 

发散性思考与线性输出

我以前一直都是采用传统的笔记方法,从上往下写。

这种线性似乎是理所应当、浑然天成的,但是人在想问题的时候又喜欢在草稿上写写画画,这个时候思维的组织方式却是非线性的。

接近的概念就是头脑风暴之类的思考组织方式,无意掉书袋,因此本文不再就此概念做过多讨论,我们姑且定义两种方式,不再深究其描述是否准确:

  1. 从上往下的一维:线性
  2. 上下左右都有:发散性

这里就会引入一个问题,当你的大脑在发散性思考的时候,用线性的笔记辅助思考,就会出现脑子和手打架的窘境。

举例来说:我在写某个主题的时候,会突然联想到一个分支想法,它不适合放进当前的正文,但是与之又有关联。如果是传统的笔记方法,这里可以使用便签(callout),但这样终究不太适合内容组织。因此类似mindmap的工具都会提供二维的内容输出方式。

但是这会引入一个新问题——发散思考之后,如何输出高可读性的内容。

不知道大家有没有这样的体验:自己做的思维导图再烂也能看懂,而别人做的再好也看得很晕。

这就是非线性(发散性)内容的弊端,除非是自己生产(即已完成内化)的内容,否则非线性内容先天就更难理解。

因此我们需要找到一个允许我们发散性思考,但是又能快捷的输出线性内容的的方法。

线性输出脚本的前身

当我产生了这个需求的时候,我先是尝试用obsidian的引用功能来实现它,因为excalidraw本身是支持对外提供内容的嵌入的。但是很难做到方便快捷。

于是我在网上查询资料,了解到两位先驱者的探索:

学习了两位网友关于excalidraw笔记如何实现线性输出的思考,其中

  • Note必利阀制作了一个脚本,可以把excalidraw中选中的文本和图像按编辑的时间顺序输出为文字与图像的引用,最终复制到剪贴板,我们只需要把这个粘贴到想要用的地方就行
  • 熊猫别熬夜制作了一套脚本,要求使用者在excalidraw编写好符合一定格式的标题,然后通过脚本把标题与对应的内容引用出来

前者的优势是输出的内容为文字与图片链接,是可以被标准md识别的,但缺少了excalidraw的强大图形能力(因为它要求把freedraw转成图片,后续再编辑也是很麻烦的)。另外因为excalidraw的Frame和Group还不支持嵌套,所以如果有画中画这样的展现形式,则无法用后者的脚本实现。

后者直接把excalidraw中的图形引用过来,能更好的保留excalidraw的功能,不过这样也导致如果有发布文章的需求,后续可能需要再手动去把excalidraw引用转换为图片。

在学习两位的过程中,我厘清了 发散性思考线性输出 的概念,同时基于我自己的日记工作流,对熊猫别熬夜的脚本进行了修改,最终我的线性输出脚本诞生了。

线性输出脚本

脚本介绍

本脚本的全名应该叫 excalidraw线性输出到同名笔记,它的功能也很简单,一言以蔽之:通过识别规定格式的文本,把与文本组合的内容以excalidraw的嵌入链接形式输出到对应笔记的指定标题下。

如下图所示:
assets/Pasted image 20240907152957.png

本脚本通过识别形如 #1 标题 的文本,解析为 标题,并将该文本所属的组合(优先级分别为:Frame>Group>Element)引用链接插入到标题之后。

脚本的优点:
1. 保留了excalidraw的图形能力
2. 将内容输出到指定文件并生成逐级标题,让思考输出的内容可以与文件本身融合,大纲可识别
3. 支持自定义在哪个标题后插入,并且会根据设置标题动态调整生成标题的层级(比如设置在 #灵感 后插入,则从二级标题开始生成,如果设置为 ##灵感,则会从三级标题开始生成,确保生成内容为子内容)

脚本使用说明

下载

你可以在我的Github下载:dangehub/aqu_ob_share: Share my Obsidian techniques

或者在文末直接复制源代码,自己新建一个md文件粘贴进去就好。

安装使用

  1. 把脚本放到excalidraw的script目录下
  2. 前往excalidraw插件设置,在最后一项 已安装脚本设置 中修改 Custom Misc Header,设置为自己想要插入在哪个标题后,参考值 ## 1.3 杂记
  3. 点击脚本按钮or使用命令工具
  4. 1.excalidraw 的线性内容会被输出到 1 中的 ## 1.3 杂记 标题下

视频教程见:obsidian+excalidraw+线性输出脚本=快乐日记

附脚本源代码

“`

// 获取脚本设置

let settings = ea.getScriptSettings();

// 设置默认值(如果是首次运行)

if (!settings[“Custom Misc Header”]) {

settings = {

…settings,  // 保留现有设置

“Custom Misc Header”: {

value: “## 1.3 杂记”,

description: “自定义杂记标题,用于插入 Excalidraw 内容”

}

};

ea.setScriptSettings(settings);

}

// 使用设置中的自定义杂记标题

const customMiscHeader = settings[“Custom Misc Header”].value;

// 计算customMiscHeader中的 #数量

const customHeaderLevel = (customMiscHeader.match(/^#+/) || [”])[0].length;

// 获取笔记的基本路径和笔记名

const currentFile = app.workspace.getActiveFile();

if (!currentFile) {

new Notice(“❌ 无法获取当前文件”, 3000);

return;

}

// 获取excalidraw文件路径、文件名,准备生成对应笔记
const filePath = currentFile.path;

const fileName = currentFile.name;

const fileBaseName = fileName.replace(‘.excalidraw’, ”);

// 初始化变量

let frameIds = [];

let extrTexts = ”;

// 获取所有以’#’开头的文本元素(即标题)

let allEls = ea.getViewElements().filter(el => el.type === “text” && el.text.startsWith(‘#’));

// 对标题进行排序

allEls.sort((a, b) => {

let aMatch = a.text.match(/^#([\d.]+)/);

let bMatch = b.text.match(/^#([\d.]+)/);

if (!aMatch || !bMatch) return 0;

let aParts = aMatch[1].split(‘.’).map(Number);

let bParts = bMatch[1].split(‘.’).map(Number);

for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {

if (aParts[i] === undefined) return -1;

if (bParts[i] === undefined) return 1;

if (aParts[i] !== bParts[i]) return aParts[i] – bParts[i];

}

return 0;

});

for (let i of allEls) {

let elText = i.rawText.trim(); // 使用 rawText 而不是 text,以规避换行符问题

let elID = i.id;

let match = elText.match(/^#([\d.]+)\s+(.*)/);

if (!match) continue;

let numberPart = match[1];

let titlePart = match[2];

// 计算标题级别

let levels = numberPart.split(‘.’).length;
    let headLevel = Math.min(levels + customHeaderLevel, 6); // 根据customMiscHeader的级别调整

let heads = ‘#’.repeat(headLevel);

let titleText = “”;

let titleLink = “”;

let embedlinks = [];

let nums = 99;

// 处理excalidraw中的Frame、Group

if (i.frameId && !frameIds.includes(i.frameId)) {

elID = i.frameId;

frameIds.push(elID);

titleLink = ${fileName}#^frame=${elID};

for (let j of ea.getViewElements().filter(el => el.type === “embeddable”)) {

if (j.frameId == elID) {

embedlinks.push(\n!${j.link})

let objectFrame = ea.getViewElements().filter(el => el.frameId === elID);

nums = objectFrame.length;

}

}

} else if (i.groupIds) {

titleLink = ${fileName}#^group=${elID};

for (let j of ea.getViewElements().filter(el => el.type === “embeddable”)) {

if (j.groupIds.some(groupId => i.groupIds.includes(groupId))) {

embedlinks.push(\n!${j.link})

let objectFrame = ea.getViewElements().filter(el => el.groupIds.some(groupId => i.groupIds.includes(groupId)));

nums = objectFrame.length;

}

}

} else {

titleLink = ${fileName}#^${elID};

}

// 生成标题文本

if (embedlinks.length > 0) {

let extrEmbedlinks = embedlinks.join(‘\r\n’);

titleText = ${heads} ${titlePart}\n${extrEmbedlinks}\n;

if (nums > 3) {

titleText += ![[${titleLink}]]\n;

}

} else {

titleText = ${heads} ${titlePart}\n;

if (nums > 2) {

titleText += ![[${titleLink}]]\n;

}

}

extrTexts += titleText;

}

// 构建输出文件路径

let outputFileName = ${fileBaseName}.md;

let outputPath = filePath.replace(‘.excalidraw’, ”);

// 检查输出文件是否存在

let outputFile = app.vault.getAbstractFileByPath(outputPath);

if (!outputFile) {

new Notice(❌ 输出文件不存在:${outputPath}, 3000);

// 尝试创建文件

try {

await app.vault.create(outputPath, ”);

outputFile = app.vault.getAbstractFileByPath(outputPath);

new Notice(✅ 已创建新文件:${outputPath}, 2000);

} catch (error) {

new Notice(❌ 无法创建文件:${outputPath}, 3000);

return;

}

}

// 读取输出文件内容

let outputContent = await app.vault.read(outputFile);

// 创建唯一标识符

let excalidrawIdentifier = EXCALIDRAW_CONTENT_${fileName.replace(/[^a-zA-Z0-9]/g, "_")};

// 构建新的 Excalidraw 内容

let newExcalidrawContent = <!-- BEGIN ${excalidrawIdentifier} -->\n${extrTexts}\n<!-- END ${excalidrawIdentifier} -->;

// 检查是否已存在 Excalidraw 内容

let startMarker = <!-- BEGIN ${excalidrawIdentifier} -->;

let endMarker = <!-- END ${excalidrawIdentifier} -->;

let startIndex = outputContent.indexOf(startMarker);

let endIndex = outputContent.indexOf(endMarker);

// 辅助函数:获取标题级别

function getHeaderLevel(header) {

return header.match(/^#+/)[0].length;

}

// 辅助函数:查找下一个相同或更高级别的标题

function findNextHeader(content, startIndex, currentLevel) {

const headerRegex = /^#{1,6}\s/gm;

headerRegex.lastIndex = startIndex;

let match;

while ((match = headerRegex.exec(content)) !== null) {

if (getHeaderLevel(match[0]) <= currentLevel) {

return match.index;

}

}

return content.length;

}

if (startIndex !== -1 && endIndex !== -1) {

// 如果存在,更新现有内容

outputContent = outputContent.substring(0, startIndex) +

newExcalidrawContent +

outputContent.substring(endIndex + endMarker.length);

} else {

// 如果不存在,在自定义杂记标题后插入新内容

let miscIndex = outputContent.indexOf(customMiscHeader);

if (miscIndex !== -1) {

let currentHeaderLevel = getHeaderLevel(customMiscHeader);

// 找到下一个相同或更高级别的标题或文件末尾

let nextHeaderIndex = findNextHeader(outputContent, miscIndex + customMiscHeader.length, currentHeaderLevel);

// 在自定义杂记标题和下一个标题之间插入新内容

outputContent = outputContent.substring(0, nextHeaderIndex) +

“\n\n” + newExcalidrawContent + “\n\n” +

outputContent.substring(nextHeaderIndex);

} else {

// 如果没有找到自定义杂记标题,则在文件末尾添加

outputContent += \n\n${customMiscHeader}\n\n + newExcalidrawContent;

}

}

// 更新输出文件

await app.vault.modify(outputFile, outputContent);

new Notice(✅ Excalidraw 内容已更新到文件:${outputPath}, 2000);
“`

本文永久更新地址:

https://blogs.qudange.top/p/%e4%b8%80%e9%94%ae%e6%8a%8aexcalidraw%e7%9a%84%e5%a4%b4%e8%84%91%e9%a3%8e%e6%9a%b4%e8%be%93%e5%87%ba%e4%b8%ba%e7%ba%bf%e6%80%a7%e6%96%87%e7%ab%a0/

#excalidraw #[2] #脚本
2024 年 9 月 7 日 84 1

回复

曲淡歌
曲淡歌@qudange

2024 年 8 月 26 日

安卓透明代理,上手 box for magisk 

前言

在使用安卓设备的时候也有科学上网的需求,而直接使用app层级的代理会遇见以下几种问题:
1. 部分app会检测代理:比如soul微调,检测代理的主要原因都是为了防破解/防抓包
2. vpn app不稳定,容易被杀后台
3. 在使用命令行时无法通过app代理
综上所述,需要实现透明代理。目前有两个项目是比较符合的我的需求,同时又有着比较低的上手门槛的:
1. 神秘盒子:基于singbox的代理模块,无法使用自定义的分流配置,但是上手简单,有app作为图形化界面
2. box for magisk:支持诸如v2ray和clash等核心,支持自定义配置,~~目前没有图形化界面~~ 可以用app管理,但是不能在app里设置订阅。

项目实践

安装记录

  1. 下载并刷入模块
  2. 在刷入时用音量+选择下载内核,或者刷入后使用命令行
    su -c /data/adb/box/scripts/box.tool upcore(此命令为更新指定的内核,需要先确认配置文件)
   # 更新 Clash 管理面板
su -c /data/adb/box/scripts/box.tool upyacd
  1. 配置/data/adb/box/settings.ini
interva_update="@daily"  #更新频率
run_crontab="true"  #开启定时更新
subscription_url_clash="<订阅地址>"  #配置clash订阅地址,如果handshake报错可以把https改为http
renew=true  #采用订阅文件中的分流规则
  1. 终端中运行命令su -c /data/adb/box/scripts/box.tool subs
  2. 如果有需要更新Geo数据库,可以采用su -c /data/adb/box/scripts/box.tool geox
  3. 如果需要同时更新订阅与Geo,可以使用su -c /data/adb/box/scripts/box.tool subs

本文永久更新地址:

https://blogs.qudange.top/p/2024-07-14-box-for-magisk/

#[5] #[17] #[4] #[2]
2024 年 8 月 26 日 152
曲淡歌
曲淡歌@qudange

2024 年 8 月 12 日

Obsidian 实现复制时自动上传图片到图床 

Obsidian 实现复制时自动上传图片到图床

【QuickAdd脚本】带图复制-自动上传图片到图床 – 经验分享 – Obsidian 中文论坛

我新开一个库可以用,但是在主力库里就不行,报错为

assets/Pasted image 20240808164552.png

QuickAdd: (ERROR) failed to run user script 带
图复制.Error:
The “path" argument must be of type string.
Received undlefined

这个问题很奇怪,我在新开的空白ob库里没有遇见,但是在主力库就有这个问题。

怎么排查问题?

  • 尝试删除js脚本后再执行命令,看看是不是脚本的问题——删除后报错找不到脚本,看来不是这个问题。

然后我想为什么空白库是对的,多半是插件或者设置的问题,然后我把脚本发给kimi,kimi分析到一个关键点,就是文件路径。

于是我发现问题了:试用的时候发现一个问题:

如果内部链接类型设置为基于当前笔记的相对路径,脚本会报错
QuickAdd: (ERROR) failed to run user script 带图 复制.Error: The “path" argument must be of type string. Received undefined

采用绝对路径也会有同样问题。改为尽量短路径就正常了。

用chatgpt修复这个bug(还得是chatgpt哇),修复版的脚本为:

const path = require('path');
const quickAddApi = app.plugins.plugins.quickadd.api;
const { editor, file, containerEl } = app.workspace.activeEditor;
const url = "http://127.0.0.1:36677/upload";

module.exports = async () => {
  const files = app.vault.getFiles();
  let selection = "";
  let content = "";
  selection = editor.getSelection();
  console.log("Selected text:", selection);

  for (let line of selection.split("\n")) {
    let embed = "";
    if (line) {
      embed = matchSelectionEmbed(line);
    }
    console.log("Matched embed:", embed);

    if (embed && /\.(png|jpg|jpeg|gif|bmp)$/.test(embed)) {
      let wikiPath = getFilePath(files, embed); // 匹配Wiki链接
      if (!wikiPath) {
        new Notice(`❌无法找到文件: ${embed}`);
        console.log(`❌无法找到文件: ${embed}`);
        continue;
      }

      // 获取绝对路径
      const imgPath = app.vault.adapter.getFullPath(wikiPath);
      console.log("Image path:", imgPath);

      const data = await uploadFiles([imgPath], url);
      if (data.success) {
        const imgWiki = `![[${embed}]]`;
        const imgLink = `![${embed}](${data.result})`;
        line = line.replace(imgWiki, imgLink);
      } else {
        new Notice(`❌上传 ${path.basename(imgPath)} 图片失败`);
        console.log(`❌上传 ${path.basename(imgPath)} 图片失败`);
      }
    }
    content += line + "\n";
  }

  console.log("Final content:", content);
  copyToClipboard(content)
  new Notice(`✅复制成功`);
};

// 获取文件路径函数
function getFilePath(files, baseName) {
  let matchingFiles = files.filter(f => {
    const fullPath = f.path;
    console.log(`Comparing ${fullPath} with ${baseName}`);
    return fullPath.endsWith(baseName);
  });

  if (matchingFiles.length === 0) {
    console.log(`No files matched for: ${baseName}`);
    return undefined;
  }

  return matchingFiles[0].path;
}

function matchSelectionEmbed(text) {
  const regex = /!\[\[?([^\]]*?)(\|.*)?\]\]?\(?([^)\n]*)\)?/;
  const matches = text.match(regex);
  if (!matches) return;
  if (matches[3]) return decodeURIComponent(matches[3]);
  if (matches[1]) return decodeURIComponent(matches[1]);
}

async function uploadFiles(imagePathList, url) {
  const response = await requestUrl({
    url: url,
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ list: imagePathList }),
  });
  const data = response.json; // 直接访问 `json` 属性,而不是调用 `json()` 方法
  return data;
};

function copyToClipboard(extrTexts) {
  const txtArea = document.createElement('textarea');
  txtArea.value = extrTexts;
  document.body.appendChild(txtArea);
  txtArea.select();
  if (document.execCommand('copy')) {
      console.log('copy to clipboard.');
  } else {
      console.log('fail to copy.');
  }
  document.body.removeChild(txtArea);
}

使用方法

  1. 安装插件quickadd
  2. 在quickadd中设置脚本存放目录 Template Folder Path,然后在对应目录下新建 带图复制.js,把代码粘贴进去
  3. 新建一个宏,选择刚刚新建的脚本
  4. 安装piclist,配置好图床
  5. 在obsidian中选中要分享的文本,其中需要包含要上传的图片,然后ctrl+p使用脚本
  6. 粘贴即可

直接把图片上传到github

用 github publisher 插件能把图片上传到 github,但是图片在文章中的格式是 [[]] 的 wiki 链接,因此我们需要通过正则的方式来转换格式。

参考这篇文章 obsidian图片链接转换成markdown语法,不关闭wiki链接_obsidian图片显示变成链接-CSDN博客

同时上面这个方法还可以解决这个问题:obsidian 粘贴进来的图片名字会自动带空格,如 Pasted image 20240806221817.png

最后发布后就能看到在 github 是可以正常查看图片了,但是为了同步到其他平台,可以批量的在图片路径前面加上 https://github.com/dangehub/github2wp/blob/main/_posts

方法也很简单,就是搜索 assets/,然后替换为 https://github.com/dangehub/github2wp/blob/main/_posts/assets/

  • 但是为什么同步到 wordpress 的文章里图片没有了?

同步到 wp 的是 html 代码:

<p><img alt="" src="https://github.com/dangehub/github2wp/blob/main/_posts/assets/Pasted%20image%2020240806214536.png" /></p>

直接访问这个链接是对的,但是这段 html 代码不能正常工作。
比如放到 obsidian 中:



上面的分隔线中就是这段 html 代码,无法显示图像。为什么?

去调试台看了下,报错 (失败)net::ERR_BLOCKED_BY_ORB,~~这是跨域问题~~

但是都没人说 github 的图片会限制跨域,检查之后发现 https://github.com/dangehub/github2wp/blob/b85405e3ef0c15a3caf57a038a545e0842d43996/_posts/assets/Pasted%20image%2020240806214536.png 指向的不是图片本身,https://github.com/dangehub/github2wp/blob/b85405e3ef0c15a3caf57a038a545e0842d43996/_posts/assets/Pasted%20image%2020240806214536.png?raw=true 才是图片本身,替换为这个链接就好了。

即用 png?raw=true 替换 png

本文永久更新地址:

https://blogs.qudange.top/p/obsidian%20%e5%ae%9e%e7%8e%b0%e5%a4%8d%e5%88%b6%e6%97%b6%e8%87%aa%e5%8a%a8%e4%b8%8a%e4%bc%a0%e5%9b%be%e7%89%87%e5%88%b0%e5%9b%be%e5%ba%8a/

#[17]
2024 年 8 月 12 日 42 46
曲淡歌
曲淡歌@qudange

2024 年 8 月 11 日

Obsidian 实现复制时自动上传图片到图床 

Obsidian 实现复制时自动上传图片到图床

【QuickAdd脚本】带图复制-自动上传图片到图床 – 经验分享 – Obsidian 中文论坛

我新开一个库可以用,但是在主力库里就不行,报错为

assets/Pasted image 20240808164552.png

QuickAdd: (ERROR) failed to run user script 带
图复制.Error:
The “path" argument must be of type string.
Received undlefined

这个问题很奇怪,我在新开的空白ob库里没有遇见,但是在主力库就有这个问题。

怎么排查问题?

  • 尝试删除js脚本后再执行命令,看看是不是脚本的问题——删除后报错找不到脚本,看来不是这个问题。

然后我想为什么空白库是对的,多半是插件或者设置的问题,然后我把脚本发给kimi,kimi分析到一个关键点,就是文件路径。

于是我发现问题了:试用的时候发现一个问题:

如果内部链接类型设置为基于当前笔记的相对路径,脚本会报错
QuickAdd: (ERROR) failed to run user script 带图 复制.Error: The “path" argument must be of type string. Received undefined

采用绝对路径也会有同样问题。改为尽量短路径就正常了。

用chatgpt修复这个bug(还得是chatgpt哇),修复版的脚本为:

const path = require('path');
const quickAddApi = app.plugins.plugins.quickadd.api;
const { editor, file, containerEl } = app.workspace.activeEditor;
const url = "http://127.0.0.1:36677/upload";

module.exports = async () => {
  const files = app.vault.getFiles();
  let selection = "";
  let content = "";
  selection = editor.getSelection();
  console.log("Selected text:", selection);

  for (let line of selection.split("\n")) {
    let embed = "";
    if (line) {
      embed = matchSelectionEmbed(line);
    }
    console.log("Matched embed:", embed);

    if (embed && /\.(png|jpg|jpeg|gif|bmp)$/.test(embed)) {
      let wikiPath = getFilePath(files, embed); // 匹配Wiki链接
      if (!wikiPath) {
        new Notice(`❌无法找到文件: ${embed}`);
        console.log(`❌无法找到文件: ${embed}`);
        continue;
      }

      // 获取绝对路径
      const imgPath = app.vault.adapter.getFullPath(wikiPath);
      console.log("Image path:", imgPath);

      const data = await uploadFiles([imgPath], url);
      if (data.success) {
        const imgWiki = `![[${embed}]]`;
        const imgLink = `![${embed}](${data.result})`;
        line = line.replace(imgWiki, imgLink);
      } else {
        new Notice(`❌上传 ${path.basename(imgPath)} 图片失败`);
        console.log(`❌上传 ${path.basename(imgPath)} 图片失败`);
      }
    }
    content += line + "\n";
  }

  console.log("Final content:", content);
  copyToClipboard(content)
  new Notice(`✅复制成功`);
};

// 获取文件路径函数
function getFilePath(files, baseName) {
  let matchingFiles = files.filter(f => {
    const fullPath = f.path;
    console.log(`Comparing ${fullPath} with ${baseName}`);
    return fullPath.endsWith(baseName);
  });

  if (matchingFiles.length === 0) {
    console.log(`No files matched for: ${baseName}`);
    return undefined;
  }

  return matchingFiles[0].path;
}

function matchSelectionEmbed(text) {
  const regex = /!\[\[?([^\]]*?)(\|.*)?\]\]?\(?([^)\n]*)\)?/;
  const matches = text.match(regex);
  if (!matches) return;
  if (matches[3]) return decodeURIComponent(matches[3]);
  if (matches[1]) return decodeURIComponent(matches[1]);
}

async function uploadFiles(imagePathList, url) {
  const response = await requestUrl({
    url: url,
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ list: imagePathList }),
  });
  const data = response.json; // 直接访问 `json` 属性,而不是调用 `json()` 方法
  return data;
};

function copyToClipboard(extrTexts) {
  const txtArea = document.createElement('textarea');
  txtArea.value = extrTexts;
  document.body.appendChild(txtArea);
  txtArea.select();
  if (document.execCommand('copy')) {
      console.log('copy to clipboard.');
  } else {
      console.log('fail to copy.');
  }
  document.body.removeChild(txtArea);
}

使用方法

  1. 安装插件quickadd
  2. 在quickadd中设置脚本存放目录 Template Folder Path,然后在对应目录下新建 带图复制.js,把代码粘贴进去
  3. 新建一个宏,选择刚刚新建的脚本
  4. 安装piclist,配置好图床
  5. 在obsidian中选中要分享的文本,其中需要包含要上传的图片,然后ctrl+p使用脚本
  6. 粘贴即可

直接把图片上传到github

用 github publisher 插件能把图片上传到 github,但是图片在文章中的格式是 [[]] 的 wiki 链接,因此我们需要通过正则的方式来转换格式。

参考这篇文章 obsidian图片链接转换成markdown语法,不关闭wiki链接_obsidian图片显示变成链接-CSDN博客

同时上面这个方法还可以解决这个问题:obsidian 粘贴进来的图片名字会自动带空格,如 Pasted image 20240806221817.png

最后发布后就能看到在 github 是可以正常查看图片了,但是为了同步到其他平台,可以批量的在图片路径前面加上 https://github.com/dangehub/github2wp/blob/main/_posts

方法也很简单,就是搜索 assets/,然后替换为 https://github.com/dangehub/github2wp/blob/main/_posts/assets/

  • 但是为什么同步到 wordpress 的文章里图片没有了?

同步到 wp 的是 html 代码:

<p><img alt="" src="https://github.com/dangehub/github2wp/blob/main/_posts/assets/Pasted%20image%2020240806214536.png" /></p>

直接访问这个链接是对的,但是这段 html 代码不能正常工作。
比如放到 obsidian 中:



上面的分隔线中就是这段 html 代码,无法显示图像。为什么?

去调试台看了下,报错 (失败)net::ERR_BLOCKED_BY_ORB,~~这是跨域问题~~

但是都没人说 github 的图片会限制跨域,检查之后发现 https://github.com/dangehub/github2wp/blob/b85405e3ef0c15a3caf57a038a545e0842d43996/_posts/assets/Pasted%20image%2020240806214536.png 指向的不是图片本身,https://github.com/dangehub/github2wp/blob/b85405e3ef0c15a3caf57a038a545e0842d43996/_posts/assets/Pasted%20image%2020240806214536.png?raw=true 才是图片本身,替换为这个链接就好了。

即用 png?raw=true 替换 png

本文永久更新地址:

https://blogs.qudange.top/p/2024-08-11-obsidian-pic-upload/

#[17]
2024 年 8 月 11 日 143
曲淡歌
曲淡歌@qudange

2024 年 8 月 7 日

用 n8n 实现三步翻译 

用 defy 搭建三步翻译老是报错,想自托管发现 defy 的配置要求很高,于是准备先用之前的 n 8 n 试试。

更新 n 8 n:1.0.4 更新到 1.44.1

采用这样的结构时,会报错 Expected to find the prompt in an input field called 'chatInput' (this is what the chat trigger node outputs). To use something else, change the 'Prompt' parameter

感觉像是个 bug,明明已经能读取前两个节点的输出,但是 n 8 n 还是提示无法读取到上上个节点。然后细查日志发现报错

NodeOperationError: No prompt specified at getPromptInputByType (/usr/local/lib/node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/dist/utils/helpers.js:71:15) at Object.execute (/usr/local/lib/node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/dist/nodes/chains/ChainLLM/ChainLlm.node.js:412:65) at Workflow.runNode (/usr/local/lib/node_modules/n8n/node_modules/n8n-workflow/dist/Workflow.js:728:19) at /usr/local/lib/node_modules/n8n/node_modules/n8n-core/dist/WorkflowExecute.js:673:51 at /usr/local/lib/node_modules/n8n/node_modules/n8n-core/dist/WorkflowExecute.js:1086:20

后面我发现问题来自于 prompt 的设置,原来每个 llm chain 一开始就有一个 prompt 设置,而默认是继承自上一个节点,而我的第一个 llm chain 节点就没设置它。

搞定后展示一下翻译效果:

原文:
It’s so creepy and weird. I’ll go back and play DoDS and HL 2 DM etc and it’s still like a chat room while you play, it makes gaming so much more casual and enjoyable. I play this game and try to engage in any way and get literally no response from anyone. Is it just because this is a newer game so it’s full of antisocial teenagers who don’t know how to talk to each other? I just honestly don’t get it. It’s not that you’re obligated to chat, but it seems very weird to pretend that you’re not playing with other human beings.

Edit: I actually think this is why I lose interest in this game so quickly. In the old school games my team could be getting destroyed but it doesn’t bother me because we’re all chatting and having a good time. In this game it’s just silence anyway, doesn’t even feel like I’m playing against actual people so I don’t feel bad leaving in the middle of a match if we’re getting rolled.

Google 翻译:
这太令人毛骨悚然和奇怪了。我会回去玩 DoDS 和 HL 2 DM 等游戏,玩的时候它仍然像一个聊天室,它让游戏变得更加休闲和愉快。我玩这个游戏并尝试以任何方式参与,但几乎没有得到任何人的回应。难道只是因为这是一款较新的游戏,所以里面充满了不知道如何与对方交谈的反社会青少年?老实说我不明白。这并不是说你有义务聊天,但假装你没有和其他人一起玩似乎很奇怪。
编辑:我实际上认为这就是我这么快对这款游戏失去兴趣的原因。在老式的学校比赛中,我的球队可能会被摧毁,但这并不困扰我,因为我们都在聊天,玩得很开心。无论如何,在这款游戏中,一切都是沉默,甚至感觉不到我是在和真人比赛,所以如果我们被淘汰,我不会因为在比赛中途离开而感到难过。

三步翻译:
实在是太奇怪而且让人有点不舒服了。回想当年玩《DoD:S》和《HL 2 DM》等游戏的时候,那种体验就像是在一间聊天室里面玩游戏,让整个游戏玩起来更加轻松且愉快。但当我玩这款游戏时,不管我怎么试图互动,却几乎得不到任何人的回应。难道仅仅因为这是一款新游戏,所以里面都是些不太愿意交流彼此的社恐少年吗?我真的搞不清楚是怎么回事。我不是说大家必须聊天,但完全假装没有和其他玩家一起玩的感觉总是让人觉得有点别扭。
编辑:我想这可能是导致我对这款游戏兴趣迅速消退的原因之一吧。在游戏中,即便是我们队伍大败亏输,我也不至于特别沮丧,因为我们那时都在愉快地聊天。但在这款游戏中,整个世界就像是一片寂静,根本感受不到自己是在跟其他玩家对战,所以我们遭受挫败时,我中途退出时也就没有什么内疚感了。

下面展示一下三步翻译的过程:

直译:
真是太诡异又让人不舒服。我回到过去玩 DoDS 和 HL 2 DM 等游戏时,感觉就像在一个聊天室里游戏,这让整个游戏体验变得更加轻松愉快。然而玩这款游戏时,无论我怎样尝试互动,却几乎得不到任何人的回应。难道仅仅因为这是一款新游戏,所以满是那些不懂得彼此交谈的社交恐惧少年?我真的不明白这是怎么回事。我不是说大家非得聊天不可,只是假装没有和其他玩家一起游戏,总让我觉得有些奇怪。\n\n 编辑:我想明白了,这可能就是我迅速对这个游戏失去兴趣的原因。在以前的老游戏中,即使我的队伍被杀得人仰马翻,我也不会感到烦闷,因为我们都在欢快地聊天。而在这款游戏中,四周一片寂静,根本感受不到是在和真人对战,所以我们被碾压时,我中途离开也不会有丝毫的愧疚感。

然后是用 ai 进行反思:
<直接翻译>中存在的问题:\n\n 1. 不符合中文的表达习惯:\n – “真是太诡异又让人不舒服”:中文通常不以”真是太…”这样的句式表达负面强烈的情感。\n \n 2. 句子结构笨拙:\n – “我玩这款游戏时,无论我怎样尝试互动,几乎得不到任何人的回应”:应改为“当我玩这款游戏时,无论怎样尝试互动,几乎得不到任何人的回应”更加自然流畅。\n \n 3. 表达含糊不清,难以理解:\n – “难道仅仅因为这是一款新游戏, 所以满是那些不懂得彼此交谈的社交恐惧少年?” : 整个句子表达过于生硬,不易于理解其意图。\n – “而这款游戏中, 四周一片寂静, 根本感受不到是在和真人对战”: 这里使用“真人”来指代其他在线玩家显得较为突兀且不自然。一般情况下,在这种语境中并不会强调对战的对象是否为真实的人,因为在网络游戏中默认就是与真人对战。\n\n 4. 表达不够地道:\n – “所以, 我们被碾压时, 我中途离开也不会有丝毫的愧疚感” : 愧疚感的表达在此处显得过于严肃,通常在网络游戏中玩家们会更倾向于用更加随意的语气来描述此类情况。\n\n 注: 以上指出的问题并未提供修改后的版本。

此处我采用的是通义千问的 qwen-long 模型,它的实力在目前的 llm 中算不上非常强劲,这正好能说明三步翻译的强劲实力。

做成 api 来配合划词翻译

先启动 n 8 n 的测试模式,然后用划词翻译发送一次,得到划词翻译的发包格式

"body": {

"model": "qwen-long",

"messages": [

{

"role": "user",

"content": "This is an embeddable Chat widget for n8n. It allows the execution of AI-Powered Workflows through a Chat window."

}

然后需要根据格式调整 n8n

n8n 自动翻译完之后的输出为


[

{

"text": "<改进翻译>\n\n这是一款专为 n8n 设计的、可嵌入式的聊天工具,它使用户能够在聊天窗口内执行由人工智能驱动的工作流程。\n\n在这个版本里解决了以下问题:\n1. 调整了“可嵌入式”的位置,使之更符合中文的语言习惯。\n2. 简化并调整了句子结构,将“它能让用户通过聊天窗口来运行由人工智能驱动的工作流程”改写为更贴合中文表达习惯和流畅度的表述:“它使用户能够在聊天窗口内执行由人工智能驱动的工作流程”。\n3. 增强了句子之间逻辑关系的连贯性。"

}

]

而划词翻译需要接受来自 openai 格式的json,因此需要把上面n8n 的输出转换一下。

openai 格式参考为:

{
  "id": "b3e86c70-bd28-995b-a6e9-c47ab55c6495",
  "model": "qwen",
  "object": "chat.completion",
  "created": 1722959731,
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Hello! How can I assist you today?"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 1,
    "completion_tokens": 9,
    "total_tokens": 10
  }
}

注意,在http request 中不能使用引号和换行。

在 prompt 中要求不要使用引号,然后用节点来处理换行符,最后得到效果如下:

可以看到这个效果已经相当好了。

本文永久更新地址:

https://blogs.qudange.top/p/2024-08-07-n8n_3steps_translate/

#[17] #翻译
2024 年 8 月 7 日 152 45
曲淡歌
曲淡歌@qudange

2024 年 7 月 16 日

语音笔记AudioPen的“平替” 

前言

在b站发出AudioPen的视频后,有网友问有没有能自己部署的项目做平替,那今天就来分析一下如何找到AudioPen的平替。

现有项目

AudioPen的工作原理其实并不复杂,语音转文字–>文字发给LLM,也就是说它并没有核心技术,只是现有技术的整合,因此如果去google以audio notes为关键词搜索,能找到好几个类似的软件,但是他们的定价策略甚至还比不过AudioPen,因此不考虑这类软件。

语音输入法+LLM

最简单的办法就是用语音输入法输入一段文字,然后发给任何一个大语言模型(比如ChatGPT),然后告诉它:“你现在是速录员,请把下面的口语整理为通顺的书面表达”,通过微调提示词,可以得到不同的效果。

Alog:ios平台+需要网络

如果使用的苹果设备,可以去app store搜索alog,这是一个可以使用自己的LLM key的软件,同时还支持apple watch端,是一个我比较看好的软件,它采用ios内置的本地语音转文字功能,仅需要自己去解决第二步的问题,而互联网上有很多免费的key获取途径。

OralPen:ios平台+需要网络

Allwillcome/OralPen: 出口, AI 成章| Record voice and refine it into language ChatGPT understands.

这是一个使用ios的快捷指令实现的脚本,也是通过ios内置的本地语音转文字,然后将转录稿发送给自定义的LLM,其实只要读懂了这个脚本,几乎就可以在任何一种设备上复刻AudioPen。

![[assets/Pasted image 20240716175420.png|assets/Pasted image 20240716175420.png]]

whisper+llm本地离线:任何平台+无需网络

通过上面的两个例子,我们完全可以通过whisper本地模型来转录语音,再用LLM来处理文字,如果本地算力足够,可以通过如ollama等服务在本地部署自己的LLM并通过api来使用它。

小结

看上去有很多方法,但殊途同归,他们都是采用的同样一套原理。但是AudioPen的效果出奇的好,可能作者在某些细节进行了处理,因此,如果想得到最好的效果,目前来看,我还是推荐付费使用产品。如果只是想尝鲜,完全可以用语音输入法那个方案,同时像ChatGPT本身就支持语音输入,也可以直接让它帮你总结。

本文永久更新地址:

https://blogs.qudange.top/p/2024-07-16-audiopen-substitute/

#[17] #[4] #[3] #知识管理 #笔记软件
2024 年 7 月 16 日 101 43
曲淡歌
曲淡歌@qudange

2024 年 7 月 15 日

AudioPen:让Ai帮你整理碎碎念,语音转书面化文字的利器 

AudioPen:让Ai帮你整理碎碎念,口语文字书面化的利器

本文标题又为:除非你比我的独处更迷人

前言

你在独处的时候,会自言自语吗?

你觉得你的自言自语有没有价值呢?

如果有,应该如何利用它?

如果上述的3个问题的答案都是肯定的,那么请你看下去,一定会有所收获。


利用ChatGPT将口语书面化

本节标题又为:一个不破碎的你,终于拯救了一个破碎的我

在上班的路上,在放学的途中,在独处的思维漫步时,在骑着单车享受夏风时,我都喜欢碎碎念。

诸军,我喜欢碎碎念。因为我喜欢和自己对话,这让我感觉自己的思绪从未如此清晰过。

我和我谈天说地,从宇宙大爆炸聊到国际局势,从游戏版本平衡聊到习习晚风,从花前月下聊到幽微……

曾经我苦于无法将这些独处的迷人记录,而现在我发现了一种方法:语音识别+大语言模型总结。

有很多办法去实现它,如ios的捷径,一个名叫alog的app,但是我最后选择了AudioPen这个web应用,只有一个原因:它的综合效果最好。

AudioPen简介

简单介绍一下它的使用方法:
打开网页–>开始录音–>对着麦克风谈天说地–>完成录音,app开始总结–>得到一个书面化的文字总结,包括标题和内容(如果不满意的话,可以调整书面化的力度)

书面化力度分三档:
1. 低:稍微清理,去除口语词
2. 中:保留口语结构与词汇,以选定的风格重写
3. 高:在中等基础上,可能会重组其内容,可以设置长度限制

预设的风格如下:
1. Casual Memo:Write in a clear, slightly formal, and witty style. Don’t use very complex language or sentence structure.
2. Simple & Clear:Friendly, flowing, simple sentences. Use simple words. Prioritize clarity above all else.
3. Descriptive:Create vivid images for the reader using sensory language, metaphors, similes, and other figures of speech.
4. Legal:Write in a legal style. Use highly formal language that is precise, includes complex sentences, and legal terminology.
5. List:Create a bulleted list. Use short sentences. Ensure each point is written within a bullet.
6. Write like me:Write in a style similar to the following text:【】Replace this text with a few paragraphs of your own writing.

AudioPen效果展示

如果你对它的不同档位感兴趣的话,下面附上原始转录的文本与低中高三档重写后的文本。风格均采用Casual Memo

1. 原始转录

注:这里的原始转录已经被去除了语气词,如果想看直接录音后识别重写的效果,请前往我的b站账号【曲淡歌】查看相关视频。

来聊一下我对守望先锋的一些感受和看法。 第一点先聊一下地图设计吧。 我觉得地图设计首先要遵循一个底层逻辑, 就是优秀的地图设计必须做到 让玩家凭借着直觉也能获得较好的游戏体验。 或者换句话说,整个游戏的底层逻辑都应该是这样。 为什么说要让玩家凭借直觉也能获得较好的游戏体验呢? 我们这里举一个正向例子和一个反面例子。 正面例子就是国王大盗。 国王大盗作为一个几乎所有人都喜欢的地图, 他的优秀在哪?我们后面会谈到。 然后我们再举一个反面例子,月球基地。 为什么大家都讨厌月球基地? 那么我们结合国王大盗的优秀, 我们来谈一谈他们的差别在哪? 我觉得一言以蔽之就是 国王大盗是一个任何玩家凭着直觉都能获得较好体验的地图, 而月球基地是反直觉的,也不是完全不能玩, 但是如果没有经过刻意的分析和训练, 没有经过学习,你很难获得一个良好的体验。 就像一款游戏,如果需要看攻略才能够玩得舒服, 那作为游戏,它本身是比较失败的, 或者说是有很高能量的。 国王大盗他不需要引导, 然后凭着直觉就能玩的一个重要的表现就在于 他给进攻方和防守方都提供了相对来说比较平等的地理环境。 一般来说,守望先锋的非对称地图, 防守方都是有优势的, 而进攻方因为重生点更近, 所以一般会有一些地形上的劣势, 往往体现为防守方有高台可以高打低, 防守方有更多的掩体可以借助掩体来回避伤害, 然后获得喘息的机会。 那我们接着看国王大盗, 他的A点进攻是平地, 防守方虽然有一个高台, 但这个高台踩着没有这么舒服, 因此大部分人都会站在一楼和进攻方在同一个平面交战, 同时他的掩体又是相对来说比较公平的, 也就是说防守方和进攻方都有足够多的掩体使用, 同时进攻方又可以从左手的小房间进行绕后偷袭, 但是这个绕后也可以由防守方执行, 也就是说进攻方和防守方在根据直觉向前走的路上 就能够获得他们需要获得的一切。 而相比之下,为什么月球基地让大部分人都很讨厌? 其中有一点就是月球基地的防守方是自然而然的踩到了二楼, 而进攻方是自然而然的走一楼。 当然月球基地可以走二楼进攻, 但走二楼是需要付出一定的代价的, 因为前往二楼的路上需要被对面收很多过路费。 其次,因为进攻有三条路线可以选择, 所以在玩家没有足够的经验支撑的前提下, 有很多玩家不会选择走二楼。 这就是地图的设计对玩家的引导作用。 然后我再谈一谈英雄设计。 依然延续我之前说的一个观点, 就是优秀的设计是要让玩家凭着直觉就可以做出正确的行为。 那么,什么叫优秀的英雄设计呢? 首先我们要确定我们想把这个游戏变成什么样子, 究竟是团队作战还是单打独斗? 什么叫优秀的顺应直觉的设计? 比如说,法机会飞,天使也会飞, 法机和天使一般会被放到一起使用, 这个就是符合直觉的。 大锤可以举盾,但盾碎掉之后是需要一定的缓冲时间, 这个时候可以用矛魅来提供一个短暂的护盾来渡过他的技能增攻期, 所以就形成了经典的逼推体系。 而到了守望先锋2,从6v6变成5v5之后, 我们可以看到有很多经典的设计被破坏了, 同时带来了很多强调单打独斗、个人英雄主义的设计, 这就导致这个游戏在团队和个人之间不断地来回拉扯, 让玩家感觉到很痛苦。 典型例子就是伊拉瑞,作为一个辅助, 他的奶量大部分都来自于他的炮台, 而他的炮台是不可以移动的, 每次释放都要很小心, 这就带来一个后果就是在很多地图他都不是很好用, 然后他过于强大的输出, 就让玩家用他的时候会经常忘记来人。 更有甚者,有些伊拉瑞玩家会自己一个人走一遍, 然后把光塔放出来给自己一个人用, 相当于队友就少了一个辅助。 这个就是很差的设计。 然后在最近的古丁更新了这么一条, 就是增大了光塔的奶量, 但是伊拉瑞自己受到光塔的治疗会减慢。 我觉得这就是一个很好的设计, 通过这样的设计就可以自然而然地引导玩家去为团队做贡献。 当然我并不是说单打独斗是错误的, 但是如果要把这款游戏定位成团队英雄设计游戏, 那么你就不应该出太多的个人英雄主义英雄。 但我当初对守望天空下的这样一个判断, 如果这款游戏想要活得长久, 那就应该让全员都转向个人英雄主义, 因为这样可以尽量减少玩家的挫败感。 当然这个问题在于, 如果你不去修改老英雄, 只是出新英雄, 那么会诱导玩家群体的分裂, 因为新的个人英雄主义英雄和旧的团队主义英雄 势必会产生割裂。 那么我们再谈一谈 守望先锋一和守望先锋二。 我觉得守望先锋一是一款很有匠心的游戏, 而守望先锋二明显是一款为了市场和金钱妥协的游戏。 它其实在我心中根本算不上二代, 因为它没有出现本质上的区别, 也没有足够多的内容。 它只是在一代的基础上换了一个皮。 而且换皮这个动作本身也让人感到很失望, 因为一代的UI音乐,包括很多功能, 其实已经做得很好。 二代为了凸显它和一代的不同, 强行修改了这些东西。 即便到现在都有很多不完善的地方。 至于很多人说一代台速度很慢, 更新这些东西, 它本质上是为了二代买单的呀。 守望先锋一末期有两年停更的时间, 没有更新。 没有更新所以导致游戏玩家流失严重, 然后越来越多的玩家走了, 导致排位速度变慢。 那如果它正常更新, 肯定不可能是现在这个样子。 但是守望先锋二更新的内容, 其实就是守望先锋一本来该更新的内容。 你不能拿守望先锋一没有更, 但它本应该更新的东西, 来作为守望先锋二打败守望先锋一的坛子。 这样去对比是不合理的。 而守望先锋二它自己独特的卖点, 比如PVE这些东西, 其实它都没有实现, 它只是出了新地图新英雄, 这些本来就应该由守望先锋一推出的东西。 然后守望先锋二其实就是为了改变 守望先锋一的付费方式的一个噱头。 本来是当年承诺的买单自由行, 现在却要改成内购点卡自由, 内购游戏,微交易游戏, 因此它需要了一些借口。 包括它当年留下的承诺, 玩家是可以选择玩守望先锋一还是守望先锋二, 这些承诺都被出耳翻耳, 这也是让人觉得很好笑的事情。

2. 低档

本例子无法用低档重写,原因未知。

3. 中档

标题:《守望先锋》感受分享

内容:

来聊一下我对《守望先锋》的感受和看法。

先说地图设计。我觉得优秀的地图设计要让玩家凭直觉就能获得好的游戏体验。举个例子,国王大道是一个大家都喜欢的地图,因为它让玩家凭直觉就能玩得很好。而月球基地则相反,需要经过分析和训练才能获得良好体验。游戏如果需要看攻略才能玩得舒服,那它本身就是失败的。

国王大道的设计给进攻方和防守方提供了相对平等的地理环境。进攻方和防守方都有足够的掩体使用,可以绕后偷袭。相比之下,月球基地的防守方自然占据二楼,而进攻方走一楼。虽然进攻可以选择二楼,但需要付出代价。因此,很多玩家不愿意走二楼,这就是地图设计对玩家引导作用的体现。

再谈英雄设计。优秀的设计要让玩家凭直觉做出正确行为。例如,法鸡和天使会飞,一般会被放到一起使用,这符合直觉。而大锤可以举盾,但盾碎掉后需要缓冲时间,可以用毛妹提供短暂护盾。这些都是经典的设计。

到了《守望先锋2》,从6v6变成5v5,很多经典设计被破坏,带来了强调单打独斗的设计,让玩家感觉痛苦。例如伊拉瑞,她的大部分治疗来自炮台,而炮台不能移动,每次释放都要小心。这导致她在很多地图上不好用,并且她强大的输出让玩家常常忘记团队合作。

最近更新增大了光塔治疗量,但伊拉瑞自己受到治疗会减慢,这是一个好的设计,引导玩家为团队做贡献。如果游戏定位是团队合作,就不应该有太多个人英雄主义英雄。但如果想让游戏长久,就应该转向个人英雄主义,以减少玩家挫败感。

最后谈《守望先锋1》和《守望先锋2》。我觉得《守望先锋1》是一款很有匠心的游戏,而《守望先锋2》明显为了市场和金钱妥协。它没有本质上的区别,只是在一代基础上换了个皮。即使这样,《守望先锋2》也没有实现它独特的卖点,比如PVE模式,只是推出新地图新英雄,这些本来就应该由一代推出。

总之,《守望先锋2》只是为了改变付费方式而存在,本来承诺买断制,现在改成内购,这让人感到失望。

4. 高档

标题:《守望先锋》体验分析

内容:

让我来谈谈我对《守望先锋》的感受和看法。首先,我们聊聊地图设计。优秀的地图设计应遵循一个底层逻辑:让玩家凭借直觉也能获得良好的游戏体验。举个例子,《国王大道》几乎人人喜爱,因为它的设计让玩家不需特别引导就能享受游戏。而《月球基地》则相反,防守方天然占据二楼优势,进攻方如果不经过刻意学习,很难有好的体验。

再谈英雄设计。优秀的英雄设计也是顺应直觉的,比如法鸡和天使常被搭配使用,这符合直觉。而大锤举盾后可以用矛魅提供短暂护盾,这形成了经典的逼推体系。但在《守望先锋2》中,从6v6变成5v5后,很多经典设计被破坏,强调个人英雄主义的设计让团队合作变得痛苦。比如伊拉瑞,她的大部分治疗来自炮台,但炮台不能移动,这在某些地图上效果不好。

最后,我们比较《守望先锋1》和《守望先锋2》。一代是一款匠心独具的游戏,而二代明显为了市场和金钱妥协。它没有本质上的区别,只是在一代基础上换了个皮,而且很多功能反而不如一代完善。更糟糕的是,二代为了改变付费方式,违背了当初的承诺,让人觉得很失望。

总之,《守望先锋2》并没有带来足够的新内容,只是利用了原本一代该有的更新。这种做法不仅让人失望,还破坏了玩家对游戏的信任。


前人工智能时代,ASR悄然积蓄力量

自动语音识别,即ASR(Automatic Speech Recognition, ASR ),在那个ChatGPT还没有出现的年代,就已经很成熟了,即便是面向大众的民用技术,也能做到百分之九十左右。我印象中,十年前就有用语音输入法写小说的网文作者,像科大讯飞更是以语音识别为核心技术卖点,它家的语音输入效果也确实很好。

那个时代,我们就已经习惯了生活中的语音输入,但是它的作用好像也仅限于“唤狗”这种层级的操作,就像现在依然还停留在上个时代的智能语音助手,往往靠复杂的脚本来完成功能,因此说一句话让它“设置一个早上八点的闹钟”,和用一种独特的口哨来达到相同的目的,从原理上并没有区别。


后人工智能时代,LLM让Ai再次伟大

十年前吴恩达就说过,99%的准确度是语音识别的质变时刻,而即便到今天,单纯的ASR也不敢保证做到99%的精确度。

或者从某种意义上讲,人类的非标性让机器伤透了脑筋。如果你曾经进行过演讲/口播,就会发现人的说话其实充斥着没有意义的停顿和语气词,也就说完全准确的识别一个人的话语,并不能准确得到他想表达的意思。换言之,话语的冗余量很大。

但是人为什么可以理解另一个人的所说?因为人能够理解并筛选。而大语言模型(LLM)的出现,为机器理解人提供的出路。现在我们只需要用已经很成熟的ASR技术识别完语音,再把转录好的文字稿发送给LLM,让LLM来理解并以书面化的表达来重写口语稿,就能得到容易理解的文字了。

本文永久更新地址:

https://blogs.qudange.top/p/2024-07-15-audiopen/

#[17] #[4]
2024 年 7 月 15 日 137 82
曲淡歌
曲淡歌@qudange

2024 年 7 月 14 日

obcsapi —— 最好的obsidian工具(需要云部署) 

前言

为什么很多人会纠结用notion还是obsidian?因为notion具有强大的在线功能,一旦使用场景超越了单设备,obsidian用户就不得不面临诸如“同步”、“发布”和“跨设备”的问题。

我使用obsidian三年有余,尝试了几乎所有同步方式,最终得出结论:目前现成的解决方法不可能得到完美体验(指对标原生在线的笔记应用,如notion)。

因此首先需要厘清自己的需求,再因地制宜去改造obsidian,这也是obsidian最大的优点:客制化潜力巨大(来源于它使用md格式文件和它繁荣的插件社区)。

比如我的工作流包括以下场景:
1. 在pc上使用obsidian客户端写笔记
2. 在手机上看b站视频、浏览器网页的时候想要分享我看的东西
3. 在手机上记录memos
4. 在手机上查看我的obsidian笔记库并编辑

其中最麻烦的事是如何在手机上得到良好体验,obsidian虽然有官方的app,但是体验不佳。而且在手机端进行复杂编辑还想要好体验本身就是一个伪命题(除非外接显示器和键盘),因此我选择使用网页(当然这里做出了妥协,即放弃了ob各种强大的功能,网页上只做简单的文字编辑),这样直接解决了跨平台的问题。

通过上面的分析后,拆解我的工作流来分析需求,可以得到三个版块
1. 同步
2. 信息输入
3. web发布

obcsapi几乎完美的符合了我的需求,下面我将正式开始介绍obcsapi这个开源项目。

obcsapi是由中国obsidian用户恐咖兵糖开发的一款obsidian工具,其官方介绍如下:

基于 Obsidian S3 存储, CouchDb ,本地存储和WebDAV 的后端 API ,可借助 Obsidian 插件 Remotely-Save 插件,或者 Self-hosted LiveSync (ex:Obsidian-livesync) 插件 CouchDb 方式,保存消息到 Obsidian 库。或者支持本地文件夹的文本编辑器。特点

  • 前端添加 Memos / 简答编辑 , 支持指令模式,有黑暗主题 ,是 PWA 应用
  • 微信测试号 微信到 Obsidian
  • 支持简悦 SimpRead Webook 裁剪网页文章
  • 支持 fv悬浮球文字图片分享保存
  • 静读天下 MoonReader 高亮标注 仿 ReadWise API
  • 通用 http api
  • 使用 Lua & Bash 拓展功能。用户可以处理任何请求
  • WebDAV 服务
  • 一个简易图床,附带命令行上传工具。
  • 云函数 或者 Dokcer 部署

可以看到obcsapi的使用前提是需要自己部署,我个人是采用自租vps部署,当然也可以使用云函数+对象存储,Nas+内网穿透等方法。

它可以做到包括但不限于如下功能:

  • 部署一个web页面,可以访问、搜索并修改obsidian整个库
  • web页面类memos,可以快速记录灵感想法

图片

  • 支持api输入,可以使用任何支持http quest的工具集成,如fv悬浮球/静读天下/简悦/ios的捷径/tasker/Windows Quicker等
  • 支持微信公众号测试号、企业微信,可以从微信端传入信息
  • 自带webdav服务,可以把obsidian库作为webdav的目录,这样就能与remotely-save等插件集成,进行同步
  • 自带图床,web上传图片自动存到图床并插入笔记
  • 支持分钟级待办提醒(微信推送、邮件提醒),邮件提醒可以自动识别最近三天日记中的所有待办并且每天早上发送邮件提醒,即每日提醒功能

还有更多功能见文档Obcsapi使用说明

最后实际的使用效果见我的b站视频:Obcsapi:如何让obsidian和notion打擂台?【元知识】_哔哩哔哩_bilibili

本文永久更新地址:

https://blogs.qudange.top/p/2024-07-14-obcsapi/

#[17] #[4]
2024 年 7 月 14 日 355 76
曲淡歌
曲淡歌@qudange

2024 年 7 月 13 日

安卓端用tasker实现ai总结b站 

前言

本文的实现方式受到chrome拓展ChatGPTBox的启发,原理是通过b站的api和cookie获得自动ai字幕,然后用大语言模型(LLM)来总结字幕,从而获得视频的总结。

此工具的功能是帮助用户快速总结视频内容,以提高信息获取的速度。诚然,b站自己也推出了ai总结功能,但是那个功能很不稳定,有时候对一个视频有详细而结构化的总结,有时却只能得到一句话的概括,即便LLM已经火了两三年了,b站仍没有在这块投入过多,因此只能自己动手,丰衣足食。

本工具的缺点:
1. 无法总结没有字幕的视频(但是像bibigpt和b站官方的ai总结有时候能总结没有字幕的视频,我怀疑是有其他途径获取字幕)
2. 对视频内容和音频内容非强相关的视频无效(例如一个ASMR催眠视频,如果只用声音来判断,可能整个视频都是没有意义的。我们期待在未来多模态的ai能解决这个问题)
3. 需要使用b站的cookie才能获取视频字幕,而cookie是动态变化的,因此需要cookie刷新机制,我的处理办法是使用cookiecloud(需要自己的服务器/托管在别人的服务器上)

安卓端利用tasker实现ai总结b站

工作原理

  1. 如果视频是手机分享的短链接形式(https://b23.tv/xxxxx ),则通过域名重定向获取bid(BVxxxxxx)
  2. 已知bid后,获取cid https://api.bilibili.com/x/web-interface/view?bvid=%bid
  3. 已知bid和cid后,获取包含字幕链接subtitle_url的信息https://api.bilibili.com/x/player/v2?cid=%cid&bvid=%bid
  4. 将字幕发送通过api发送给ai,让ai总结

案例:

以视频【游戏试玩】杀戮尖塔+娃娃机=抓抓地牢?游戏实况 为例,如果采用安卓端国内版bilibili应用分享链接,得到的链接为【【游戏试玩】杀戮尖塔+娃娃机=抓抓地牢?-哔哩哔哩】 https://b23.tv/jpcq7rz

(隐藏的第一步为获取最新的cookie,因为实现方法不唯一,在下一章节介绍)

因此第一步是要从文字中提取出链接,一般采用正则

(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]

得到结果为https://b23.tv/jpcq7rz,然后我们需要将短链接转为长链接,最简单的办法就是使用浏览器的重定向功能,最终将得到https://www.bilibili.com/video/BV1DC411J7Wy/

然后使用正则提取出bid,正则如下:

BV([^/?]+)

即得到BV1DC411J7Wy

然后执行http get https://api.bilibili.com/x/web-interface/view?bvid=BV1DC411J7Wy 可以获取一个json文件,需要从中提取cid,因此执行两次正则

正则1(先匹配cid):

"cid":(\d+)

得到:"cid":1524209082

正则2(提取cid的值):

(\d+)

得到1524209082

然后获取字幕url,执行http get https://api.bilibili.com/x/player/v2?cid=1524209082&bvid=BV1DC411J7Wy
注意,header中需要加入cookie,如

User-Agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
Cookie:%cookieString

然后会获得一个包含键为subtitle_url的json文件(前提是视频有字幕),因此这里为了避免不存在字幕产生的误会,需要做一个有无的判断

用正则进行匹配

//[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]

如果不匹配,则提示此视频无字幕,若满足则进行下一步

subtitle_url执行http get,获得json,提取键content

然后把content的内容http post给LLM

headers:

Authorization: Bearer %api_key

body:

{
    "model": "%llm_model",
    "messages": [
        {
            "role": "user",
            "content": "用尽量简练的语言,采用markdown语法书写(不要用代码块包裹),联系视频标题,对视频进行内容摘要,同时仍要保留重要细节和标题信息,视频标题为:%bili_name,字幕内容为:%subtitle"
        }
    ],
    "use_search": true,
    "stream": false
}

然后就能获得ai总结的内容了

同步cookie

我选择使用开源项目CookieCloud实现cookie的同步

变量解释:
%cookiecloud_urlcookiecloud服务器域名,例如:https://cookiecloud.25wz.cn/,注意,结尾没有/
%cookiecloud_uuid:见cookiecloud插件文档
%cookiecloud_key:见cookiecloud插件文档

步骤详解:

执行http get %cookiecloud_url/get/%cookiecloud_uuid
请求的body如下:

password%cookiecloud_key

这一步会获得解码后的json,包括cookie和localdata

然后通过JavaScriptlet来格式化cookie

var jsonData = JSON.parse(local('%http_data'));
var cookies = jsonData.cookie_data[".bilibili.com"];
var result = [];

for (var i = 0; i < cookies.length; i++) {
    var name = cookies[i].name;
    var value = cookies[i].value;
    result.push(name + "=" + value);
}

// 将结果存储在Tasker的全局变量中
setGlobal('cookieString', result.join(';'));

注:其实这一步理论上只需要SESSDATA,我为了省事这么写了。
格式化得到a=1,b=2,c=3格式的cookie信息,然后参见上一章完成目标即可。

本文永久更新地址:

https://blogs.qudange.top/p/2024-07-13-android_tasker_ai_summary/

#Ai #[5] #[17] #gpt #LLM
2024 年 7 月 13 日 202 64

Loading...
载入中

已到底部

没有可加载的页面

C
写微博
S
搜索
J
下一篇微博/评论
K
上一篇微博/评论
R
回复
E
编辑
O
显示隐藏评论
T
回顶部
L
登录
H
显示隐藏帮助
Ctrl+Enter
提交发布
ESC
取消并清除内容